summaryrefslogtreecommitdiff
path: root/packages/backend
diff options
context:
space:
mode:
authormisskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com>2025-02-27 08:58:43 +0000
committerGitHub <noreply@github.com>2025-02-27 08:58:43 +0000
commita5f28c21e47030a9202de9ccf87556c5bebd7129 (patch)
treebd161b9620622d5bdc0d0a6a48dad7eda95c931d /packages/backend
parentMerge pull request #15378 from misskey-dev/develop (diff)
parentRelease: 2025.2.1 (diff)
downloadmisskey-a5f28c21e47030a9202de9ccf87556c5bebd7129.tar.gz
misskey-a5f28c21e47030a9202de9ccf87556c5bebd7129.tar.bz2
misskey-a5f28c21e47030a9202de9ccf87556c5bebd7129.zip
Merge pull request #15507 from misskey-dev/develop
Release: 2025.2.1
Diffstat (limited to 'packages/backend')
-rw-r--r--packages/backend/.swcrc2
-rw-r--r--packages/backend/migration/1739006797620-GoogleAnalytics.js16
-rw-r--r--packages/backend/package.json138
-rw-r--r--packages/backend/src/config.ts11
-rw-r--r--packages/backend/src/core/CaptchaService.ts6
-rw-r--r--packages/backend/src/core/DownloadService.ts4
-rw-r--r--packages/backend/src/core/DriveService.ts7
-rw-r--r--packages/backend/src/core/EmailService.ts7
-rw-r--r--packages/backend/src/core/FanoutTimelineService.ts3
-rw-r--r--packages/backend/src/core/GlobalEventService.ts2
-rw-r--r--packages/backend/src/core/HttpRequestService.ts53
-rw-r--r--packages/backend/src/core/MfmService.ts3
-rw-r--r--packages/backend/src/core/NoteDeleteService.ts24
-rw-r--r--packages/backend/src/core/RemoteUserResolveService.ts2
-rw-r--r--packages/backend/src/core/SearchService.ts2
-rw-r--r--packages/backend/src/core/UserWebhookService.ts2
-rw-r--r--packages/backend/src/core/UtilityService.ts8
-rw-r--r--packages/backend/src/core/WebAuthnService.ts18
-rw-r--r--packages/backend/src/core/activitypub/ApDeliverManagerService.ts19
-rw-r--r--packages/backend/src/core/activitypub/ApRendererService.ts4
-rw-r--r--packages/backend/src/core/activitypub/ApRequestService.ts8
-rw-r--r--packages/backend/src/core/activitypub/ApResolverService.ts21
-rw-r--r--packages/backend/src/core/activitypub/misc/check-against-url.ts126
-rw-r--r--packages/backend/src/core/entities/MetaEntityService.ts1
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts4
-rw-r--r--packages/backend/src/misc/json-schema.ts4
-rw-r--r--packages/backend/src/misc/json-value.ts2
-rw-r--r--packages/backend/src/models/Meta.ts6
-rw-r--r--packages/backend/src/models/Notification.ts4
-rw-r--r--packages/backend/src/models/User.ts8
-rw-r--r--packages/backend/src/models/json-schema/meta.ts4
-rw-r--r--packages/backend/src/models/json-schema/notification.ts10
-rw-r--r--packages/backend/src/postgres.ts2
-rw-r--r--packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts2
-rw-r--r--packages/backend/src/queue/processors/InboxProcessorService.ts4
-rw-r--r--packages/backend/src/queue/types.ts10
-rw-r--r--packages/backend/src/server/ServerService.ts37
-rw-r--r--packages/backend/src/server/api/endpoints.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/meta.ts6
-rw-r--r--packages/backend/src/server/api/endpoints/admin/update-meta.ts9
-rw-r--r--packages/backend/src/server/api/endpoints/ap/show.ts12
-rw-r--r--packages/backend/src/server/api/endpoints/clips/create.ts6
-rw-r--r--packages/backend/src/server/api/endpoints/clips/update.ts6
-rw-r--r--packages/backend/src/server/api/endpoints/following/invalidate.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/miauth/gen-token.ts5
-rw-r--r--packages/backend/src/server/api/openapi/gen-spec.ts10
-rw-r--r--packages/backend/src/server/api/stream/channel.ts6
-rw-r--r--packages/backend/src/server/web/boot.embed.js12
-rw-r--r--packages/backend/src/server/web/boot.js38
-rw-r--r--packages/backend/src/server/web/error.css107
-rw-r--r--packages/backend/src/server/web/error.js40
-rw-r--r--packages/backend/src/server/web/views/error.pug52
-rw-r--r--packages/backend/src/types.ts2
-rw-r--r--packages/backend/test-federation/test/note.test.ts106
-rw-r--r--packages/backend/test-federation/test/utils.ts2
-rw-r--r--packages/backend/test-server/.swcrc2
-rw-r--r--packages/backend/test/e2e/clips.ts36
-rw-r--r--packages/backend/test/e2e/fetch-resource.ts10
-rw-r--r--packages/backend/test/e2e/timelines.ts2
-rw-r--r--packages/backend/test/unit/MfmService.ts4
-rw-r--r--packages/backend/test/unit/RelayService.ts3
-rw-r--r--packages/backend/test/unit/ap-request.ts125
62 files changed, 885 insertions, 304 deletions
diff --git a/packages/backend/.swcrc b/packages/backend/.swcrc
index 845190b5f4..f4bf7a4d2a 100644
--- a/packages/backend/.swcrc
+++ b/packages/backend/.swcrc
@@ -1,5 +1,5 @@
{
- "$schema": "https://json.schemastore.org/swcrc",
+ "$schema": "https://swc.rs/schema.json",
"jsc": {
"parser": {
"syntax": "typescript",
diff --git a/packages/backend/migration/1739006797620-GoogleAnalytics.js b/packages/backend/migration/1739006797620-GoogleAnalytics.js
new file mode 100644
index 0000000000..5871bf098a
--- /dev/null
+++ b/packages/backend/migration/1739006797620-GoogleAnalytics.js
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class GoogleAnalytics1739006797620 {
+ name = 'GoogleAnalytics1739006797620'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "meta" ADD "googleAnalyticsMeasurementId" character varying(64)`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "googleAnalyticsMeasurementId"`);
+ }
+}
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 757912755a..cee5c7205b 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -37,20 +37,20 @@
},
"optionalDependencies": {
"@swc/core-android-arm64": "1.3.11",
- "@swc/core-darwin-arm64": "1.3.56",
- "@swc/core-darwin-x64": "1.3.56",
+ "@swc/core-darwin-arm64": "1.10.16",
+ "@swc/core-darwin-x64": "1.10.16",
"@swc/core-freebsd-x64": "1.3.11",
- "@swc/core-linux-arm-gnueabihf": "1.3.56",
- "@swc/core-linux-arm64-gnu": "1.3.56",
- "@swc/core-linux-arm64-musl": "1.3.56",
- "@swc/core-linux-x64-gnu": "1.3.56",
- "@swc/core-linux-x64-musl": "1.3.56",
- "@swc/core-win32-arm64-msvc": "1.3.56",
- "@swc/core-win32-ia32-msvc": "1.3.56",
- "@swc/core-win32-x64-msvc": "1.3.56",
- "@tensorflow/tfjs": "4.4.0",
- "@tensorflow/tfjs-node": "4.4.0",
- "bufferutil": "4.0.7",
+ "@swc/core-linux-arm-gnueabihf": "1.10.16",
+ "@swc/core-linux-arm64-gnu": "1.10.16",
+ "@swc/core-linux-arm64-musl": "1.10.16",
+ "@swc/core-linux-x64-gnu": "1.10.16",
+ "@swc/core-linux-x64-musl": "1.10.16",
+ "@swc/core-win32-arm64-msvc": "1.10.16",
+ "@swc/core-win32-ia32-msvc": "1.10.16",
+ "@swc/core-win32-x64-msvc": "1.10.16",
+ "@tensorflow/tfjs": "4.22.0",
+ "@tensorflow/tfjs-node": "4.22.0",
+ "bufferutil": "4.0.9",
"slacc-android-arm-eabi": "0.0.10",
"slacc-android-arm64": "0.0.10",
"slacc-darwin-arm64": "0.0.10",
@@ -64,37 +64,37 @@
"slacc-linux-x64-musl": "0.0.10",
"slacc-win32-arm64-msvc": "0.0.10",
"slacc-win32-x64-msvc": "0.0.10",
- "utf-8-validate": "6.0.3"
+ "utf-8-validate": "6.0.5"
},
"dependencies": {
- "@aws-sdk/client-s3": "3.620.0",
- "@aws-sdk/lib-storage": "3.620.0",
- "@bull-board/api": "6.5.0",
- "@bull-board/fastify": "6.5.0",
- "@bull-board/ui": "6.5.0",
+ "@aws-sdk/client-s3": "3.749.0",
+ "@aws-sdk/lib-storage": "3.749.0",
+ "@bull-board/api": "6.7.7",
+ "@bull-board/fastify": "6.7.7",
+ "@bull-board/ui": "6.7.7",
"@discordapp/twemoji": "15.1.0",
- "@fastify/accepts": "5.0.1",
- "@fastify/cookie": "11.0.1",
- "@fastify/cors": "10.0.1",
- "@fastify/express": "4.0.1",
- "@fastify/http-proxy": "10.0.1",
- "@fastify/multipart": "9.0.1",
- "@fastify/static": "8.0.2",
- "@fastify/view": "10.0.1",
+ "@fastify/accepts": "5.0.2",
+ "@fastify/cookie": "11.0.2",
+ "@fastify/cors": "10.0.2",
+ "@fastify/express": "4.0.2",
+ "@fastify/http-proxy": "10.0.2",
+ "@fastify/multipart": "9.0.3",
+ "@fastify/static": "8.1.0",
+ "@fastify/view": "10.0.2",
"@misskey-dev/sharp-read-bmp": "1.2.0",
- "@misskey-dev/summaly": "5.1.0",
- "@napi-rs/canvas": "0.1.56",
- "@nestjs/common": "10.4.7",
- "@nestjs/core": "10.4.7",
- "@nestjs/testing": "10.4.7",
+ "@misskey-dev/summaly": "5.2.0",
+ "@napi-rs/canvas": "0.1.67",
+ "@nestjs/common": "11.0.9",
+ "@nestjs/core": "11.0.9",
+ "@nestjs/testing": "11.0.9",
"@peertube/http-signature": "1.7.0",
- "@sentry/node": "8.38.0",
- "@sentry/profiling-node": "8.38.0",
- "@simplewebauthn/server": "10.0.1",
- "@sinonjs/fake-timers": "11.2.2",
+ "@sentry/node": "8.55.0",
+ "@sentry/profiling-node": "8.55.0",
+ "@simplewebauthn/server": "12.0.0",
+ "@sinonjs/fake-timers": "11.3.1",
"@smithy/node-http-handler": "2.5.0",
- "@swc/cli": "0.3.12",
- "@swc/core": "1.9.2",
+ "@swc/cli": "0.6.0",
+ "@swc/core": "1.10.16",
"@twemoji/parser": "15.1.1",
"accepts": "1.3.8",
"ajv": "8.17.1",
@@ -103,10 +103,10 @@
"bcryptjs": "2.4.3",
"blurhash": "2.0.5",
"body-parser": "1.20.3",
- "bullmq": "5.26.1",
+ "bullmq": "5.41.1",
"cacheable-lookup": "7.0.0",
"cbor": "9.0.2",
- "chalk": "5.3.0",
+ "chalk": "5.4.1",
"chalk-template": "1.1.0",
"chokidar": "3.6.0",
"cli-highlight": "2.1.11",
@@ -114,46 +114,46 @@
"content-disposition": "0.5.4",
"date-fns": "2.30.0",
"deep-email-validator": "0.1.21",
- "fastify": "5.0.0",
+ "fastify": "5.2.1",
"fastify-raw-body": "5.0.0",
"feed": "4.2.2",
"file-type": "19.6.0",
"fluent-ffmpeg": "2.1.3",
- "form-data": "4.0.1",
- "got": "14.4.4",
- "happy-dom": "15.11.4",
+ "form-data": "4.0.2",
+ "got": "14.4.6",
+ "happy-dom": "16.8.1",
"hpagent": "1.2.0",
"htmlescape": "1.1.1",
"http-link-header": "1.1.3",
- "ioredis": "5.4.1",
+ "ioredis": "5.5.0",
"ip-cidr": "4.0.2",
"ipaddr.js": "2.2.0",
"is-svg": "5.1.0",
"js-yaml": "4.1.0",
- "jsdom": "24.1.1",
+ "jsdom": "26.0.0",
"json5": "2.2.3",
- "jsonld": "8.3.2",
+ "jsonld": "8.3.3",
"jsrsasign": "11.1.0",
"juice": "11.0.0",
- "meilisearch": "0.45.0",
+ "meilisearch": "0.48.2",
"mfm-js": "0.24.0",
"microformats-parser": "2.0.2",
"mime-types": "2.1.35",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"ms": "3.0.0-canary.1",
- "nanoid": "5.0.8",
+ "nanoid": "5.1.0",
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
- "nodemailer": "6.9.16",
+ "nodemailer": "6.10.0",
"nsfwjs": "4.2.0",
"oauth": "0.10.0",
"oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14",
- "otpauth": "9.3.4",
+ "otpauth": "9.3.6",
"parse5": "7.2.1",
- "pg": "8.13.1",
+ "pg": "8.13.3",
"pkce-challenge": "4.1.0",
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
@@ -167,19 +167,19 @@
"rename": "1.0.4",
"rss-parser": "3.13.0",
"rxjs": "7.8.1",
- "sanitize-html": "2.13.1",
- "secure-json-parse": "2.7.0",
+ "sanitize-html": "2.14.0",
+ "secure-json-parse": "3.0.2",
"sharp": "0.33.5",
"slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
- "systeminformation": "5.23.5",
+ "systeminformation": "5.25.11",
"tinycolor2": "1.6.0",
"tmp": "0.2.3",
"tsc-alias": "1.8.10",
"tsconfig-paths": "4.2.0",
"typeorm": "0.3.20",
- "typescript": "5.6.3",
+ "typescript": "5.7.3",
"ulid": "2.3.0",
"vary": "1.1.2",
"web-push": "3.6.7",
@@ -188,8 +188,8 @@
},
"devDependencies": {
"@jest/globals": "29.7.0",
- "@nestjs/platform-express": "10.4.7",
- "@simplewebauthn/types": "10.0.0",
+ "@nestjs/platform-express": "10.4.15",
+ "@simplewebauthn/types": "12.0.0",
"@swc/jest": "0.2.37",
"@types/accepts": "1.3.7",
"@types/archiver": "6.0.3",
@@ -204,15 +204,15 @@
"@types/js-yaml": "4.0.9",
"@types/jsdom": "21.1.7",
"@types/jsonld": "1.5.15",
- "@types/jsrsasign": "10.5.14",
+ "@types/jsrsasign": "10.5.15",
"@types/mime-types": "2.1.4",
"@types/ms": "0.7.34",
- "@types/node": "22.9.0",
- "@types/nodemailer": "6.4.16",
+ "@types/node": "22.13.4",
+ "@types/nodemailer": "6.4.17",
"@types/oauth": "0.9.6",
"@types/oauth2orize": "1.11.5",
"@types/oauth2orize-pkce": "0.1.2",
- "@types/pg": "8.11.10",
+ "@types/pg": "8.11.11",
"@types/pug": "2.0.10",
"@types/qrcode": "1.5.5",
"@types/random-seed": "0.3.5",
@@ -226,18 +226,18 @@
"@types/tmp": "0.2.6",
"@types/vary": "1.1.3",
"@types/web-push": "3.6.4",
- "@types/ws": "8.5.13",
- "@typescript-eslint/eslint-plugin": "7.17.0",
- "@typescript-eslint/parser": "7.17.0",
- "aws-sdk-client-mock": "4.0.1",
+ "@types/ws": "8.5.14",
+ "@typescript-eslint/eslint-plugin": "8.24.0",
+ "@typescript-eslint/parser": "8.24.0",
+ "aws-sdk-client-mock": "4.1.0",
"cross-env": "7.0.3",
- "eslint-plugin-import": "2.30.0",
+ "eslint-plugin-import": "2.31.0",
"execa": "8.0.1",
"fkill": "9.0.0",
"jest": "29.7.0",
"jest-mock": "29.7.0",
- "nodemon": "3.1.7",
- "pid-port": "1.0.0",
+ "nodemon": "3.1.9",
+ "pid-port": "1.0.2",
"simple-oauth2": "5.1.0"
}
}
diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts
index c0b1484804..32ea700748 100644
--- a/packages/backend/src/config.ts
+++ b/packages/backend/src/config.ts
@@ -73,6 +73,7 @@ type Source = {
proxyBypassHosts?: string[];
allowedPrivateNetworks?: string[];
+ disallowExternalApRedirect?: boolean;
maxFileSize?: number;
@@ -105,8 +106,8 @@ type Source = {
logging?: {
sql?: {
- disableQueryTruncation? : boolean,
- enableQueryParamLogging? : boolean,
+ disableQueryTruncation?: boolean,
+ enableQueryParamLogging?: boolean,
}
}
};
@@ -149,6 +150,7 @@ export type Config = {
proxySmtp: string | undefined;
proxyBypassHosts: string[] | undefined;
allowedPrivateNetworks: string[] | undefined;
+ disallowExternalApRedirect: boolean;
maxFileSize: number;
clusterLimit: number | undefined;
id: string;
@@ -166,8 +168,8 @@ export type Config = {
signToActivityPubGet: boolean | undefined;
logging?: {
sql?: {
- disableQueryTruncation? : boolean,
- enableQueryParamLogging? : boolean,
+ disableQueryTruncation?: boolean,
+ enableQueryParamLogging?: boolean,
}
}
@@ -287,6 +289,7 @@ export function loadConfig(): Config {
proxySmtp: config.proxySmtp,
proxyBypassHosts: config.proxyBypassHosts,
allowedPrivateNetworks: config.allowedPrivateNetworks,
+ disallowExternalApRedirect: config.disallowExternalApRedirect ?? false,
maxFileSize: config.maxFileSize ?? 262144000,
clusterLimit: config.clusterLimit,
outgoingAddress: config.outgoingAddress,
diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts
index 8c7f66236e..ee081f29b0 100644
--- a/packages/backend/src/core/CaptchaService.ts
+++ b/packages/backend/src/core/CaptchaService.ts
@@ -43,7 +43,7 @@ export type CaptchaSetting = {
siteKey: string | null;
secretKey: string | null;
}
-}
+};
export class CaptchaError extends Error {
public readonly code: CaptchaErrorCode;
@@ -59,11 +59,11 @@ export class CaptchaError extends Error {
export type CaptchaSaveSuccess = {
success: true;
-}
+};
export type CaptchaSaveFailure = {
success: false;
error: CaptchaError;
-}
+};
export type CaptchaSaveResult = CaptchaSaveSuccess | CaptchaSaveFailure;
type CaptchaResponse = {
diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts
index 2e78e6d877..a2b74d1ab2 100644
--- a/packages/backend/src/core/DownloadService.ts
+++ b/packages/backend/src/core/DownloadService.ts
@@ -60,8 +60,8 @@ export class DownloadService {
request: operationTimeout, // whole operation timeout
},
agent: {
- http: this.httpRequestService.httpAgent,
- https: this.httpRequestService.httpsAgent,
+ http: this.httpRequestService.getAgentForHttp(urlObj, true),
+ https: this.httpRequestService.getAgentForHttps(urlObj, true),
},
http2: false, // default
retry: {
diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts
index c332e5a0a8..1550fe3d3c 100644
--- a/packages/backend/src/core/DriveService.ts
+++ b/packages/backend/src/core/DriveService.ts
@@ -173,7 +173,8 @@ export class DriveService {
?? `${ this.meta.objectStorageUseSSL ? 'https' : 'http' }://${ this.meta.objectStorageEndpoint }${ this.meta.objectStoragePort ? `:${this.meta.objectStoragePort}` : '' }/${ this.meta.objectStorageBucket }`;
// for original
- const key = `${this.meta.objectStoragePrefix}/${randomUUID()}${ext}`;
+ const prefix = this.meta.objectStoragePrefix ? `${this.meta.objectStoragePrefix}/` : '';
+ const key = `${prefix}${randomUUID()}${ext}`;
const url = `${ baseUrl }/${ key }`;
// for alts
@@ -190,7 +191,7 @@ export class DriveService {
];
if (alts.webpublic) {
- webpublicKey = `${this.meta.objectStoragePrefix}/webpublic-${randomUUID()}.${alts.webpublic.ext}`;
+ webpublicKey = `${prefix}webpublic-${randomUUID()}.${alts.webpublic.ext}`;
webpublicUrl = `${ baseUrl }/${ webpublicKey }`;
this.registerLogger.info(`uploading webpublic: ${webpublicKey}`);
@@ -198,7 +199,7 @@ export class DriveService {
}
if (alts.thumbnail) {
- thumbnailKey = `${this.meta.objectStoragePrefix}/thumbnail-${randomUUID()}.${alts.thumbnail.ext}`;
+ thumbnailKey = `${prefix}thumbnail-${randomUUID()}.${alts.thumbnail.ext}`;
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts
index da198d0e42..45d7ea11e4 100644
--- a/packages/backend/src/core/EmailService.ts
+++ b/packages/backend/src/core/EmailService.ts
@@ -164,6 +164,13 @@ export class EmailService {
available: boolean;
reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp' | 'banned' | 'network' | 'blacklist';
}> {
+ if (!this.utilityService.validateEmailFormat(emailAddress)) {
+ return {
+ available: false,
+ reason: 'format',
+ };
+ }
+
const exist = await this.userProfilesRepository.countBy({
emailVerified: true,
email: emailAddress,
diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts
index f6dabfadcd..24999bf4da 100644
--- a/packages/backend/src/core/FanoutTimelineService.ts
+++ b/packages/backend/src/core/FanoutTimelineService.ts
@@ -9,7 +9,7 @@ import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
-export type FanoutTimelineName =
+export type FanoutTimelineName = (
// home timeline
| `homeTimeline:${string}`
| `homeTimelineWithFiles:${string}` // only notes with files are included
@@ -37,6 +37,7 @@ export type FanoutTimelineName =
// role timelines
| `roleTimeline:${string}` // any notes are included
+);
@Injectable()
export class FanoutTimelineService {
diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts
index 03646ff566..224fdabc4c 100644
--- a/packages/backend/src/core/GlobalEventService.ts
+++ b/packages/backend/src/core/GlobalEventService.ts
@@ -211,7 +211,7 @@ type SerializedAll<T> = {
type UndefinedAsNullAll<T> = {
[K in keyof T]: T[K] extends undefined ? null : T[K];
-}
+};
export interface InternalEventTypes {
userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; };
diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts
index 083153940a..13d8f7f43b 100644
--- a/packages/backend/src/core/HttpRequestService.ts
+++ b/packages/backend/src/core/HttpRequestService.ts
@@ -16,7 +16,7 @@ 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 { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
+import { assertActivityMatchesUrls, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
import type { IObject } from '@/core/activitypub/type.js';
import type { Response } from 'node-fetch';
import type { URL } from 'node:url';
@@ -115,32 +115,32 @@ export class HttpRequestService {
/**
* Get http non-proxy agent (without local address filtering)
*/
- private httpNative: http.Agent;
+ private readonly httpNative: http.Agent;
/**
* Get https non-proxy agent (without local address filtering)
*/
- private httpsNative: https.Agent;
+ private readonly httpsNative: https.Agent;
/**
* Get http non-proxy agent
*/
- private http: http.Agent;
+ private readonly http: http.Agent;
/**
* Get https non-proxy agent
*/
- private https: https.Agent;
+ private readonly https: https.Agent;
/**
* Get http proxy or non-proxy agent
*/
- public httpAgent: http.Agent;
+ public readonly httpAgent: http.Agent;
/**
* Get https proxy or non-proxy agent
*/
- public httpsAgent: https.Agent;
+ public readonly httpsAgent: https.Agent;
constructor(
@Inject(DI.config)
@@ -197,7 +197,8 @@ export class HttpRequestService {
/**
* Get agent by URL
* @param url URL
- * @param bypassProxy Allways bypass proxy
+ * @param bypassProxy Always bypass proxy
+ * @param isLocalAddressAllowed
*/
@bindThis
public getAgentByUrl(url: URL, bypassProxy = false, isLocalAddressAllowed = false): http.Agent | https.Agent {
@@ -214,8 +215,40 @@ export class HttpRequestService {
}
}
+ /**
+ * Get agent for http by URL
+ * @param url URL
+ * @param isLocalAddressAllowed
+ */
+ @bindThis
+ public getAgentForHttp(url: URL, isLocalAddressAllowed = false): http.Agent {
+ if ((this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
+ return isLocalAddressAllowed
+ ? this.httpNative
+ : this.http;
+ } else {
+ return this.httpAgent;
+ }
+ }
+
+ /**
+ * Get agent for https by URL
+ * @param url URL
+ * @param isLocalAddressAllowed
+ */
+ @bindThis
+ public getAgentForHttps(url: URL, isLocalAddressAllowed = false): https.Agent {
+ if ((this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
+ return isLocalAddressAllowed
+ ? this.httpsNative
+ : this.https;
+ } else {
+ return this.httpsAgent;
+ }
+ }
+
@bindThis
- public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObject> {
+ public async getActivityJson(url: string, isLocalAddressAllowed = false, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict): Promise<IObject> {
const res = await this.send(url, {
method: 'GET',
headers: {
@@ -232,7 +265,7 @@ export class HttpRequestService {
const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject;
- assertActivityMatchesUrls(activity, [finalUrl]);
+ assertActivityMatchesUrls(url, activity, [finalUrl], allowSoftfail);
return activity;
}
diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts
index bf06d4457e..00208927e2 100644
--- a/packages/backend/src/core/MfmService.ts
+++ b/packages/backend/src/core/MfmService.ts
@@ -492,7 +492,8 @@ export class MfmService {
appendChildren(nodes, body);
- const serialized = new XMLSerializer().serializeToString(body);
+ // Remove the unnecessary namespace
+ const serialized = new XMLSerializer().serializeToString(body).replace(/^\s*<p xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">/, '<p>');
happyDOM.close().catch(err => {});
diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts
index 4ecd2592b2..e394506a44 100644
--- a/packages/backend/src/core/NoteDeleteService.ts
+++ b/packages/backend/src/core/NoteDeleteService.ts
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { Brackets, In } from 'typeorm';
+import { Brackets, In, IsNull, Not } from 'typeorm';
import { Injectable, Inject } from '@nestjs/common';
import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
import type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
@@ -190,12 +190,26 @@ export class NoteDeleteService {
}
@bindThis
+ private async getRenotedOrRepliedRemoteUsers(note: MiNote) {
+ const query = this.notesRepository.createQueryBuilder('note')
+ .leftJoinAndSelect('note.user', 'user')
+ .where(new Brackets(qb => {
+ qb.orWhere('note.renoteId = :renoteId', { renoteId: note.id });
+ qb.orWhere('note.replyId = :replyId', { replyId: note.id });
+ }))
+ .andWhere({ userHost: Not(IsNull()) });
+ const notes = await query.getMany() as (MiNote & { user: MiRemoteUser })[];
+ const remoteUsers = notes.map(({ user }) => user);
+ return remoteUsers;
+ }
+
+ @bindThis
private async deliverToConcerned(user: { id: MiLocalUser['id']; host: null; }, note: MiNote, content: any) {
this.apDeliverManagerService.deliverToFollowers(user, content);
this.relayService.deliverToRelays(user, content);
- const remoteUsers = await this.getMentionedRemoteUsers(note);
- for (const remoteUser of remoteUsers) {
- this.apDeliverManagerService.deliverToUser(user, content, remoteUser);
- }
+ this.apDeliverManagerService.deliverToUsers(user, content, [
+ ...await this.getMentionedRemoteUsers(note),
+ ...await this.getRenotedOrRepliedRemoteUsers(note),
+ ]);
}
}
diff --git a/packages/backend/src/core/RemoteUserResolveService.ts b/packages/backend/src/core/RemoteUserResolveService.ts
index 098b5e1706..a2f1b73cdb 100644
--- a/packages/backend/src/core/RemoteUserResolveService.ts
+++ b/packages/backend/src/core/RemoteUserResolveService.ts
@@ -74,7 +74,7 @@ export class RemoteUserResolveService {
if (user == null) {
const self = await this.resolveSelf(acctLower);
- if (self.href.startsWith(this.config.url)) {
+ if (this.utilityService.isUriLocal(self.href)) {
const local = this.apDbResolverService.parseUri(self.href);
if (local.local && local.type === 'users') {
// the LR points to local
diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts
index 64e3f2f56a..bc62559e46 100644
--- a/packages/backend/src/core/SearchService.ts
+++ b/packages/backend/src/core/SearchService.ts
@@ -220,7 +220,7 @@ export class SearchService {
.leftJoinAndSelect('renote.user', 'renoteUser');
if (this.config.fulltextSearch?.provider === 'sqlPgroonga') {
- query.andWhere('note.text &@ :q', { q });
+ query.andWhere('note.text &@~ :q', { q });
} else {
query.andWhere('LOWER(note.text) LIKE :q', { q: `%${ sqlLikeEscape(q.toLowerCase()) }%` });
}
diff --git a/packages/backend/src/core/UserWebhookService.ts b/packages/backend/src/core/UserWebhookService.ts
index b1728671ae..9b0a598a1b 100644
--- a/packages/backend/src/core/UserWebhookService.ts
+++ b/packages/backend/src/core/UserWebhookService.ts
@@ -15,7 +15,7 @@ import { QueueService } from '@/core/QueueService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
export type UserWebhookPayload<T extends WebhookEventTypes> =
- T extends 'note' | 'reply' | 'renote' |'mention' ? {
+ T extends 'note' | 'reply' | 'renote' | 'mention' ? {
note: Packed<'Note'>,
} :
T extends 'follow' | 'unfollow' ? {
diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts
index fcb750d3bf..23fb928ac9 100644
--- a/packages/backend/src/core/UtilityService.ts
+++ b/packages/backend/src/core/UtilityService.ts
@@ -38,6 +38,14 @@ export class UtilityService {
return this.punyHost(uri) === this.toPuny(this.config.host);
}
+ // メールアドレスのバリデーションを行う
+ // https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
+ @bindThis
+ public validateEmailFormat(email: string): boolean {
+ const regexp = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
+ return regexp.test(email);
+ }
+
@bindThis
public isBlockedHost(blockedHosts: string[], host: string | null): boolean {
if (host == null) return false;
diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts
index ed75e4f467..372e1e2ab7 100644
--- a/packages/backend/src/core/WebAuthnService.ts
+++ b/packages/backend/src/core/WebAuthnService.ts
@@ -127,11 +127,11 @@ export class WebAuthnService {
const { registrationInfo } = verification;
return {
- credentialID: registrationInfo.credentialID,
- credentialPublicKey: registrationInfo.credentialPublicKey,
+ credentialID: registrationInfo.credential.id,
+ credentialPublicKey: registrationInfo.credential.publicKey,
attestationObject: registrationInfo.attestationObject,
fmt: registrationInfo.fmt,
- counter: registrationInfo.counter,
+ counter: registrationInfo.credential.counter,
userVerified: registrationInfo.userVerified,
credentialDeviceType: registrationInfo.credentialDeviceType,
credentialBackedUp: registrationInfo.credentialBackedUp,
@@ -212,9 +212,9 @@ export class WebAuthnService {
expectedChallenge: challenge,
expectedOrigin: relyingParty.origin,
expectedRPID: relyingParty.rpId,
- authenticator: {
- credentialID: key.id,
- credentialPublicKey: Buffer.from(key.publicKey, 'base64url'),
+ credential: {
+ id: key.id,
+ publicKey: Buffer.from(key.publicKey, 'base64url'),
counter: key.counter,
transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined,
},
@@ -292,9 +292,9 @@ export class WebAuthnService {
expectedChallenge: challenge,
expectedOrigin: relyingParty.origin,
expectedRPID: relyingParty.rpId,
- authenticator: {
- credentialID: key.id,
- credentialPublicKey: Buffer.from(key.publicKey, 'base64url'),
+ credential: {
+ id: key.id,
+ publicKey: Buffer.from(key.publicKey, 'base64url'),
counter: key.counter,
transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined,
},
diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts
index 5d07cd8e8f..0140ce9fd6 100644
--- a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts
+++ b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts
@@ -196,6 +196,25 @@ export class ApDeliverManagerService {
await manager.execute();
}
+ /**
+ * Deliver activity to users
+ * @param actor
+ * @param activity Activity
+ * @param targets Target users
+ */
+ @bindThis
+ public async deliverToUsers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, targets: MiRemoteUser[]): Promise<void> {
+ const manager = new DeliverManager(
+ this.userEntityService,
+ this.followingsRepository,
+ this.queueService,
+ actor,
+ activity,
+ );
+ for (const to of targets) manager.addDirectRecipe(to);
+ await manager.execute();
+ }
+
@bindThis
public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager {
return new DeliverManager(
diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts
index 9148095067..8688015aff 100644
--- a/packages/backend/src/core/activitypub/ApRendererService.ts
+++ b/packages/backend/src/core/activitypub/ApRendererService.ts
@@ -27,6 +27,7 @@ import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFil
import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { IdService } from '@/core/IdService.js';
+import { UtilityService } from '@/core/UtilityService.js';
import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js';
@@ -61,6 +62,7 @@ export class ApRendererService {
private apMfmService: ApMfmService,
private mfmService: MfmService,
private idService: IdService,
+ private utilityService: UtilityService,
) {
}
@@ -577,7 +579,7 @@ export class ApRendererService {
@bindThis
public renderUndo(object: string | IObject, user: { id: MiUser['id'] }): IUndo {
- const id = typeof object !== 'string' && typeof object.id === 'string' && object.id.startsWith(this.config.url) ? `${object.id}/undo` : undefined;
+ const id = typeof object !== 'string' && typeof object.id === 'string' && this.utilityService.isUriLocal(object.id) ? `${object.id}/undo` : undefined;
return {
type: 'Undo',
diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts
index 8c3b7295e4..6c29cce325 100644
--- a/packages/backend/src/core/activitypub/ApRequestService.ts
+++ b/packages/backend/src/core/activitypub/ApRequestService.ts
@@ -17,7 +17,7 @@ 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';
-import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
+import { assertActivityMatchesUrls, FetchAllowSoftFailMask as FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
import type { IObject } from './type.js';
type Request = {
@@ -185,7 +185,7 @@ export class ApRequestService {
* @param url URL to fetch
*/
@bindThis
- public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<unknown> {
+ public async signedGet(url: string, user: { id: MiUser['id'] }, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict, followAlternate?: boolean): Promise<unknown> {
const _followAlternate = followAlternate ?? true;
const keypair = await this.userKeypairService.getUserKeypair(user.id);
@@ -243,7 +243,7 @@ export class ApRequestService {
if (alternate) {
const href = alternate.getAttribute('href');
if (href && this.utilityService.punyHost(url) === this.utilityService.punyHost(href)) {
- return await this.signedGet(href, user, false);
+ return await this.signedGet(href, user, allowSoftfail, false);
}
}
} catch (e) {
@@ -258,7 +258,7 @@ export class ApRequestService {
const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject;
- assertActivityMatchesUrls(activity, [finalUrl]);
+ assertActivityMatchesUrls(url, activity, [finalUrl], allowSoftfail);
return activity;
}
diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts
index 52cc569140..fb963294cb 100644
--- a/packages/backend/src/core/activitypub/ApResolverService.ts
+++ b/packages/backend/src/core/activitypub/ApResolverService.ts
@@ -21,6 +21,7 @@ import { ApRendererService } from './ApRendererService.js';
import { ApRequestService } from './ApRequestService.js';
import type { IObject, ICollection, IOrderedCollection } from './type.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
+import { FetchAllowSoftFailMask } from './misc/check-against-url.js';
export class Resolver {
private history: Set<string>;
@@ -72,7 +73,7 @@ export class Resolver {
}
@bindThis
- public async resolve(value: string | IObject): Promise<IObject> {
+ public async resolve(value: string | IObject, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict): Promise<IObject> {
if (typeof value !== 'string') {
return value;
}
@@ -108,8 +109,8 @@ export class Resolver {
}
const object = (this.user
- ? await this.apRequestService.signedGet(value, this.user) as IObject
- : await this.httpRequestService.getActivityJson(value)) as IObject;
+ ? await this.apRequestService.signedGet(value, this.user, allowSoftfail) as IObject
+ : await this.httpRequestService.getActivityJson(value, undefined, allowSoftfail)) as IObject;
if (
Array.isArray(object['@context']) ?
@@ -118,19 +119,7 @@ export class Resolver {
) {
throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', 'invalid response');
}
-
- // HttpRequestService / ApRequestService have already checked that
- // `object.id` or `object.url` matches the URL used to fetch the
- // object after redirects; here we double-check that no redirects
- // bounced between hosts
- if (object.id == null) {
- throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', 'invalid AP object: missing id');
- }
-
- if (this.utilityService.punyHost(object.id) !== this.utilityService.punyHost(value)) {
- throw new IdentifiableError('fd93c2fa-69a8-440f-880b-bf178e0ec877', `invalid AP object ${value}: id ${object.id} has different host`);
- }
-
+
return object;
}
diff --git a/packages/backend/src/core/activitypub/misc/check-against-url.ts b/packages/backend/src/core/activitypub/misc/check-against-url.ts
index d679bd8180..dfcfb1943e 100644
--- a/packages/backend/src/core/activitypub/misc/check-against-url.ts
+++ b/packages/backend/src/core/activitypub/misc/check-against-url.ts
@@ -4,18 +4,124 @@
*/
import type { IObject } from '../type.js';
-export function assertActivityMatchesUrls(activity: IObject, urls: string[]) {
- const hosts = urls.map(it => new URL(it).host);
+export enum FetchAllowSoftFailMask {
+ // Allow no softfail flags
+ Strict = 0,
+ // The values in tuple (requestUrl, finalUrl, objectId) are not all identical
+ //
+ // This condition is common for user-initiated lookups but should not be allowed in federation loop
+ //
+ // Allow variations:
+ // good example: https://alice.example.com/@user -> https://alice.example.com/user/:userId
+ // problematic example: https://alice.example.com/redirect?url=https://bad.example.com/ -> https://bad.example.com/ -> https://alice.example.com/somethingElse
+ NonCanonicalId = 1 << 0,
+ // Allow the final object to be at most one subdomain deeper than the request URL, similar to SPF relaxed alignment
+ //
+ // Currently no code path allows this flag to be set, but is kept in case of future use as some niche deployments do this, and we provide a pre-reviewed mechanism to opt-in.
+ //
+ // Allow variations:
+ // good example: https://example.com/@user -> https://activitypub.example.com/@user { id: 'https://activitypub.example.com/@user' }
+ // problematic example: https://example.com/@user -> https://untrusted.example.com/@user { id: 'https://untrusted.example.com/@user' }
+ MisalignedOrigin = 1 << 1,
+ // The requested URL has a different host than the returned object ID, although the final URL is still consistent with the object ID
+ //
+ // This condition is common for user-initiated lookups using an intermediate host but should not be allowed in federation loops
+ //
+ // Allow variations:
+ // good example: https://alice.example.com/@user@bob.example.com -> https://bob.example.com/@user { id: 'https://bob.example.com/@user' }
+ // problematic example: https://alice.example.com/definitelyAlice -> https://bob.example.com/@somebodyElse { id: 'https://bob.example.com/@somebodyElse' }
+ CrossOrigin = 1 << 2 | MisalignedOrigin,
+ // Allow all softfail flags
+ //
+ // do not use this flag on released code
+ Any = ~0,
+}
+
+/**
+ * Fuzz match on whether the candidate host has authority over the request host
+ *
+ * @param requestHost The host of the requested resources
+ * @param candidateHost The host of final response
+ * @returns Whether the candidate host has authority over the request host, or if a soft fail is required for a match
+ */
+function hostFuzzyMatch(requestHost: string, candidateHost: string): FetchAllowSoftFailMask {
+ const requestFqdn = requestHost.endsWith('.') ? requestHost : `${requestHost}.`;
+ const candidateFqdn = candidateHost.endsWith('.') ? candidateHost : `${candidateHost}.`;
+
+ if (requestFqdn === candidateFqdn) {
+ return FetchAllowSoftFailMask.Strict;
+ }
+
+ // allow only one case where candidateHost is a first-level subdomain of requestHost
+ const requestDnsDepth = requestFqdn.split('.').length;
+ const candidateDnsDepth = candidateFqdn.split('.').length;
+
+ if ((candidateDnsDepth - requestDnsDepth) !== 1) {
+ return FetchAllowSoftFailMask.CrossOrigin;
+ }
- const idOk = activity.id !== undefined && hosts.includes(new URL(activity.id).host);
+ if (`.${candidateHost}`.endsWith(`.${requestHost}`)) {
+ return FetchAllowSoftFailMask.MisalignedOrigin;
+ }
- // technically `activity.url` could be an `ApObject = IObject |
- // string | (IObject | string)[]`, but if it's a complicated thing
- // and the `activity.id` doesn't match, I think we're fine
- // rejecting the activity
- const urlOk = typeof(activity.url) === 'string' && hosts.includes(new URL(activity.url).host);
+ return FetchAllowSoftFailMask.CrossOrigin;
+}
- if (!idOk && !urlOk) {
- throw new Error(`bad Activity: neither id(${activity?.id}) nor url(${activity?.url}) match location(${urls})`);
+// normalize host names by removing www. prefix
+function normalizeSynonymousSubdomain(url: URL | string): URL {
+ const urlParsed = url instanceof URL ? url : new URL(url);
+ const host = urlParsed.host;
+ const normalizedHost = host.replace(/^www\./, '');
+ return new URL(urlParsed.toString().replace(host, normalizedHost));
+}
+
+export function assertActivityMatchesUrls(requestUrl: string | URL, activity: IObject, candidateUrls: (string | URL)[], allowSoftfail: FetchAllowSoftFailMask): FetchAllowSoftFailMask {
+ // must have a unique identifier to verify authority
+ if (!activity.id) {
+ throw new Error('bad Activity: missing id field');
}
+
+ let softfail = 0;
+
+ // if the flag is allowed, set the flag on return otherwise throw
+ const requireSoftfail = (needed: FetchAllowSoftFailMask, message: string) => {
+ if ((allowSoftfail & needed) !== needed) {
+ throw new Error(message);
+ }
+
+ softfail |= needed;
+ };
+
+ const requestUrlParsed = normalizeSynonymousSubdomain(requestUrl);
+ const idParsed = normalizeSynonymousSubdomain(activity.id);
+
+ const candidateUrlsParsed = candidateUrls.map(it => normalizeSynonymousSubdomain(it));
+
+ const requestUrlSecure = requestUrlParsed.protocol === 'https:';
+ const finalUrlSecure = candidateUrlsParsed.every(it => it.protocol === 'https:');
+ if (requestUrlSecure && !finalUrlSecure) {
+ throw new Error(`bad Activity: id(${activity.id}) is not allowed to have http:// in the url`);
+ }
+
+ // Compare final URL to the ID
+ if (!candidateUrlsParsed.some(it => it.href === idParsed.href)) {
+ requireSoftfail(FetchAllowSoftFailMask.NonCanonicalId, `bad Activity: id(${activity.id}) does not match response url(${candidateUrlsParsed.map(it => it.toString())})`);
+
+ // at lease host need to match exactly (ActivityPub requirement)
+ if (!candidateUrlsParsed.some(it => idParsed.host === it.host)) {
+ throw new Error(`bad Activity: id(${activity.id}) does not match response host(${candidateUrlsParsed.map(it => it.host)})`);
+ }
+ }
+
+ // Compare request URL to the ID
+ if (!requestUrlParsed.href.includes(idParsed.href)) {
+ requireSoftfail(FetchAllowSoftFailMask.NonCanonicalId, `bad Activity: id(${activity.id}) does not match request url(${requestUrlParsed.toString()})`);
+
+ // if cross-origin lookup is allowed, we can accept some variation between the original request URL to the final object ID (but not between the final URL and the object ID)
+ const hostResult = hostFuzzyMatch(requestUrlParsed.host, idParsed.host);
+
+ requireSoftfail(hostResult, `bad Activity: id(${activity.id}) is valid but is not the same origin as request url(${requestUrlParsed.toString()})`);
+ }
+
+ return softfail;
}
diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts
index ec0b5360f4..7ad6071ceb 100644
--- a/packages/backend/src/core/entities/MetaEntityService.ts
+++ b/packages/backend/src/core/entities/MetaEntityService.ts
@@ -97,6 +97,7 @@ export class MetaEntityService {
enableTurnstile: instance.enableTurnstile,
turnstileSiteKey: instance.turnstileSiteKey,
enableTestcaptcha: instance.enableTestcaptcha,
+ googleAnalyticsMeasurementId: instance.googleAnalyticsMeasurementId,
swPublickey: instance.swPublicKey,
themeColor: instance.themeColor,
mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png',
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index d3c087a153..fbd3892dd4 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -57,12 +57,14 @@ const ajv = new Ajv();
function isLocalUser(user: MiUser): user is MiLocalUser;
function isLocalUser<T extends { host: MiUser['host'] }>(user: T): user is (T & { host: null; });
+
function isLocalUser(user: MiUser | { host: MiUser['host'] }): boolean {
return user.host == null;
}
function isRemoteUser(user: MiUser): user is MiRemoteUser;
function isRemoteUser<T extends { host: MiUser['host'] }>(user: T): user is (T & { host: string; });
+
function isRemoteUser(user: MiUser | { host: MiUser['host'] }): boolean {
return !isLocalUser(user);
}
@@ -78,7 +80,7 @@ export type UserRelation = {
isBlocked: boolean
isMuted: boolean
isRenoteMuted: boolean
-}
+};
@Injectable()
export class UserEntityService implements OnModuleInit {
diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts
index f612591eda..ac74d68c95 100644
--- a/packages/backend/src/misc/json-schema.ts
+++ b/packages/backend/src/misc/json-schema.ts
@@ -143,7 +143,7 @@ type OfSchema = {
readonly anyOf?: ReadonlyArray<Schema>;
readonly oneOf?: ReadonlyArray<Schema>;
readonly allOf?: ReadonlyArray<Schema>;
-}
+};
export interface Schema extends OfSchema {
readonly type?: TypeStringef;
@@ -217,7 +217,7 @@ type ObjectSchemaTypeDef<p extends Schema> =
:
p['anyOf'] extends ReadonlyArray<Schema> ? never : // see CONTRIBUTING.md
p['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<p['allOf']>> :
- any
+ any;
type ObjectSchemaType<p extends Schema> = NullOrUndefined<p, ObjectSchemaTypeDef<p>>;
diff --git a/packages/backend/src/misc/json-value.ts b/packages/backend/src/misc/json-value.ts
index bd7fe12058..195f7c4d47 100644
--- a/packages/backend/src/misc/json-value.ts
+++ b/packages/backend/src/misc/json-value.ts
@@ -4,7 +4,7 @@
*/
export type JsonValue = JsonArray | JsonObject | string | number | boolean | null;
-export type JsonObject = {[K in string]?: JsonValue};
+export type JsonObject = { [K in string]?: JsonValue };
export type JsonArray = JsonValue[];
export function isJsonObject(value: JsonValue | undefined): value is JsonObject {
diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts
index ad5e31ad6f..9df2f74984 100644
--- a/packages/backend/src/models/Meta.ts
+++ b/packages/backend/src/models/Meta.ts
@@ -658,4 +658,10 @@ export class MiMeta {
default: '{}',
})
public federationHosts: string[];
+
+ @Column('varchar', {
+ length: 64,
+ nullable: true,
+ })
+ public googleAnalyticsMeasurementId: string | null;
}
diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts
index b7f8e94d69..5772ace338 100644
--- a/packages/backend/src/models/Notification.ts
+++ b/packages/backend/src/models/Notification.ts
@@ -91,6 +91,10 @@ export type MiNotification = {
id: string;
createdAt: string;
} | {
+ type: 'createToken';
+ id: string;
+ createdAt: string;
+} | {
type: 'app';
id: string;
createdAt: string;
diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts
index 96de30c4c2..549d78a22c 100644
--- a/packages/backend/src/models/User.ts
+++ b/packages/backend/src/models/User.ts
@@ -288,24 +288,24 @@ export class MiUser {
export type MiLocalUser = MiUser & {
host: null;
uri: null;
-}
+};
export type MiPartialLocalUser = Partial<MiUser> & {
id: MiUser['id'];
host: null;
uri: null;
-}
+};
export type MiRemoteUser = MiUser & {
host: string;
uri: string;
-}
+};
export type MiPartialRemoteUser = Partial<MiUser> & {
id: MiUser['id'];
host: string;
uri: string;
-}
+};
export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const;
export const passwordSchema = { type: 'string', minLength: 1 } as const;
diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts
index e7ae2ee8e5..1e25c355ca 100644
--- a/packages/backend/src/models/json-schema/meta.ts
+++ b/packages/backend/src/models/json-schema/meta.ts
@@ -119,6 +119,10 @@ export const packedMetaLiteSchema = {
type: 'boolean',
optional: false, nullable: false,
},
+ googleAnalyticsMeasurementId: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
swPublickey: {
type: 'string',
optional: false, nullable: true,
diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts
index cddaf4bc83..1638b2b3c7 100644
--- a/packages/backend/src/models/json-schema/notification.ts
+++ b/packages/backend/src/models/json-schema/notification.ts
@@ -339,6 +339,16 @@ export const packedNotificationSchema = {
type: {
type: 'string',
optional: false, nullable: false,
+ enum: ['createToken'],
+ },
+ },
+ }, {
+ type: 'object',
+ properties: {
+ ...baseSchema.properties,
+ type: {
+ type: 'string',
+ optional: false, nullable: false,
enum: ['app'],
},
body: {
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index d09240eba1..8a0b7d97d7 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -92,7 +92,7 @@ const sqlLogger = dbLogger.createSubLogger('sql', 'gray');
export type LoggerProps = {
disableQueryTruncation?: boolean;
enableQueryParamLogging?: boolean;
-}
+};
function highlightSql(sql: string) {
return highlight.highlight(sql, {
diff --git a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts
index 2e84430e72..c9fe4fca73 100644
--- a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts
+++ b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts
@@ -29,7 +29,7 @@ export type ModeratorInactivityEvaluationResult = {
isModeratorsInactive: boolean;
inactiveModerators: MiUser[];
remainingTime: ModeratorInactivityRemainingTime;
-}
+};
export type ModeratorInactivityRemainingTime = {
time: number;
diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts
index 004fe1382d..079e014da8 100644
--- a/packages/backend/src/queue/processors/InboxProcessorService.ts
+++ b/packages/backend/src/queue/processors/InboxProcessorService.ts
@@ -107,12 +107,12 @@ export class InboxProcessorService implements OnApplicationShutdown {
// それでもわからなければ終了
if (authUser == null) {
- throw new Bull.UnrecoverableError('skip: failed to resolve user');
+ throw new Bull.UnrecoverableError(`skip: failed to resolve user ${getApId(activity.actor)}`);
}
// publicKey がなくても終了
if (authUser.key == null) {
- throw new Bull.UnrecoverableError('skip: failed to resolve user publicKey');
+ throw new Bull.UnrecoverableError(`skip: failed to resolve user publicKey ${getApId(activity.actor)}`);
}
// HTTP-Signatureの検証
diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts
index 5db919a149..757daea88b 100644
--- a/packages/backend/src/queue/types.ts
+++ b/packages/backend/src/queue/types.ts
@@ -38,7 +38,7 @@ export type RelationshipJobData = {
silent?: boolean;
requestId?: string;
withReplies?: boolean;
-}
+};
export type DbJobData<T extends keyof DbJobMap> = DbJobMap[T];
@@ -61,11 +61,11 @@ export type DbJobMap = {
importUserLists: DbUserImportJobData;
importCustomEmojis: DbUserImportJobData;
deleteAccount: DbUserDeleteJobData;
-}
+};
export type DbJobDataWithUser = {
user: ThinUser;
-}
+};
export type DbExportFollowingData = {
user: ThinUser;
@@ -75,7 +75,7 @@ export type DbExportFollowingData = {
export type DBExportAntennasData = {
user: ThinUser
-}
+};
export type DbUserDeleteJobData = {
user: ThinUser;
@@ -91,7 +91,7 @@ export type DbUserImportJobData = {
export type DBAntennaImportJobData = {
user: ThinUser,
antenna: Antenna
-}
+};
export type DbUserImportToDbJobData = {
user: ThinUser;
diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts
index fd2bd3267d..b899053287 100644
--- a/packages/backend/src/server/ServerService.ts
+++ b/packages/backend/src/server/ServerService.ts
@@ -103,6 +103,43 @@ export class ServerService implements OnApplicationShutdown {
serve: false,
});
+ // if the requester looks like to be performing an ActivityPub object lookup, reject all external redirects
+ //
+ // this will break lookup that involve copying a URL from a third-party server, like trying to lookup http://charlie.example.com/@alice@alice.com
+ //
+ // this is not required by standard but protect us from peers that did not validate final URL.
+ if (this.config.disallowExternalApRedirect) {
+ const maybeApLookupRegex = /application\/activity\+json|application\/ld\+json.+activitystreams/i;
+ fastify.addHook('onSend', (request, reply, _, done) => {
+ const location = reply.getHeader('location');
+ if (reply.statusCode < 300 || reply.statusCode >= 400 || typeof location !== 'string') {
+ done();
+ return;
+ }
+
+ if (!maybeApLookupRegex.test(request.headers.accept ?? '')) {
+ done();
+ return;
+ }
+
+ const effectiveLocation = process.env.NODE_ENV === 'production' ? location : location.replace(/^http:\/\//, 'https://');
+ if (effectiveLocation.startsWith(`https://${this.config.host}/`)) {
+ done();
+ return;
+ }
+
+ reply.status(406);
+ reply.removeHeader('location');
+ reply.header('content-type', 'text/plain; charset=utf-8');
+ reply.header('link', `<${encodeURI(location)}>; rel="canonical"`);
+ done(null, [
+ "Refusing to relay remote ActivityPub object lookup.",
+ "",
+ `Please remove 'application/activity+json' and 'application/ld+json' from the Accept header or fetch using the authoritative URL at ${location}.`,
+ ].join('\n'));
+ });
+ }
+
fastify.register(this.apiServerService.createServer, { prefix: '/api' });
fastify.register(this.openApiServerService.createServer);
fastify.register(this.fileServerService.createServer);
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index a9a2ebc041..7f4ca9c0e0 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -122,7 +122,7 @@ export type IEndpointMeta = (Omit<IEndpointMetaBase, 'requireCrential' | 'requir
}) | (Omit<IEndpointMetaBase, 'requireAdmin' | 'kind'> & {
requireAdmin: true,
kind: (typeof permissions)[number],
-})
+});
export interface IEndpoint {
name: string;
diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts
index 64e3cc33bd..9d5691a427 100644
--- a/packages/backend/src/server/api/endpoints/admin/meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/meta.ts
@@ -73,6 +73,10 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
+ googleAnalyticsMeasurementId: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
swPublickey: {
type: 'string',
optional: false, nullable: true,
@@ -512,6 +516,7 @@ export const meta = {
},
federation: {
type: 'string',
+ enum: ['all', 'specified', 'none'],
optional: false, nullable: false,
},
federationHosts: {
@@ -571,6 +576,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
enableTurnstile: instance.enableTurnstile,
turnstileSiteKey: instance.turnstileSiteKey,
enableTestcaptcha: instance.enableTestcaptcha,
+ googleAnalyticsMeasurementId: instance.googleAnalyticsMeasurementId,
swPublickey: instance.swPublicKey,
themeColor: instance.themeColor,
mascotImageUrl: instance.mascotImageUrl,
diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
index 38ef0d1de8..45c012cb0a 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -84,6 +84,7 @@ export const paramDef = {
turnstileSiteKey: { type: 'string', nullable: true },
turnstileSecretKey: { type: 'string', nullable: true },
enableTestcaptcha: { type: 'boolean' },
+ googleAnalyticsMeasurementId: { type: 'string', nullable: true },
sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] },
sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] },
setSensitiveFlagAutomatically: { type: 'boolean' },
@@ -117,7 +118,7 @@ export const paramDef = {
useObjectStorage: { type: 'boolean' },
objectStorageBaseUrl: { type: 'string', nullable: true },
objectStorageBucket: { type: 'string', nullable: true },
- objectStoragePrefix: { type: 'string', nullable: true },
+ objectStoragePrefix: { type: 'string', pattern: /^[a-zA-Z0-9-._]*$/.source, nullable: true },
objectStorageEndpoint: { type: 'string', nullable: true },
objectStorageRegion: { type: 'string', nullable: true },
objectStoragePort: { type: 'integer', nullable: true },
@@ -371,6 +372,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.enableTestcaptcha = ps.enableTestcaptcha;
}
+ if (ps.googleAnalyticsMeasurementId !== undefined) {
+ // 空文字列をnullにしたいので??は使わない
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ set.googleAnalyticsMeasurementId = ps.googleAnalyticsMeasurementId || null;
+ }
+
if (ps.sensitiveMediaDetection !== undefined) {
set.sensitiveMediaDetection = ps.sensitiveMediaDetection;
}
diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts
index 5c2e82da88..4afed7dc5c 100644
--- a/packages/backend/src/server/api/endpoints/ap/show.ts
+++ b/packages/backend/src/server/api/endpoints/ap/show.ts
@@ -20,6 +20,7 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { ApiError } from '../../error.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
+import { FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
export const meta = {
tags: ['federation'],
@@ -53,11 +54,6 @@ export const meta = {
code: 'RESPONSE_INVALID',
id: '70193c39-54f3-4813-82f0-70a680f7495b',
},
- responseInvalidIdHostNotMatch: {
- message: 'Requested URI and response URI host does not match.',
- code: 'RESPONSE_INVALID_ID_HOST_NOT_MATCH',
- id: 'a2c9c61a-cb72-43ab-a964-3ca5fddb410a',
- },
noSuchObject: {
message: 'No such object.',
code: 'NO_SUCH_OBJECT',
@@ -153,7 +149,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// リモートから一旦オブジェクトフェッチ
const resolver = this.apResolverService.createResolver();
- const object = await resolver.resolve(uri).catch((err) => {
+ // allow ap/show exclusively to lookup URLs that are cross-origin or non-canonical (like https://alice.example.com/@bob@bob.example.com -> https://bob.example.com/@bob)
+ const object = await resolver.resolve(uri, FetchAllowSoftFailMask.CrossOrigin | FetchAllowSoftFailMask.NonCanonicalId).catch((err) => {
if (err instanceof IdentifiableError) {
switch (err.id) {
// resolve
@@ -165,10 +162,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
case '09d79f9e-64f1-4316-9cfa-e75c4d091574':
throw new ApiError(meta.errors.federationNotAllowed);
case '72180409-793c-4973-868e-5a118eb5519b':
- case 'ad2dc287-75c1-44c4-839d-3d2e64576675':
throw new ApiError(meta.errors.responseInvalid);
- case 'fd93c2fa-69a8-440f-880b-bf178e0ec877':
- throw new ApiError(meta.errors.responseInvalidIdHostNotMatch);
// resolveLocal
case '02b40cd0-fa92-4b0c-acc9-fb2ada952ab8':
diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts
index ceebc8ba5e..b40706297d 100644
--- a/packages/backend/src/server/api/endpoints/clips/create.ts
+++ b/packages/backend/src/server/api/endpoints/clips/create.ts
@@ -39,7 +39,7 @@ export const paramDef = {
properties: {
name: { type: 'string', minLength: 1, maxLength: 100 },
isPublic: { type: 'boolean', default: false },
- description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 },
+ description: { type: 'string', nullable: true, maxLength: 2048 },
},
required: ['name'],
} as const;
@@ -53,7 +53,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => {
let clip: MiClip;
try {
- clip = await this.clipService.create(me, ps.name, ps.isPublic, ps.description ?? null);
+ // 空文字列をnullにしたいので??は使わない
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ clip = await this.clipService.create(me, ps.name, ps.isPublic, ps.description || null);
} catch (e) {
if (e instanceof ClipService.TooManyClipsError) {
throw new ApiError(meta.errors.tooManyClips);
diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts
index 603a3ccf3d..6ff3f9aada 100644
--- a/packages/backend/src/server/api/endpoints/clips/update.ts
+++ b/packages/backend/src/server/api/endpoints/clips/update.ts
@@ -39,7 +39,7 @@ export const paramDef = {
clipId: { type: 'string', format: 'misskey:id' },
name: { type: 'string', minLength: 1, maxLength: 100 },
isPublic: { type: 'boolean' },
- description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 },
+ description: { type: 'string', nullable: true, maxLength: 2048 },
},
required: ['clipId'],
} as const;
@@ -53,7 +53,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) {
super(meta, paramDef, async (ps, me) => {
try {
- await this.clipService.update(me, ps.clipId, ps.name, ps.isPublic, ps.description);
+ // 空文字列をnullにしたいので??は使わない
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ await this.clipService.update(me, ps.clipId, ps.name, ps.isPublic, ps.description || null);
} catch (e) {
if (e instanceof ClipService.NoSuchClipError) {
throw new ApiError(meta.errors.noSuchClip);
diff --git a/packages/backend/src/server/api/endpoints/following/invalidate.ts b/packages/backend/src/server/api/endpoints/following/invalidate.ts
index 8935c2c2da..b45d21410b 100644
--- a/packages/backend/src/server/api/endpoints/following/invalidate.ts
+++ b/packages/backend/src/server/api/endpoints/following/invalidate.ts
@@ -96,7 +96,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
await this.userFollowingService.unfollow(follower, followee);
- return await this.userEntityService.pack(followee.id, me);
+ return await this.userEntityService.pack(follower.id, me);
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts
index fc9a8f3ebe..f1e3726641 100644
--- a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts
+++ b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts
@@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { AccessTokensRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
+import { NotificationService } from '@/core/NotificationService.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { DI } from '@/di-symbols.js';
@@ -50,6 +51,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private accessTokensRepository: AccessTokensRepository,
private idService: IdService,
+ private notificationService: NotificationService,
) {
super(meta, paramDef, async (ps, me) => {
// Generate access token
@@ -71,6 +73,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
permission: ps.permission,
});
+ // アクセストークンが生成されたことを通知
+ this.notificationService.createNotification(me.id, 'createToken', {});
+
return {
token: accessToken,
};
diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts
index 3b20ec1321..ea64e32ee6 100644
--- a/packages/backend/src/server/api/openapi/gen-spec.ts
+++ b/packages/backend/src/server/api/openapi/gen-spec.ts
@@ -210,9 +210,15 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) {
spec.paths['/' + endpoint.name] = {
...(endpoint.meta.allowGet ? {
- get: info,
+ get: {
+ ...info,
+ operationId: 'get___' + info.operationId,
+ },
} : {}),
- post: info,
+ post: {
+ ...info,
+ operationId: 'post___' + info.operationId,
+ },
};
}
diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts
index 84cb552369..686aea423c 100644
--- a/packages/backend/src/server/api/stream/channel.ts
+++ b/packages/backend/src/server/api/stream/channel.ts
@@ -82,8 +82,8 @@ export default abstract class Channel {
this.connection = connection;
}
- public send(payload: { type: string, body: JsonValue }): void
- public send(type: string, payload: JsonValue): void
+ public send(payload: { type: string, body: JsonValue }): void;
+ public send(type: string, payload: JsonValue): void;
@bindThis
public send(typeOrPayload: { type: string, body: JsonValue } | string, payload?: JsonValue) {
const type = payload === undefined ? (typeOrPayload as { type: string, body: JsonValue }).type : (typeOrPayload as string);
@@ -108,4 +108,4 @@ export type MiChannelService<T extends boolean> = {
requireCredential: T;
kind: T extends true ? string : string | null | undefined;
create: (id: string, connection: Connection) => Channel;
-}
+};
diff --git a/packages/backend/src/server/web/boot.embed.js b/packages/backend/src/server/web/boot.embed.js
index 48d1cd262b..9de1275380 100644
--- a/packages/backend/src/server/web/boot.embed.js
+++ b/packages/backend/src/server/web/boot.embed.js
@@ -114,13 +114,17 @@
if (document.readyState === 'loading') {
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
}
+
+ const locale = JSON.parse(localStorage.getItem('locale') || '{}');
+
+ const title = locale?._bootErrors?.title || 'Failed to initialize Misskey';
+ const reload = locale?.reload || 'Reload';
+
document.body.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M12 9v4" /><path d="M12 16v.01" /></svg>
- <div class="message">読み込みに失敗しました</div>
- <div class="submessage">Failed to initialize Misskey</div>
+ <div class="message">${title}</div>
<div class="submessage">Error Code: ${code}</div>
<button onclick="location.reload(!0)">
- <div>リロード</div>
- <div><small>Reload</small></div>
+ <div>${reload}</div>
</button>`;
addStyle(`
#misskey_app,
diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js
index a04640d993..b55d327f86 100644
--- a/packages/backend/src/server/web/boot.js
+++ b/packages/backend/src/server/web/boot.js
@@ -151,6 +151,22 @@
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
}
+ const locale = JSON.parse(localStorage.getItem('locale') || '{}');
+
+ const messages = Object.assign({
+ title: 'Failed to initialize Misskey',
+ solution: 'The following actions may solve the problem.',
+ solution1: 'Update your os and browser',
+ solution2: 'Disable an adblocker',
+ solution3: 'Clear the browser cache',
+ solution4: '(Tor Browser) Set dom.webaudio.enabled to true',
+ otherOption: 'Other options',
+ otherOption1: 'Clear preferences and cache',
+ otherOption2: 'Start the simple client',
+ otherOption3: 'Start the repair tool',
+ }, locale?._bootErrors || {});
+ const reload = locale?.reload || 'Reload';
+
let errorsElement = document.getElementById('errors');
if (!errorsElement) {
@@ -160,32 +176,32 @@
<path d="M12 9v2m0 4v.01"></path>
<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path>
</svg>
- <h1>Failed to load<br>読み込みに失敗しました</h1>
+ <h1>${messages.title}</h1>
<button class="button-big" onclick="location.reload(true);">
- <span class="button-label-big">Reload / リロード</span>
+ <span class="button-label-big">${reload}</span>
</button>
- <p><b>The following actions may solve the problem. / 以下を行うと解決する可能性があります。</b></p>
- <p>Update your os and browser / ブラウザおよびOSを最新バージョンに更新する</p>
- <p>Disable an adblocker / アドブロッカーを無効にする</p>
- <p>Clear the browser cache / ブラウザのキャッシュをクリアする</p>
- <p>&#40;Tor Browser&#41; Set dom.webaudio.enabled to true / dom.webaudio.enabledをtrueに設定する</p>
+ <p><b>${messages.solution}</b></p>
+ <p>${messages.solution1}</p>
+ <p>${messages.solution2}</p>
+ <p>${messages.solution3}</p>
+ <p>${messages.solution4}</p>
<details style="color: #86b300;">
- <summary>Other options / その他のオプション</summary>
+ <summary>${messages.otherOption}</summary>
<a href="/flush">
<button class="button-small">
- <span class="button-label-small">Clear preferences and cache</span>
+ <span class="button-label-small">${messages.otherOption1}</span>
</button>
</a>
<br>
<a href="/cli">
<button class="button-small">
- <span class="button-label-small">Start the simple client</span>
+ <span class="button-label-small">${messages.otherOption2}</span>
</button>
</a>
<br>
<a href="/bios">
<button class="button-small">
- <span class="button-label-small">Start the repair tool</span>
+ <span class="button-label-small">${messages.otherOption3}</span>
</button>
</a>
</details>
diff --git a/packages/backend/src/server/web/error.css b/packages/backend/src/server/web/error.css
index f2b63296eb..803bd1b4b5 100644
--- a/packages/backend/src/server/web/error.css
+++ b/packages/backend/src/server/web/error.css
@@ -5,112 +5,107 @@
*/
* {
- font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
+ font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
}
#misskey_app,
#splash {
- display: none !important;
+ display: none !important;
}
body,
html {
- background-color: #222;
- color: #dfddcc;
- justify-content: center;
- margin: auto;
- padding: 10px;
- text-align: center;
+ background-color: #222;
+ color: #dfddcc;
+ justify-content: center;
+ margin: auto;
+ padding: 10px;
+ text-align: center;
}
button {
- border-radius: 999px;
- padding: 0px 12px 0px 12px;
- border: none;
- cursor: pointer;
- margin-bottom: 12px;
+ border-radius: 999px;
+ padding: 0px 12px 0px 12px;
+ border: none;
+ cursor: pointer;
+ margin-bottom: 12px;
}
.button-big {
- background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0));
- line-height: 50px;
+ background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0));
+ line-height: 50px;
}
.button-big:hover {
- background: rgb(153, 204, 0);
+ background: rgb(153, 204, 0);
}
.button-small {
- background: #444;
- line-height: 40px;
+ background: #444;
+ line-height: 40px;
}
.button-small:hover {
- background: #555;
+ background: #555;
}
.button-label-big {
- color: #222;
- font-weight: bold;
- font-size: 20px;
- padding: 12px;
+ color: #222;
+ font-weight: bold;
+ font-size: 1.2em;
+ padding: 12px;
}
.button-label-small {
- color: rgb(153, 204, 0);
- font-size: 16px;
- padding: 12px;
+ color: rgb(153, 204, 0);
+ font-size: 16px;
+ padding: 12px;
}
a {
- color: rgb(134, 179, 0);
- text-decoration: none;
+ color: rgb(134, 179, 0);
+ text-decoration: none;
}
p,
li {
- font-size: 16px;
-}
-
-.dont-worry,
-#msg {
- font-size: 18px;
+ font-size: 16px;
}
.icon-warning {
- color: #dec340;
- height: 4rem;
- padding-top: 2rem;
+ color: #dec340;
+ height: 4rem;
+ padding-top: 2rem;
}
h1 {
- font-size: 32px;
+ font-size: 1.5em;
+ margin: 1em;
}
code {
- display: block;
- font-family: Fira, FiraCode, monospace;
- background: #333;
- padding: 0.5rem 1rem;
- max-width: 40rem;
- border-radius: 10px;
- justify-content: center;
- margin: auto;
- white-space: pre-wrap;
- word-break: break-word;
+ display: block;
+ font-family: Fira, FiraCode, monospace;
+ background: #333;
+ padding: 0.5rem 1rem;
+ max-width: 40rem;
+ border-radius: 10px;
+ justify-content: center;
+ margin: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
}
-summary {
- cursor: pointer;
+#errorInfo summary {
+ cursor: pointer;
}
-summary > * {
- display: inline;
- white-space: pre-wrap;
+#errorInfo summary>* {
+ display: inline;
}
@media screen and (max-width: 500px) {
- details {
- width: 50%;
- }
+ #errorInfo {
+ width: 50%;
+ }
}
diff --git a/packages/backend/src/server/web/error.js b/packages/backend/src/server/web/error.js
new file mode 100644
index 0000000000..4838dd6ef3
--- /dev/null
+++ b/packages/backend/src/server/web/error.js
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+'use strict';
+
+(() => {
+ document.addEventListener('DOMContentLoaded', () => {
+ const locale = JSON.parse(localStorage.getItem('locale') || '{}');
+
+ const messages = Object.assign({
+ title: 'Failed to initialize Misskey',
+ serverError: 'If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.',
+ solution: 'The following actions may solve the problem.',
+ solution1: 'Update your os and browser',
+ solution2: 'Disable an adblocker',
+ solution3: 'Clear the browser cache',
+ solution4: '(Tor Browser) Set dom.webaudio.enabled to true',
+ otherOption: 'Other options',
+ otherOption1: 'Clear preferences and cache',
+ otherOption2: 'Start the simple client',
+ otherOption3: 'Start the repair tool',
+ }, locale?._bootErrors || {});
+ const reload = locale?.reload || 'Reload';
+
+ const reloadEls = document.querySelectorAll('[data-i18n-reload]');
+ for (const el of reloadEls) {
+ el.textContent = reload;
+ }
+
+ const i18nEls = document.querySelectorAll('[data-i18n]');
+ for (const el of i18nEls) {
+ const key = el.dataset.i18n;
+ if (key && messages[key]) {
+ el.textContent = messages[key];
+ }
+ }
+ });
+})();
diff --git a/packages/backend/src/server/web/views/error.pug b/packages/backend/src/server/web/views/error.pug
index 44ebf53cf7..6a78d1878c 100644
--- a/packages/backend/src/server/web/views/error.pug
+++ b/packages/backend/src/server/web/views/error.pug
@@ -2,15 +2,15 @@ doctype html
//
-
- _____ _ _
- | |_|___ ___| |_ ___ _ _
+ _____ _ _
+ | |_|___ ___| |_ ___ _ _
| | | | |_ -|_ -| '_| -_| | |
|_|_|_|_|___|___|_,_|___|_ |
- |___|
+ |___|
Thank you for using Misskey!
If you are reading this message... how about joining the development?
https://github.com/misskey-dev/misskey
-
+
html
@@ -27,39 +27,45 @@ html
style
include ../error.css
+ script
+ include ../error.js
+
body
svg.icon-warning(xmlns="http://www.w3.org/2000/svg", viewBox="0 0 24 24", stroke-width="2", stroke="currentColor", fill="none", stroke-linecap="round", stroke-linejoin="round")
path(stroke="none", d="M0 0h24v24H0z", fill="none")
path(d="M12 9v2m0 4v.01")
path(d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75")
- h1 An error has occurred!
+ h1(data-i18n="title") Failed to initialize Misskey
button.button-big(onclick="location.reload();")
- span.button-label-big Refresh
-
- p.dont-worry Don't worry, it's (probably) not your fault.
+ span.button-label-big(data-i18n-reload) Reload
- p If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.
+ p(data-i18n="serverError") If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.
div#errors
code.
ERROR CODE: #{code}
ERROR ID: #{id}
- p You may also try the following options:
+ p
+ b(data-i18n="solution") The following actions may solve the problem.
- p Update your os and browser.
- p Disable an adblocker.
+ p(data-i18n="solution1") Update your os and browser
+ p(data-i18n="solution2") Disable an adblocker
+ p(data-i18n="solution3") Clear your browser cache
+ p(data-i18n="solution4") (Tor Browser) Set dom.webaudio.enabled to true
- a(href="/flush")
- button.button-small
- span.button-label-small Clear preferences and cache
- br
- a(href="/cli")
- button.button-small
- span.button-label-small Start the simple client
- br
- a(href="/bios")
- button.button-small
- span.button-label-small Start the repair tool
+ details(style="color: #86b300;")
+ summary(data-i18n="otherOption") Other options
+ a(href="/flush")
+ button.button-small
+ span.button-label-small(data-i18n="otherOption1") Clear preferences and cache
+ br
+ a(href="/cli")
+ button.button-small
+ span.button-label-small(data-i18n="otherOption2") Start the simple client
+ br
+ a(href="/bios")
+ button.button-small
+ span.button-label-small(data-i18n="otherOption3") Start the repair tool
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index df3cfee171..bf409031c8 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -18,6 +18,7 @@
* achievementEarned - 実績を獲得
* exportCompleted - エクスポートが完了
* login - ログイン
+ * createToken - トークン作成
* app - アプリ通知
* test - テスト通知(サーバー側)
*/
@@ -36,6 +37,7 @@ export const notificationTypes = [
'achievementEarned',
'exportCompleted',
'login',
+ 'createToken',
'app',
'test',
] as const;
diff --git a/packages/backend/test-federation/test/note.test.ts b/packages/backend/test-federation/test/note.test.ts
index 220c22e198..1584f9587e 100644
--- a/packages/backend/test-federation/test/note.test.ts
+++ b/packages/backend/test-federation/test/note.test.ts
@@ -139,29 +139,99 @@ describe('Note', () => {
});
describe('Deletion', () => {
- describe('Check Delete consistency', () => {
- let carol: LoginUser;
+ describe('Check Delete is delivered', () => {
+ describe('To followers', () => {
+ let carol: LoginUser;
- beforeAll(async () => {
- carol = await createAccount('a.test');
+ beforeAll(async () => {
+ carol = await createAccount('a.test');
- await carol.client.request('following/create', { userId: bobInA.id });
- await sleep();
+ await carol.client.request('following/create', { userId: bobInA.id });
+ await sleep();
+ });
+
+ test('Check', async () => {
+ const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
+ const noteInA = await resolveRemoteNote('b.test', note.id, carol);
+ await bob.client.request('notes/delete', { noteId: note.id });
+ await sleep();
+
+ await rejects(
+ async () => await carol.client.request('notes/show', { noteId: noteInA.id }),
+ (err: any) => {
+ strictEqual(err.code, 'NO_SUCH_NOTE');
+ return true;
+ },
+ );
+ });
+
+ afterAll(async () => {
+ await carol.client.request('following/delete', { userId: bobInA.id });
+ await sleep();
+ });
});
- test('Delete is derivered to followers', async () => {
- const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
- const noteInA = await resolveRemoteNote('b.test', note.id, carol);
- await bob.client.request('notes/delete', { noteId: note.id });
- await sleep();
+ describe('To renoted and not followed user', () => {
+ test('Check', async () => {
+ const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
+ const noteInA = await resolveRemoteNote('b.test', note.id, alice);
+ await alice.client.request('notes/create', { renoteId: noteInA.id });
+ await sleep();
- await rejects(
- async () => await carol.client.request('notes/show', { noteId: noteInA.id }),
- (err: any) => {
- strictEqual(err.code, 'NO_SUCH_NOTE');
- return true;
- },
- );
+ await bob.client.request('notes/delete', { noteId: note.id });
+ await sleep();
+
+ await rejects(
+ async () => await alice.client.request('notes/show', { noteId: noteInA.id }),
+ (err: any) => {
+ strictEqual(err.code, 'NO_SUCH_NOTE');
+ return true;
+ },
+ );
+ });
+ });
+
+ describe('To replied and not followed user', () => {
+ test('Check', async () => {
+ const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
+ const noteInA = await resolveRemoteNote('b.test', note.id, alice);
+ await alice.client.request('notes/create', { text: 'Hello Bob!', replyId: noteInA.id });
+ await sleep();
+
+ await bob.client.request('notes/delete', { noteId: note.id });
+ await sleep();
+
+ await rejects(
+ async () => await alice.client.request('notes/show', { noteId: noteInA.id }),
+ (err: any) => {
+ strictEqual(err.code, 'NO_SUCH_NOTE');
+ return true;
+ },
+ );
+ });
+ });
+
+ /**
+ * FIXME: not delivered
+ * @see https://github.com/misskey-dev/misskey/issues/15548
+ */
+ describe('To only resolved and not followed user', () => {
+ test.failing('Check', async () => {
+ const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
+ const noteInA = await resolveRemoteNote('b.test', note.id, alice);
+ await sleep();
+
+ await bob.client.request('notes/delete', { noteId: note.id });
+ await sleep();
+
+ await rejects(
+ async () => await alice.client.request('notes/show', { noteId: noteInA.id }),
+ (err: any) => {
+ strictEqual(err.code, 'NO_SUCH_NOTE');
+ return true;
+ },
+ );
+ });
});
});
diff --git a/packages/backend/test-federation/test/utils.ts b/packages/backend/test-federation/test/utils.ts
index 093277cdb4..db8da5025a 100644
--- a/packages/backend/test-federation/test/utils.ts
+++ b/packages/backend/test-federation/test/utils.ts
@@ -22,7 +22,7 @@ export type LoginUser = SigninResponse & {
client: Misskey.api.APIClient;
username: string;
password: string;
-}
+};
/** used for avoiding overload and some endpoints */
export type Request = <
diff --git a/packages/backend/test-server/.swcrc b/packages/backend/test-server/.swcrc
index e3d6935169..eeac7eabc6 100644
--- a/packages/backend/test-server/.swcrc
+++ b/packages/backend/test-server/.swcrc
@@ -1,5 +1,5 @@
{
- "$schema": "https://json.schemastore.org/swcrc",
+ "$schema": "https://swc.rs/schema.json",
"jsc": {
"parser": {
"syntax": "typescript",
diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts
index a130c3698d..7ae1ee4523 100644
--- a/packages/backend/test/e2e/clips.ts
+++ b/packages/backend/test/e2e/clips.ts
@@ -182,7 +182,6 @@ describe('クリップ', () => {
{ label: 'nameがnull', parameters: { name: null } },
{ label: 'nameが最大長+1', parameters: { name: 'x'.repeat(101) } },
{ label: 'isPublicがboolじゃない', parameters: { isPublic: 'true' } },
- { label: 'descriptionがゼロ長', parameters: { description: '' } },
{ label: 'descriptionが最大長+1', parameters: { description: 'a'.repeat(2049) } },
];
test.each(createClipDenyPattern)('の作成は$labelならできない', async ({ parameters }) => failedApiCall({
@@ -199,6 +198,23 @@ describe('クリップ', () => {
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
}));
+ test('の作成はdescriptionが空文字ならnullになる', async () => {
+ const clip = await successfulApiCall({
+ endpoint: 'clips/create',
+ parameters: {
+ ...defaultCreate(),
+ description: '',
+ },
+ user: alice,
+ });
+
+ assert.deepStrictEqual(clip, {
+ ...clip,
+ ...defaultCreate(),
+ description: null,
+ });
+ });
+
test('の更新ができる', async () => {
const res = await update({
clipId: (await create()).id,
@@ -249,6 +265,24 @@ describe('クリップ', () => {
...assertion,
}));
+ test('の更新はdescriptionが空文字ならnullになる', async () => {
+ const clip = await successfulApiCall({
+ endpoint: 'clips/update',
+ parameters: {
+ clipId: (await create()).id,
+ name: 'updated',
+ description: '',
+ },
+ user: alice,
+ });
+
+ assert.deepStrictEqual(clip, {
+ ...clip,
+ name: 'updated',
+ description: null,
+ });
+ });
+
test('の削除ができる', async () => {
await deleteClip({
clipId: (await create()).id,
diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts
index 8ea4cb9800..b85cebf724 100644
--- a/packages/backend/test/e2e/fetch-resource.ts
+++ b/packages/backend/test/e2e/fetch-resource.ts
@@ -10,13 +10,13 @@ import { channel, clip, cookie, galleryPost, page, play, post, signup, simpleGet
import type { SimpleGetResponse } from '../utils.js';
import type * as misskey from 'misskey-js';
-// Request Accept
+// Request Accept in lowercase
const ONLY_AP = 'application/activity+json';
const PREFER_AP = 'application/activity+json, */*';
const PREFER_HTML = 'text/html, */*';
const UNSPECIFIED = '*/*';
-// Response Content-Type
+// Response Content-Type in lowercase
const AP = 'application/activity+json; charset=utf-8';
const HTML = 'text/html; charset=utf-8';
const JSON_UTF8 = 'application/json; charset=utf-8';
@@ -44,7 +44,8 @@ describe('Webリソース', () => {
const { path, accept, cookie, type } = param;
const res = await simpleGet(path, accept, cookie);
assert.strictEqual(res.status, 200);
- assert.strictEqual(res.type, type ?? HTML);
+ // Header values are case-insensitive
+ assert.strictEqual(res.type?.toLowerCase(), (type ?? HTML).toLowerCase());
return res;
};
@@ -95,8 +96,7 @@ describe('Webリソース', () => {
describe.each([
{ path: '/', type: HTML },
{ path: '/docs/ja-JP/about', type: HTML }, // "指定されたURLに該当するページはありませんでした。"
- // fastify-static gives charset=UTF-8 instead of utf-8 and that's okay
- { path: '/api-doc', type: 'text/html; charset=UTF-8' },
+ { path: '/api-doc', type: HTML },
{ path: '/api.json', type: JSON_UTF8 },
{ path: '/api-console', type: HTML },
{ path: '/_info_card_', type: HTML },
diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts
index 319c8581f4..d6d2cb33f0 100644
--- a/packages/backend/test/e2e/timelines.ts
+++ b/packages/backend/test/e2e/timelines.ts
@@ -397,7 +397,7 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
assert.strictEqual(res.body.some(note => note.id === carolNote1.id), false);
assert.strictEqual(res.body.some(note => note.id === carolNote2.id), false);
- }, 1000 * 15);
+ }, 1000 * 30);
test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
diff --git a/packages/backend/test/unit/MfmService.ts b/packages/backend/test/unit/MfmService.ts
index 36af8823f6..7350da3cae 100644
--- a/packages/backend/test/unit/MfmService.ts
+++ b/packages/backend/test/unit/MfmService.ts
@@ -24,13 +24,13 @@ describe('MfmService', () => {
describe('toHtml', () => {
test('br', () => {
const input = 'foo\nbar\nbaz';
- const output = '<p><span>foo<br>bar<br>baz</span></p>';
+ const output = '<p><span>foo<br />bar<br />baz</span></p>';
assert.equal(mfmService.toHtml(mfm.parse(input)), output);
});
test('br alt', () => {
const input = 'foo\r\nbar\rbaz';
- const output = '<p><span>foo<br>bar<br>baz</span></p>';
+ const output = '<p><span>foo<br />bar<br />baz</span></p>';
assert.equal(mfmService.toHtml(mfm.parse(input)), output);
});
diff --git a/packages/backend/test/unit/RelayService.ts b/packages/backend/test/unit/RelayService.ts
index 9676abf07b..3b3d212c30 100644
--- a/packages/backend/test/unit/RelayService.ts
+++ b/packages/backend/test/unit/RelayService.ts
@@ -3,6 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
+import { UtilityService } from '@/core/UtilityService.js';
+
process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';
@@ -40,6 +42,7 @@ describe('RelayService', () => {
ApRendererService,
RelayService,
UserEntityService,
+ UtilityService,
],
})
.useMocker((token) => {
diff --git a/packages/backend/test/unit/ap-request.ts b/packages/backend/test/unit/ap-request.ts
index d3d39240dc..0426de8e19 100644
--- a/packages/backend/test/unit/ap-request.ts
+++ b/packages/backend/test/unit/ap-request.ts
@@ -8,6 +8,8 @@ import httpSignature from '@peertube/http-signature';
import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
+import { assertActivityMatchesUrls, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
+import { IObject } from '@/core/activitypub/type.js';
export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => {
return {
@@ -24,6 +26,10 @@ export const buildParsedSignature = (signingString: string, signature: string, a
};
};
+function cartesianProduct<T, U>(a: T[], b: U[]): [T, U][] {
+ return a.flatMap(a => b.map(b => [a, b] as [T, U]));
+}
+
describe('ap-request', () => {
test('createSignedPost with verify', async () => {
const keypair = await genRsaKeyPair();
@@ -58,4 +64,123 @@ describe('ap-request', () => {
const result = httpSignature.verifySignature(parsed, keypair.publicKey);
assert.deepStrictEqual(result, true);
});
+
+ test('rejects non matching domain', () => {
+ assert.doesNotThrow(() => assertActivityMatchesUrls(
+ 'https://alice.example.com/abc',
+ { id: 'https://alice.example.com/abc' } as IObject,
+ [
+ 'https://alice.example.com/abc',
+ ],
+ FetchAllowSoftFailMask.Strict,
+ ), 'validation should pass base case');
+ assert.throws(() => assertActivityMatchesUrls(
+ 'https://alice.example.com/abc',
+ { id: 'https://bob.example.com/abc' } as IObject,
+ [
+ 'https://alice.example.com/abc',
+ ],
+ FetchAllowSoftFailMask.Any,
+ ), 'validation should fail no matter what if the response URL is inconsistent with the object ID');
+
+ // fix issues like threads
+ // https://github.com/misskey-dev/misskey/issues/15039
+ const withOrWithoutWWW = [
+ 'https://alice.example.com/abc',
+ 'https://www.alice.example.com/abc',
+ ];
+
+ cartesianProduct(
+ cartesianProduct(
+ withOrWithoutWWW,
+ withOrWithoutWWW,
+ ),
+ withOrWithoutWWW,
+ ).forEach(([[a, b], c]) => {
+ assert.doesNotThrow(() => assertActivityMatchesUrls(
+ a,
+ { id: b } as IObject,
+ [
+ c,
+ ],
+ FetchAllowSoftFailMask.Strict,
+ ), 'validation should pass with or without www. subdomain');
+ });
+ });
+
+ test('cross origin lookup', () => {
+ assert.doesNotThrow(() => assertActivityMatchesUrls(
+ 'https://alice.example.com/abc',
+ { id: 'https://bob.example.com/abc' } as IObject,
+ [
+ 'https://bob.example.com/abc',
+ ],
+ FetchAllowSoftFailMask.CrossOrigin | FetchAllowSoftFailMask.NonCanonicalId,
+ ), 'validation should pass if the response is otherwise consistent and cross-origin is allowed');
+ assert.throws(() => assertActivityMatchesUrls(
+ 'https://alice.example.com/abc',
+ { id: 'https://bob.example.com/abc' } as IObject,
+ [
+ 'https://bob.example.com/abc',
+ ],
+ FetchAllowSoftFailMask.Strict,
+ ), 'validation should fail if the response is otherwise consistent and cross-origin is not allowed');
+ });
+
+ test('rejects non-canonical ID', () => {
+ assert.throws(() => assertActivityMatchesUrls(
+ 'https://alice.example.com/@alice',
+ { id: 'https://alice.example.com/users/alice' } as IObject,
+ [
+ 'https://alice.example.com/users/alice'
+ ],
+ FetchAllowSoftFailMask.Strict,
+ ), 'throws if the response ID did not exactly match the expected ID');
+ assert.doesNotThrow(() => assertActivityMatchesUrls(
+ 'https://alice.example.com/@alice',
+ { id: 'https://alice.example.com/users/alice' } as IObject,
+ [
+ 'https://alice.example.com/users/alice',
+ ],
+ FetchAllowSoftFailMask.NonCanonicalId,
+ ), 'does not throw if non-canonical ID is allowed');
+ });
+
+ test('origin relaxed alignment', () => {
+ assert.doesNotThrow(() => assertActivityMatchesUrls(
+ 'https://alice.example.com/abc',
+ { id: 'https://ap.alice.example.com/abc' } as IObject,
+ [
+ 'https://ap.alice.example.com/abc',
+ ],
+ FetchAllowSoftFailMask.MisalignedOrigin | FetchAllowSoftFailMask.NonCanonicalId,
+ ), 'validation should pass if response is a subdomain of the expected origin');
+ assert.throws(() => assertActivityMatchesUrls(
+ 'https://alice.multi-tenant.example.com/abc',
+ { id: 'https://alice.multi-tenant.example.com/abc' } as IObject,
+ [
+ 'https://bob.multi-tenant.example.com/abc',
+ ],
+ FetchAllowSoftFailMask.MisalignedOrigin | FetchAllowSoftFailMask.NonCanonicalId,
+ ), 'validation should fail if response is a disjoint domain of the expected origin');
+ assert.throws(() => assertActivityMatchesUrls(
+ 'https://alice.example.com/abc',
+ { id: 'https://ap.alice.example.com/abc' } as IObject,
+ [
+ 'https://ap.alice.example.com/abc',
+ ],
+ FetchAllowSoftFailMask.Strict,
+ ), 'throws if relaxed origin is forbidden');
+ });
+
+ test('resist HTTP downgrade', () => {
+ assert.throws(() => assertActivityMatchesUrls(
+ 'https://alice.example.com/abc',
+ { id: 'https://alice.example.com/abc' } as IObject,
+ [
+ 'http://alice.example.com/abc',
+ ],
+ FetchAllowSoftFailMask.Strict,
+ ), 'throws if HTTP downgrade is detected');
+ });
});