diff options
| author | misskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com> | 2025-02-27 08:58:43 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-02-27 08:58:43 +0000 |
| commit | a5f28c21e47030a9202de9ccf87556c5bebd7129 (patch) | |
| tree | bd161b9620622d5bdc0d0a6a48dad7eda95c931d /packages/backend | |
| parent | Merge pull request #15378 from misskey-dev/develop (diff) | |
| parent | Release: 2025.2.1 (diff) | |
| download | misskey-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')
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>(Tor Browser) 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'); + }); }); |