diff --git a/.config/example.yml b/.config/example.yml index 0aa8940677..7151d5ae5b 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -303,8 +303,31 @@ id: 'aidx' #sentryForBackend: # enableNodeProfiling: true +# # Specify Sentry integration names to disable individual auto-instrumentation. +# # The names are integration .name values, not factory function names. +# # To check enabled names, set `options.debug: true` and see +# # "Integration installed: " in the Sentry logs. +# # +# # As of 2026-07-07 / @sentry/node 10.62.0, useful names for Misskey include: +# # Postgres ... DB queries (when pg is externalized) +# # Redis ... ioredis commands +# # Fastify ... inbound HTTP routes +# # Http ... inbound/outbound HTTP; disabling this can also affect request isolation +# # NodeFetch ... fetch/undici requests +# disabledIntegrations: ['Postgres'] # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' +# # By default, Misskey prevents Sentry trace headers (`sentry-trace` and +# # `baggage`) from being sent to remote ActivityPub/Webhook/etc. hosts. +# # To intentionally propagate distributed traces to trusted internal services, +# # list only those internal URL patterns here. +# # Avoid broad patterns that match remote servers unless you intentionally +# # want to restore Sentry's legacy behavior of propagating traces to all +# # outbound HTTP requests. +# #tracePropagationTargets: [] +# # Internal allowlist example: +# #tracePropagationTargets: +# # - 'internal-service.example' #sentryForFrontend: # vueIntegration: diff --git a/.github/workflows/report-api-diff.yml b/.github/workflows/report-api-diff.yml index 67d163d697..4dc65fb1a5 100644 --- a/.github/workflows/report-api-diff.yml +++ b/.github/workflows/report-api-diff.yml @@ -65,7 +65,7 @@ jobs: echo '```diff' >> ./output.md cat ./api.json.diff >> ./output.md echo '```' >> ./output.md - echo '' >> ./output.md + echo '' >> .output.md fi echo "$FOOTER" >> ./output.md diff --git a/CHANGELOG.md b/CHANGELOG.md index bad2db5ce2..be5e129d34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## 2026.6.1 +## 2026.7.0 ### Note @@ -15,19 +15,25 @@ ### General - Feat: コントロールパネルから二要素認証を解除できるように +- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように + (Based on https://github.com/MisskeyIO/misskey/pull/214) ### Client -- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正 - 2025.4.0 以前の設定情報の移行処理が削除されました - 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。 - Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正 +- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正 +- Fix: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正 ### Server - Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804) +- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように - Enhance: Node.js 22.23.0以降、24.17.0以降、26.4.0以降をサポートするように - Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新 - Fix: `/stats` API のレスポンス型が正しくない問題を修正 - Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正 +- Fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正 +- Fix: Sentry 使用環境下にて、外部送信リクエストへ `sentry-trace` / `baggage` ヘッダーが既定で付与されないように ## 2026.6.0 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 965330ee82..2f6c912044 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1419,6 +1419,8 @@ addToEmojiPalette: "絵文字パレットに追加" emojiPaletteAlreadyAddedConfirm: "この絵文字はすでにこの絵文字パレットに含まれています。追加しなおしますか?" append: "末尾に追加" prepend: "先頭に追加" +urlPreviewSensitiveList: "サムネイルの表示を制限するURL" +urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。" _imageEditing: _vars: diff --git a/package.json b/package.json index 5dcc85c172..f710f031dd 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "misskey", - "version": "2026.7.0-alpha.0", + "version": "2026.7.0-alpha.2", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/misskey-dev/misskey.git" }, - "packageManager": "pnpm@11.8.0", + "packageManager": "pnpm@11.9.0", "workspaces": [ "packages/misskey-js", "packages/i18n", @@ -57,23 +57,22 @@ "esbuild": "0.28.1", "execa": "9.6.1", "ignore-walk": "9.0.0", - "js-yaml": "4.2.0", - "tar": "7.5.16" + "js-yaml": "5.2.0", + "tar": "7.5.19" }, "devDependencies": { "@eslint/js": "9.39.4", "@misskey-dev/eslint-plugin": "2.1.0", - "@types/js-yaml": "4.0.9", - "@types/node": "26.0.0", - "@typescript-eslint/eslint-plugin": "8.61.1", - "@typescript-eslint/parser": "8.61.1", + "@types/node": "26.0.1", + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", "@typescript/native-preview": "7.0.0-dev.20260426.1", "cross-env": "10.1.0", - "cypress": "15.17.0", + "cypress": "15.18.0", "eslint": "9.39.4", - "globals": "17.6.0", + "globals": "17.7.0", "ncp": "2.0.0", - "pnpm": "11.8.0", + "pnpm": "11.9.0", "start-server-and-test": "3.0.11", "typescript": "5.9.3" } diff --git a/packages/backend/migration/1782581064131-urlPreviewSensitiveList.js b/packages/backend/migration/1782581064131-urlPreviewSensitiveList.js new file mode 100644 index 0000000000..8c0bdedeb9 --- /dev/null +++ b/packages/backend/migration/1782581064131-urlPreviewSensitiveList.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class UrlPreviewSensitiveList1782581064131 { + name = 'UrlPreviewSensitiveList1782581064131' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "urlPreviewSensitiveList" character varying(3072) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "urlPreviewSensitiveList"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 58b4df7158..30b6a8a4ca 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -51,8 +51,8 @@ "utf-8-validate": "6.0.6" }, "dependencies": { - "@aws-sdk/client-s3": "3.1073.0", - "@aws-sdk/lib-storage": "3.1073.0", + "@aws-sdk/client-s3": "3.1075.0", + "@aws-sdk/lib-storage": "3.1075.0", "@fastify/accepts": "5.0.4", "@fastify/cors": "11.2.0", "@fastify/http-proxy": "11.5.0", @@ -63,23 +63,22 @@ "@misskey-dev/emoji-data": "17.0.3", "@misskey-dev/sharp-read-bmp": "1.3.1", "@misskey-dev/summaly": "5.5.1", - "@napi-rs/canvas": "1.0.0", + "@napi-rs/canvas": "1.0.1", "@nestjs/common": "11.1.27", "@nestjs/core": "11.1.27", "@nestjs/testing": "11.1.27", "@oxc-project/runtime": "0.137.0", "@peertube/http-signature": "1.7.0", - "@sentry/node": "10.59.0", - "@sentry/profiling-node": "10.59.0", - "@simplewebauthn/server": "13.3.1", - "@sinonjs/fake-timers": "15.4.0", - "@smithy/node-http-handler": "4.8.1", + "@sentry/node": "10.62.0", + "@sentry/profiling-node": "10.62.0", + "@simplewebauthn/server": "13.3.2", + "@smithy/node-http-handler": "4.8.2", "accepts": "1.3.8", "ajv": "8.20.0", "archiver": "8.0.0", "bcryptjs": "3.0.3", "blurhash": "2.0.5", - "bullmq": "5.79.0", + "bullmq": "5.79.2", "cacheable-lookup": "7.0.0", "chalk": "5.6.2", "chalk-template": "1.1.2", @@ -88,12 +87,12 @@ "content-disposition": "2.0.1", "date-fns": "4.4.0", "deep-email-validator": "0.1.27", - "fastify": "5.8.5", + "fastify": "5.9.0", "fastify-raw-body": "5.0.0", "feed": "5.2.1", "file-type": "22.0.1", "fluent-ffmpeg": "2.1.3", - "got": "15.0.5", + "got": "15.0.7", "hpagent": "1.2.0", "http-link-header": "1.1.3", "i18n": "workspace:*", @@ -110,10 +109,10 @@ "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", "ms": "3.0.0-canary.202508261828", - "nanoid": "5.1.14", + "nanoid": "5.1.16", "nested-property": "4.0.0", "node-fetch": "3.3.2", - "node-html-parser": "7.1.0", + "node-html-parser": "8.0.3", "nodemailer": "9.0.1", "os-utils": "0.0.14", "otpauth": "9.5.1", @@ -135,7 +134,7 @@ "slacc": "0.1.5", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", - "systeminformation": "5.31.7", + "systeminformation": "5.31.11", "tinycolor2": "1.6.0", "tmp": "0.2.7", "tsc-alias": "1.8.17", @@ -150,16 +149,16 @@ "@kitajs/ts-html-plugin": "4.1.4", "@nestjs/platform-express": "11.1.27", "@rollup/plugin-esm-shim": "0.1.8", - "@sentry/vue": "10.59.0", + "@sentry/vue": "10.62.0", + "@sinonjs/fake-timers": "15.4.0", "@types/accepts": "1.3.7", "@types/archiver": "8.0.0", "@types/fluent-ffmpeg": "2.1.28", "@types/http-link-header": "1.0.7", - "@types/js-yaml": "4.0.9", "@types/jsonld": "1.5.15", "@types/mime-types": "3.0.1", "@types/ms": "2.1.0", - "@types/node": "26.0.0", + "@types/node": "26.0.1", "@types/nodemailer": "8.0.1", "@types/pg": "8.20.0", "@types/qrcode": "1.5.6", @@ -170,14 +169,13 @@ "@types/semver": "7.7.1", "@types/simple-oauth2": "5.0.8", "@types/sinonjs__fake-timers": "15.0.1", - "@types/supertest": "7.2.0", "@types/tinycolor2": "1.4.6", "@types/tmp": "0.2.6", "@types/vary": "1.1.3", "@types/web-push": "3.6.4", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.61.1", - "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", "@vitest/coverage-v8": "4.1.9", "aws-sdk-client-mock": "4.1.0", "cbor2": "2.3.0", @@ -185,11 +183,10 @@ "eslint-plugin-import": "2.32.0", "execa": "9.6.1", "fkill": "10.0.3", - "js-yaml": "4.2.0", + "js-yaml": "5.2.0", "pid-port": "2.1.1", - "rolldown": "1.1.2", + "rolldown": "1.1.3", "simple-oauth2": "5.1.0", - "supertest": "7.2.2", "vite": "8.1.0", "vitest": "4.1.9", "vitest-mock-extended": "4.0.0" diff --git a/packages/backend/rolldown.config.ts b/packages/backend/rolldown.config.ts index 187c444b58..8d8278fdd3 100644 --- a/packages/backend/rolldown.config.ts +++ b/packages/backend/rolldown.config.ts @@ -75,6 +75,8 @@ export default defineConfig((args) => { 're2', 'ipaddr.js', 'file-type', + // バンドルするとSentryの自動計装が正しく行われなくなるため外しておく + 'pg', ]; const define: Record = { diff --git a/packages/backend/scripts/compile_config.js b/packages/backend/scripts/compile_config.js index e78fa3dc9f..2eefbe3cfb 100644 --- a/packages/backend/scripts/compile_config.js +++ b/packages/backend/scripts/compile_config.js @@ -11,7 +11,7 @@ import fs from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import yaml from 'js-yaml'; +import { load as loadYaml } from 'js-yaml'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -34,7 +34,7 @@ function yamlToJson(ymlPath) { console.log(`${ymlPath} → ${OUTPUT_PATH}`); const yamlContent = fs.readFileSync(ymlPath, 'utf-8'); - const jsonContent = yaml.load(yamlContent); + const jsonContent = loadYaml(yamlContent); if (!fs.existsSync(dirname(OUTPUT_PATH))) { fs.mkdirSync(dirname(OUTPUT_PATH), { recursive: true }); } diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index 533ebe5bbb..aac68cb02f 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -13,6 +13,7 @@ import { loadConfig } from '@/config.js'; import type { Config } from '@/config.js'; import { showMachineInfo } from '@/misc/show-machine-info.js'; import { envOption } from '@/env.js'; +import { initTelemetry } from '@/core/telemetry/telemetry-registry.js'; import { initExtraThreadPool, jobQueue, server } from './common.js'; const logger = new Logger('core', 'cyan'); @@ -66,26 +67,7 @@ export async function masterMain() { initExtraThreadPool(config); - if (config.sentryForBackend) { - const Sentry = await import('@sentry/node'); - const { nodeProfilingIntegration } = await import('@sentry/profiling-node'); - - Sentry.init({ - integrations: [ - ...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []), - ], - - // Performance Monitoring - tracesSampleRate: 1.0, // Capture 100% of the transactions - - // Set sampling rate for profiling - this is relative to tracesSampleRate - profilesSampleRate: 1.0, - - maxBreadcrumbs: 0, - - ...config.sentryForBackend.options, - }); - } + await initTelemetry(config); bootLogger.info( `mode: [disableClustering: ${envOption.disableClustering}, onlyServer: ${envOption.onlyServer}, onlyQueue: ${envOption.onlyQueue}]`, diff --git a/packages/backend/src/boot/worker.ts b/packages/backend/src/boot/worker.ts index d8fb1eeaac..00d5dd9063 100644 --- a/packages/backend/src/boot/worker.ts +++ b/packages/backend/src/boot/worker.ts @@ -6,6 +6,7 @@ import cluster from 'node:cluster'; import { envOption } from '@/env.js'; import { loadConfig } from '@/config.js'; +import { initTelemetry } from '@/core/telemetry/telemetry-registry.js'; import { initExtraThreadPool, jobQueue, server } from './common.js'; /** @@ -16,26 +17,7 @@ export async function workerMain() { initExtraThreadPool(config); - if (config.sentryForBackend) { - const Sentry = await import('@sentry/node'); - const { nodeProfilingIntegration } = await import('@sentry/profiling-node'); - - Sentry.init({ - integrations: [ - ...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []), - ], - - // Performance Monitoring - tracesSampleRate: 1.0, // Capture 100% of the transactions - - // Set sampling rate for profiling - this is relative to tracesSampleRate - profilesSampleRate: 1.0, - - maxBreadcrumbs: 0, - - ...config.sentryForBackend.options, - }); - } + await initTelemetry(config); if (envOption.onlyServer) { await server(); diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index d67fe6fc6f..a9ce40db09 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -20,6 +20,12 @@ type RedisOptionsSource = Partial & { prefix?: string; }; +type SentryBackendConfig = { + options: Partial; + enableNodeProfiling: boolean; + disabledIntegrations?: string[]; +}; + /** * 設定ファイルの型 */ @@ -64,7 +70,7 @@ type Source = { index: string; scope?: 'local' | 'global' | string[]; }; - sentryForBackend?: { options: Partial; enableNodeProfiling: boolean; }; + sentryForBackend?: SentryBackendConfig; sentryForFrontend?: { options: Partial & { dsn: string }; vueIntegration?: SentryVue.VueIntegrationOptions | null; @@ -201,7 +207,7 @@ export type Config = { redisForJobQueue: RedisOptions & RedisOptionsSource; redisForTimelines: RedisOptions & RedisOptionsSource; redisForReactions: RedisOptions & RedisOptionsSource; - sentryForBackend: { options: Partial; enableNodeProfiling: boolean; } | undefined; + sentryForBackend: SentryBackendConfig | undefined; sentryForFrontend: { options: Partial & { dsn: string }; vueIntegration?: SentryVue.VueIntegrationOptions | null; diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index f075671d93..6ac4eb9079 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -154,10 +154,12 @@ import { ApQuestionService } from './activitypub/models/ApQuestionService.js'; import { QueueModule } from './QueueModule.js'; import { QueueService } from './QueueService.js'; import { LoggerService } from './LoggerService.js'; +import { TelemetryService } from './telemetry/TelemetryService.js'; import type { Provider } from '@nestjs/common'; //#region 文字列ベースでのinjection用(循環参照対応のため) const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService }; +const $TelemetryService: Provider = { provide: 'TelemetryService', useExisting: TelemetryService }; const $AbuseReportService: Provider = { provide: 'AbuseReportService', useExisting: AbuseReportService }; const $AbuseReportNotificationService: Provider = { provide: 'AbuseReportNotificationService', useExisting: AbuseReportNotificationService }; const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService }; @@ -458,6 +460,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApPersonService, ApQuestionService, QueueService, + TelemetryService, //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, @@ -606,6 +609,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApNoteService, $ApPersonService, $ApQuestionService, + $TelemetryService, //#endregion ], exports: [ @@ -757,6 +761,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApPersonService, ApQuestionService, QueueService, + TelemetryService, //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, @@ -903,6 +908,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApNoteService, $ApPersonService, $ApQuestionService, + $TelemetryService, //#endregion ], }) diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts index 384704b252..5324864ad1 100644 --- a/packages/backend/src/core/EmailService.ts +++ b/packages/backend/src/core/EmailService.ts @@ -3,11 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { URLSearchParams } from 'node:url'; import * as nodemailer from 'nodemailer'; import juice from 'juice'; import { Inject, Injectable } from '@nestjs/common'; -import { validate as validateEmail } from 'deep-email-validator'; import { UtilityService } from '@/core/UtilityService.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; @@ -197,6 +195,7 @@ export class EmailService { } else if (this.meta.enableTruemailApi && this.meta.truemailInstance && this.meta.truemailAuthKey != null) { validated = await this.trueMail(this.meta.truemailInstance, emailAddress, this.meta.truemailAuthKey); } else { + const { validate: validateEmail } = await import('deep-email-validator'); validated = await validateEmail({ email: emailAddress, validateRegex: true, diff --git a/packages/backend/src/core/telemetry/TelemetryService.ts b/packages/backend/src/core/telemetry/TelemetryService.ts new file mode 100644 index 0000000000..e73beb4f8a --- /dev/null +++ b/packages/backend/src/core/telemetry/TelemetryService.ts @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; +import { captureMessage, shutdownTelemetry, startSpan } from './telemetry-registry.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; +import type { TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js'; + +@Injectable() +export class TelemetryService implements OnApplicationShutdown { + @bindThis + public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void { + captureMessage(message, opts); + } + + @bindThis + public startSpan(name: string, fn: () => T): T { + return startSpan(name, fn); + } + + @bindThis + public async onApplicationShutdown(_signal?: string): Promise { + await shutdownTelemetry(); + } +} diff --git a/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts new file mode 100644 index 0000000000..b1858c31c1 --- /dev/null +++ b/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Config } from '@/config.js'; +import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js'; + +type SentryIntegrationsOption = NonNullable; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SentryIntegrationFactory = Extract any[]>; +type SentryIntegration = Parameters[0][number]; +type SentryNodeOptions = import('@sentry/node').NodeOptions; + +type BuildSentryIntegrationsOptions = { + disabledIntegrations?: string[]; + enableNodeProfiling: boolean; + nodeProfilingIntegration?: () => SentryIntegration; + warn?: (message: string) => void; +}; + +export function buildSentryIntegrations(options: BuildSentryIntegrationsOptions): SentryIntegrationFactory { + return (defaults) => { + const disabledIntegrations = new Set(options.disabledIntegrations ?? []); + const defaultIntegrationNames = new Set(defaults.map((integration) => integration.name)); + const unknownIntegrations = [...disabledIntegrations].filter((name) => !defaultIntegrationNames.has(name)); + + if (unknownIntegrations.length > 0) { + (options.warn ?? console.warn)(`Unknown Sentry integration configured in sentryForBackend.disabledIntegrations: ${unknownIntegrations.join(', ')}`); + } + + return [ + ...defaults.filter((integration) => !disabledIntegrations.has(integration.name)), + ...(options.enableNodeProfiling && options.nodeProfilingIntegration != null ? [options.nodeProfilingIntegration()] : []), + ]; + }; +} + +export function buildSentryNodeOptions( + config: NonNullable, + nodeProfilingIntegration?: () => SentryIntegration, +): SentryNodeOptions { + return { + // Do not send Sentry trace headers to remote ActivityPub/Webhook/etc. hosts by default. + // Admins can opt in for trusted internal services via sentryForBackend.options. + tracePropagationTargets: [], + + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions + + // Set sampling rate for profiling - this is relative to tracesSampleRate + profilesSampleRate: 1.0, + + maxBreadcrumbs: 0, + + ...config.options, + + integrations: buildSentryIntegrations({ + disabledIntegrations: config.disabledIntegrations, + enableNodeProfiling: config.enableNodeProfiling, + nodeProfilingIntegration, + }), + }; +} + +export class SentryTelemetryAdapter implements TelemetryAdapter { + private constructor( + private readonly Sentry: typeof import('@sentry/node'), + ) { + } + + public static async create(config: NonNullable): Promise { + const Sentry = await import('@sentry/node'); + const { nodeProfilingIntegration } = await import('@sentry/profiling-node'); + + Sentry.init(buildSentryNodeOptions(config, nodeProfilingIntegration)); + + return new SentryTelemetryAdapter(Sentry); + } + + public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void { + this.Sentry.captureMessage(message, { + level: opts.level, + ...(opts.userId != null ? { user: { id: opts.userId } } : {}), + extra: opts.extra, + }); + } + + public startSpan(name: string, fn: () => T): T { + return this.Sentry.startSpan({ name }, fn); + } + + public async shutdown(): Promise { + await this.Sentry.close(); + } +} diff --git a/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts new file mode 100644 index 0000000000..d074de056d --- /dev/null +++ b/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export interface TelemetryCaptureMessageOptions { + level: 'error'; + userId?: string; + extra?: Record; +} + +/** + * Sentry・OpenTelemetryなど、エラートラッキング/APMサービスごとの実装差異を隠蔽するための抽象。 + * 新しいサービスを追加する場合はこのインターフェースを実装するアダプタをこのディレクトリに追加し、 + * telemetry-registry.tsのinitTelemetry内で登録する。 + */ +export interface TelemetryAdapter { + captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void; + startSpan(name: string, fn: () => T): T; + shutdown(): Promise; +} diff --git a/packages/backend/src/core/telemetry/telemetry-registry.ts b/packages/backend/src/core/telemetry/telemetry-registry.ts new file mode 100644 index 0000000000..90dc267837 --- /dev/null +++ b/packages/backend/src/core/telemetry/telemetry-registry.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Config } from '@/config.js'; +import { SentryTelemetryAdapter } from './adapters/SentryTelemetryAdapter.js'; +import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js'; + +/** + * NestのDIコンテナが構築される前(boot処理内)で初期化する必要があるため、 + * DIを介さないモジュールレベルの状態として有効なアダプタを保持する。 + * TelemetryServiceはこの状態への薄いラッパーとして振る舞う。 + */ +const adapters: TelemetryAdapter[] = []; + +export async function initTelemetry(config: Config): Promise { + if (config.sentryForBackend) { + adapters.push(await SentryTelemetryAdapter.create(config.sentryForBackend)); + } +} + +export function captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void { + for (const adapter of adapters) { + adapter.captureMessage(message, opts); + } +} + +export function startSpan(name: string, fn: () => T): T { + const wrapped = adapters.reduceRight<() => T>( + (inner, adapter) => () => adapter.startSpan(name, inner), + fn, + ); + return wrapped(); +} + +export async function shutdownTelemetry(): Promise { + await Promise.all(adapters.map(adapter => adapter.shutdown())); +} diff --git a/packages/backend/src/env.ts b/packages/backend/src/env.ts index ba44cfa2e6..c3b1cd015f 100644 --- a/packages/backend/src/env.ts +++ b/packages/backend/src/env.ts @@ -11,6 +11,7 @@ const envOption = { verbose: false, withLogTime: false, quiet: false, + enableCrossOriginIsolation: false, }; for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) { diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index d4214c0e5a..307b3f7a5d 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -672,6 +672,11 @@ export class MiMeta { }) public urlPreviewUserAgent: string | null; + @Column('varchar', { + length: 3072, array: true, default: '{}', + }) + public urlPreviewSensitiveList: string[]; + @Column('varchar', { length: 128, default: 'none', diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 2b3b3fc0ad..cd1e752fd5 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -9,6 +9,7 @@ import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { TelemetryService } from '@/core/telemetry/TelemetryService.js'; import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; @@ -92,6 +93,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private config: Config, private queueLoggerService: QueueLoggerService, + private telemetryService: TelemetryService, private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService, private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService, private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, @@ -156,13 +158,6 @@ export class QueueProcessorService implements OnApplicationShutdown { }; } - let Sentry: typeof import('@sentry/node') | undefined; - if (this.config.sentryForBackend) { - import('@sentry/node').then((mod) => { - Sentry = mod; - }); - } - //#region system { const processer = (job: Bull.Job) => { @@ -181,11 +176,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }; this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: System: ' + job.name }, () => processer(job)); - } else { - return processer(job); - } + return this.telemetryService.startSpan('Queue: System: ' + job.name, () => processer(job)); }, { ...baseWorkerOptions(this.config, QUEUE.SYSTEM), autorun: false, @@ -198,12 +189,10 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err: Error) => { logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - if (Sentry != null) { - Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - } + this.telemetryService.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); @@ -238,11 +227,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }; this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: DB: ' + job.name }, () => processer(job)); - } else { - return processer(job); - } + return this.telemetryService.startSpan('Queue: DB: ' + job.name, () => processer(job)); }, { ...baseWorkerOptions(this.config, QUEUE.DB), autorun: false, @@ -255,12 +240,10 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - if (Sentry != null) { - Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - } + this.telemetryService.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); @@ -270,11 +253,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region deliver { this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: Deliver' }, () => this.deliverProcessorService.process(job)); - } else { - return this.deliverProcessorService.process(job); - } + return this.telemetryService.startSpan('Queue: Deliver', () => this.deliverProcessorService.process(job)); }, { ...baseWorkerOptions(this.config, QUEUE.DELIVER), autorun: false, @@ -295,12 +274,10 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - if (Sentry != null) { - Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - } + this.telemetryService.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); @@ -310,11 +287,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region inbox { this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: Inbox' }, () => this.inboxProcessorService.process(job)); - } else { - return this.inboxProcessorService.process(job); - } + return this.telemetryService.startSpan('Queue: Inbox', () => this.inboxProcessorService.process(job)); }, { ...baseWorkerOptions(this.config, QUEUE.INBOX), autorun: false, @@ -335,12 +308,10 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job: renderJob(job), e: renderError(err) }); - if (Sentry != null) { - Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - } + this.telemetryService.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); @@ -350,11 +321,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region user-webhook deliver { this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: UserWebhookDeliver' }, () => this.userWebhookDeliverProcessorService.process(job)); - } else { - return this.userWebhookDeliverProcessorService.process(job); - } + return this.telemetryService.startSpan('Queue: UserWebhookDeliver', () => this.userWebhookDeliverProcessorService.process(job)); }, { ...baseWorkerOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER), autorun: false, @@ -375,12 +342,10 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - if (Sentry != null) { - Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - } + this.telemetryService.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); @@ -390,11 +355,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region system-webhook deliver { this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: SystemWebhookDeliver' }, () => this.systemWebhookDeliverProcessorService.process(job)); - } else { - return this.systemWebhookDeliverProcessorService.process(job); - } + return this.telemetryService.startSpan('Queue: SystemWebhookDeliver', () => this.systemWebhookDeliverProcessorService.process(job)); }, { ...baseWorkerOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER), autorun: false, @@ -415,12 +376,10 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - if (Sentry != null) { - Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - } + this.telemetryService.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); @@ -440,11 +399,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }; this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: Relationship: ' + job.name }, () => processer(job)); - } else { - return processer(job); - } + return this.telemetryService.startSpan('Queue: Relationship: ' + job.name, () => processer(job)); }, { ...baseWorkerOptions(this.config, QUEUE.RELATIONSHIP), autorun: false, @@ -462,12 +417,10 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - if (Sentry != null) { - Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - } + this.telemetryService.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); @@ -485,11 +438,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }; this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: ObjectStorage: ' + job.name }, () => processer(job)); - } else { - return processer(job); - } + return this.telemetryService.startSpan('Queue: ObjectStorage: ' + job.name, () => processer(job)); }, { ...baseWorkerOptions(this.config, QUEUE.OBJECT_STORAGE), autorun: false, @@ -503,12 +452,10 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - if (Sentry != null) { - Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - } + this.telemetryService.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); @@ -518,11 +465,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region ended poll notification { this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: EndedPollNotification' }, () => this.endedPollNotificationProcessorService.process(job)); - } else { - return this.endedPollNotificationProcessorService.process(job); - } + return this.telemetryService.startSpan('Queue: EndedPollNotification', () => this.endedPollNotificationProcessorService.process(job)); }, { ...baseWorkerOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION), autorun: false, @@ -533,11 +476,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region post scheduled note { this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, async (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: PostScheduledNote' }, () => this.postScheduledNoteProcessorService.process(job)); - } else { - return this.postScheduledNoteProcessorService.process(job); - } + return this.telemetryService.startSpan('Queue: PostScheduledNote', () => this.postScheduledNoteProcessorService.process(job)); }, { ...baseWorkerOptions(this.config, QUEUE.POST_SCHEDULED_NOTE), autorun: false, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 23ead0feba..1333c1a88b 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -21,6 +21,7 @@ import { genIdenticon } from '@/misc/gen-identicon.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; +import { envOption } from '@/env.js'; import { ActivityPubServerService } from './ActivityPubServerService.js'; import { NodeinfoServerService } from './NodeinfoServerService.js'; import { ApiServerService } from './api/ApiServerService.js'; @@ -89,6 +90,15 @@ export class ServerService implements OnApplicationShutdown { }); } + // for test + if (envOption.enableCrossOriginIsolation) { + fastify.addHook('onRequest', (request, reply, done) => { + reply.header('Cross-Origin-Opener-Policy', 'same-origin'); + reply.header('Cross-Origin-Embedder-Policy', 'credentialless'); + done(); + }); + } + // Register raw-body parser for ActivityPub HTTP signature validation. await fastify.register(fastifyRawBody, { global: false, diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 0ccb3df631..dc3a88ca27 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -16,6 +16,7 @@ import type { MiMeta, UserIpsRepository } from '@/models/_.js'; import { createTemp } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { TelemetryService } from '@/core/telemetry/TelemetryService.js'; import type { Config } from '@/config.js'; import { ApiError } from './error.js'; import { RateLimiterService } from './RateLimiterService.js'; @@ -36,7 +37,6 @@ export class ApiCallService implements OnApplicationShutdown { private logger: Logger; private userIpHistories: Map>; private userIpHistoriesClearIntervalId: NodeJS.Timeout; - private Sentry: typeof import('@sentry/node') | null = null; constructor( @Inject(DI.meta) @@ -52,6 +52,7 @@ export class ApiCallService implements OnApplicationShutdown { private rateLimiterService: RateLimiterService, private roleService: RoleService, private apiLoggerService: ApiLoggerService, + private telemetryService: TelemetryService, ) { this.logger = this.apiLoggerService.logger; this.userIpHistories = new Map>(); @@ -59,12 +60,6 @@ export class ApiCallService implements OnApplicationShutdown { this.userIpHistoriesClearIntervalId = setInterval(() => { this.userIpHistories.clear(); }, 1000 * 60 * 60); - - if (this.config.sentryForBackend) { - import('@sentry/node').then((Sentry) => { - this.Sentry = Sentry; - }); - } } #sendApiError(reply: FastifyReply, err: ApiError): void { @@ -126,24 +121,20 @@ export class ApiCallService implements OnApplicationShutdown { }, }); - if (this.Sentry != null) { - this.Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { - level: 'error', - user: { - id: userId, + this.telemetryService.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { + level: 'error', + userId, + extra: { + ep: ep.name, + ps: data, + e: { + message: err.message, + code: err.name, + stack: err.stack, + id: errId, }, - extra: { - ep: ep.name, - ps: data, - e: { - message: err.message, - code: err.name, - stack: err.stack, - id: errId, - }, - }, - }); - } + }, + }); throw new ApiError(null, { e: { @@ -441,15 +432,8 @@ export class ApiCallService implements OnApplicationShutdown { } // API invoking - if (this.Sentry != null) { - return await this.Sentry.startSpan({ - name: 'API: ' + ep.name, - }, () => ep.exec(data, user, token, file, request.ip, request.headers) - .catch((err: Error) => this.#onExecError(ep, data, err, user?.id))); - } else { - return await ep.exec(data, user, token, file, request.ip, request.headers) - .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)); - } + return await this.telemetryService.startSpan('API: ' + ep.name, () => ep.exec(data, user, token, file, request.ip, request.headers) + .catch((err: Error) => this.#onExecError(ep, data, err, user?.id))); } @bindThis diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 956f991d32..849a05c334 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -544,6 +544,14 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + urlPreviewSensitiveList: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, federation: { type: 'string', enum: ['all', 'specified', 'none'], @@ -760,6 +768,7 @@ export default class extends Endpoint { // eslint- urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength, urlPreviewUserAgent: instance.urlPreviewUserAgent, urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl, + urlPreviewSensitiveList: instance.urlPreviewSensitiveList, federation: instance.federation, federationHosts: instance.federationHosts, deliverSuspendedSoftware: instance.deliverSuspendedSoftware, 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 def6a0bda7..4a7df410bc 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -189,6 +189,12 @@ export const paramDef = { urlPreviewRequireContentLength: { type: 'boolean' }, urlPreviewUserAgent: { type: 'string', nullable: true }, urlPreviewSummaryProxyUrl: { type: 'string', nullable: true }, + urlPreviewSensitiveList: { + type: 'array', nullable: true, + items: { + type: 'string', + } + }, federation: { type: 'string', enum: ['all', 'none', 'specified'], @@ -734,6 +740,10 @@ export default class extends Endpoint { // eslint- set.urlPreviewSummaryProxyUrl = value === '' ? null : value; } + if (Array.isArray(ps.urlPreviewSensitiveList)) { + set.urlPreviewSensitiveList = ps.urlPreviewSensitiveList.filter(Boolean); + } + if (ps.federation !== undefined) { set.federation = ps.federation; } diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index 886e876c40..e5ba8c596c 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -11,6 +11,7 @@ import { HttpRequestService } from '@/core/HttpRequestService.js'; import type Logger from '@/logger.js'; import { query } from '@/misc/prelude/url.js'; import { LoggerService } from '@/core/LoggerService.js'; +import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { ApiError } from '@/server/api/error.js'; import { MiMeta } from '@/models/Meta.js'; @@ -29,6 +30,7 @@ export class UrlPreviewService { private meta: MiMeta, private httpRequestService: HttpRequestService, + private utilityService: UtilityService, private loggerService: LoggerService, ) { this.logger = this.loggerService.getLogger('url-preview'); @@ -95,6 +97,10 @@ export class UrlPreviewService { summary.icon = this.wrap(summary.icon); summary.thumbnail = this.wrap(summary.thumbnail); + if (summary.sensitive !== true) { + summary.sensitive = this.utilityService.isKeyWordIncluded(summary.url, this.meta.urlPreviewSensitiveList); + } + // Cache 1day reply.header('Cache-Control', 'max-age=86400, immutable'); diff --git a/packages/backend/test/e2e/api-visibility.ts b/packages/backend/test/e2e/api-visibility.ts index 4f244c0cce..8bfca077ba 100644 --- a/packages/backend/test/e2e/api-visibility.ts +++ b/packages/backend/test/e2e/api-visibility.ts @@ -6,10 +6,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { describe, beforeAll, beforeEach, test } from 'vitest'; +import { describe, beforeAll, beforeEach, test, vi } from 'vitest'; import { UserToken, api, post, signup } from '../utils.js'; import type * as misskey from 'misskey-js'; +const waitForPushToTlOptions = { timeout: 3000, interval: 25 }; + describe('API visibility', () => { describe('Note visibility', () => { //#region vars @@ -409,10 +411,12 @@ describe('API visibility', () => { //#region HTL test('[HTL] public-post が 自分が見れる', async () => { - const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.status, 200); - const notes = res.body.filter(n => n.id === pub.id); - assert.strictEqual(notes[0].text, 'x'); + await vi.waitFor(async () => { + const res = await api('notes/timeline', { limit: 100 }, alice); + assert.strictEqual(res.status, 200); + const notes = res.body.filter(n => n.id === pub.id); + assert.strictEqual(notes[0].text, 'x'); + }, waitForPushToTlOptions); }); test('[HTL] public-post が 非フォロワーから見れない', async () => { @@ -423,10 +427,12 @@ describe('API visibility', () => { }); test('[HTL] followers-post が フォロワーから見れる', async () => { - const res = await api('notes/timeline', { limit: 100 }, follower); - assert.strictEqual(res.status, 200); - const notes = res.body.filter(n => n.id === fol.id); - assert.strictEqual(notes[0].text, 'x'); + await vi.waitFor(async () => { + const res = await api('notes/timeline', { limit: 100 }, follower); + assert.strictEqual(res.status, 200); + const notes = res.body.filter(n => n.id === fol.id); + assert.strictEqual(notes[0].text, 'x'); + }, waitForPushToTlOptions); }); //#endregion diff --git a/packages/backend/test/e2e/block.ts b/packages/backend/test/e2e/block.ts index 9ef4dd8be9..86e27b461e 100644 --- a/packages/backend/test/e2e/block.ts +++ b/packages/backend/test/e2e/block.ts @@ -6,10 +6,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { describe, beforeAll, test } from 'vitest'; +import { describe, beforeAll, test, vi } from 'vitest'; import { api, castAsError, post, signup } from '../utils.js'; import type * as misskey from 'misskey-js'; +const waitForPushToTlOptions = { timeout: 3000, interval: 25 }; + describe('Block', () => { // alice blocks bob let alice: misskey.entities.SignupResponse; @@ -75,13 +77,15 @@ describe('Block', () => { const bobNote = await post(bob, { text: 'hi' }); const carolNote = await post(carol, { text: 'hi' }); - const res = await api('notes/local-timeline', {}, bob); - const body = res.body as misskey.entities.Note[]; + await vi.waitFor(async () => { + const res = await api('notes/local-timeline', {}, bob); + const body = res.body as misskey.entities.Note[]; - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(body.some(note => note.id === aliceNote.id), false); - assert.strictEqual(body.some(note => note.id === bobNote.id), true); - assert.strictEqual(body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(body.some(note => note.id === aliceNote.id), false); + assert.strictEqual(body.some(note => note.id === bobNote.id), true); + assert.strictEqual(body.some(note => note.id === carolNote.id), true); + }, waitForPushToTlOptions); }); }); diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index 09198384c4..402836ca47 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -6,7 +6,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { describe, beforeAll, test, expect } from 'vitest'; +import { describe, beforeAll, test, expect, vi } from 'vitest'; // node-fetch only supports it's own Blob yet // https://github.com/node-fetch/node-fetch/pull/1664 import { Blob } from 'node-fetch'; @@ -14,6 +14,8 @@ import { api, castAsError, initTestDb, post, role, signup, simpleGet, uploadFile import type * as misskey from 'misskey-js'; import { MiUser } from '@/models/_.js'; +const waitForPushToTlOptions = { timeout: 3000, interval: 25 }; + describe('Endpoints', () => { let alice: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse; @@ -1149,12 +1151,14 @@ describe('Endpoints', () => { visibility: 'followers', }); - const res = await api('notes/timeline', {}, dave); + await vi.waitFor(async () => { + const res = await api('notes/timeline', {}, dave); - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.length, 1); - assert.strictEqual(res.body[0].id, carolPost.id); + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 1); + assert.strictEqual(res.body[0].id, carolPost.id); + }, waitForPushToTlOptions); }); }); diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts index f5cc875e7c..c579c52a2b 100644 --- a/packages/backend/test/e2e/mute.ts +++ b/packages/backend/test/e2e/mute.ts @@ -6,10 +6,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { beforeAll, describe, test } from 'vitest'; +import { beforeAll, describe, test, vi } from 'vitest'; import { api, post, react, signup, waitFire } from '../utils.js'; import type * as misskey from 'misskey-js'; +const waitForPushToTlOptions = { timeout: 3000, interval: 25 }; + describe('Mute', () => { // alice mutes carol let alice: misskey.entities.SignupResponse; @@ -67,13 +69,15 @@ describe('Mute', () => { const bobNote = await post(bob, { text: 'hi' }); const carolNote = await post(carol, { text: 'hi' }); - const res = await api('notes/local-timeline', {}, alice); + await vi.waitFor(async () => { + const res = await api('notes/local-timeline', {}, alice); - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }, waitForPushToTlOptions); }); test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => { @@ -83,13 +87,15 @@ describe('Mute', () => { renoteId: carolNote.id, }); - const res = await api('notes/local-timeline', {}, alice); + await vi.waitFor(async () => { + const res = await api('notes/local-timeline', {}, alice); - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }, waitForPushToTlOptions); }); }); diff --git a/packages/backend/test/e2e/renote-mute.ts b/packages/backend/test/e2e/renote-mute.ts index 785c9dff8b..555d7c2a20 100644 --- a/packages/backend/test/e2e/renote-mute.ts +++ b/packages/backend/test/e2e/renote-mute.ts @@ -6,11 +6,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { beforeAll, describe, test } from 'vitest'; -import { setTimeout } from 'node:timers/promises'; +import { beforeAll, describe, test, vi } from 'vitest'; import { api, post, signup, waitFire } from '../utils.js'; import type * as misskey from 'misskey-js'; +const waitForPushToTlOptions = { timeout: 3000, interval: 25 }; + describe('Renote Mute', () => { // alice mutes carol let alice: misskey.entities.SignupResponse; @@ -36,16 +37,15 @@ describe('Renote Mute', () => { const carolRenote = await post(carol, { renoteId: bobNote.id }); const carolNote = await post(carol, { text: 'hi' }); - // redisに追加されるのを待つ - await setTimeout(100); + await vi.waitFor(async () => { + const res = await api('notes/local-timeline', {}, alice); - const res = await api('notes/local-timeline', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolRenote.id), false); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolRenote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + }, waitForPushToTlOptions); }); test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => { @@ -53,16 +53,15 @@ describe('Renote Mute', () => { const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' }); const carolNote = await post(carol, { text: 'hi' }); - // redisに追加されるのを待つ - await setTimeout(100); + await vi.waitFor(async () => { + const res = await api('notes/local-timeline', {}, alice); - const res = await api('notes/local-timeline', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolRenote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolRenote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + }, waitForPushToTlOptions); }); // #12956 @@ -70,15 +69,14 @@ describe('Renote Mute', () => { const carolNote = await post(carol, { text: 'hi' }); const bobRenote = await post(bob, { renoteId: carolNote.id }); - // redisに追加されるのを待つ - await setTimeout(100); + await vi.waitFor(async () => { + const res = await api('notes/local-timeline', {}, alice); - const res = await api('notes/local-timeline', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); - assert.strictEqual(res.body.some(note => note.id === bobRenote.id), true); + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobRenote.id), true); + }, waitForPushToTlOptions); }); test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => { diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index a051be6c3c..ec2e7592fa 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -510,7 +510,7 @@ describe('Streaming', () => { test('withReplies: true のとき自分のfollowers投稿に対するリプライが流れる', async () => { const erinNote = await post(erin, { text: 'hi', visibility: 'followers' }); const fired = await waitFire( - erin, 'homeTimeline', // erin:home + erin, 'hybridTimeline', // erin:Hybrid () => api('notes/create', { text: 'hello', replyId: erinNote.id }, ayano), // ayano reply to erin's followers post msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano ); @@ -521,7 +521,7 @@ describe('Streaming', () => { test('withReplies: false でも自分の投稿に対するリプライが流れる', async () => { const ayanoNote = await post(ayano, { text: 'hi', visibility: 'followers' }); const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home + ayano, 'hybridTimeline', // ayano:Hybrid () => api('notes/create', { text: 'hello', replyId: ayanoNote.id }, erin), // erin reply to ayano's followers post msg => msg.type === 'note' && msg.body.userId === erin.id, // wait erin ); @@ -530,9 +530,12 @@ describe('Streaming', () => { }); test('withReplies: true のフォローしていない人のfollowersノートに対するリプライが流れない', async () => { + // ayano は kyoko をフォローしているため kyoko の followers 投稿にリプライできるが、 + // erin は kyoko をフォローしていないため、そのリプライは erin の Hybrid Timeline には流れないはず + const kyokoFollowersNote = await post(kyoko, { text: 'hi', visibility: 'followers' }); const fired = await waitFire( - erin, 'homeTimeline', // erin:home - () => api('notes/create', { text: 'hello', replyId: chitose.id }, ayano), // ayano reply to chitose's post + erin, 'hybridTimeline', // erin:Hybrid + () => api('notes/create', { text: 'hello', replyId: kyokoFollowersNote.id }, ayano), // ayano reply to kyoko's followers post msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano ); @@ -680,7 +683,7 @@ describe('Streaming', () => { const fired = await waitFire( chitose, 'userList', () => api('notes/create', { text: 'foo' }, takumi), - msg => msg.type === 'note' && msg.body.userId === kyoko.id, + msg => msg.type === 'note' && msg.body.userId === takumi.id, { listId: list.id }, ); diff --git a/packages/backend/test/e2e/user-notes.ts b/packages/backend/test/e2e/user-notes.ts index 2f89ac54ce..c6fabca05f 100644 --- a/packages/backend/test/e2e/user-notes.ts +++ b/packages/backend/test/e2e/user-notes.ts @@ -6,10 +6,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { beforeAll, describe, test } from 'vitest'; +import { beforeAll, describe, test, vi } from 'vitest'; import { api, post, signup, uploadUrl } from '../utils.js'; import type * as misskey from 'misskey-js'; +const waitForPushToTlOptions = { timeout: 3000, interval: 25 }; + describe('users/notes', () => { let alice: misskey.entities.SignupResponse; let jpgNote: misskey.entities.Note; @@ -32,16 +34,18 @@ describe('users/notes', () => { }, 1000 * 60 * 2); test('withFiles', async () => { - const res = await api('users/notes', { - userId: alice.id, - withFiles: true, - }, alice); + await vi.waitFor(async () => { + const res = await api('users/notes', { + userId: alice.id, + withFiles: true, + }, alice); - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.length, 3); - assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === pngNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true); + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 3); + assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === pngNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true); + }, waitForPushToTlOptions); }); }); diff --git a/packages/backend/test/unit/SentryTelemetryAdapter.ts b/packages/backend/test/unit/SentryTelemetryAdapter.ts new file mode 100644 index 0000000000..4e66fc08e7 --- /dev/null +++ b/packages/backend/test/unit/SentryTelemetryAdapter.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; +import { buildSentryIntegrations } from '@/core/telemetry/adapters/SentryTelemetryAdapter.js'; + +type TestIntegration = Parameters>[0][number]; + +function testIntegration(name: string): TestIntegration { + return { name }; +} + +describe('SentryTelemetryAdapter', () => { + test('removes disabled integrations from Sentry defaults', () => { + const integrations = buildSentryIntegrations({ + disabledIntegrations: ['Postgres'], + enableNodeProfiling: false, + }); + + const result = integrations([ + testIntegration('Http'), + testIntegration('Postgres'), + testIntegration('Redis'), + ]); + + expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'Redis']); + }); + + test('keeps profiling integration when enabled', () => { + const integrations = buildSentryIntegrations({ + disabledIntegrations: [], + enableNodeProfiling: true, + nodeProfilingIntegration: () => testIntegration('ProfilingIntegration'), + }); + + const result = integrations([ + testIntegration('Http'), + ]); + + expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'ProfilingIntegration']); + }); + + test('warns about unknown disabled integration names without removing defaults', () => { + const warn = vi.fn(); + const integrations = buildSentryIntegrations({ + disabledIntegrations: ['Unknown'], + enableNodeProfiling: false, + warn, + }); + + const result = integrations([ + testIntegration('Http'), + ]); + + expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http']); + expect(warn).toHaveBeenCalledWith('Unknown Sentry integration configured in sentryForBackend.disabledIntegrations: Unknown'); + }); +}); diff --git a/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts b/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts new file mode 100644 index 0000000000..263f30520f --- /dev/null +++ b/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { describe, expect, test } from 'vitest'; +import { buildSentryNodeOptions } from '../../../../../src/core/telemetry/adapters/SentryTelemetryAdapter.js'; + +describe('buildSentryNodeOptions', () => { + test('disables outbound trace propagation by default', () => { + const options = buildSentryNodeOptions({ + enableNodeProfiling: false, + options: {}, + }); + + expect(options.tracePropagationTargets).toEqual([]); + }); + + test('allows explicit tracePropagationTargets to override the default', () => { + const options = buildSentryNodeOptions({ + enableNodeProfiling: false, + options: { + tracePropagationTargets: ['^https://internal\\.example/'], + }, + }); + + expect(options.tracePropagationTargets).toEqual(['^https://internal\\.example/']); + }); +}); diff --git a/packages/frontend-builder/package.json b/packages/frontend-builder/package.json index ceb33a230d..ede45c1fcf 100644 --- a/packages/frontend-builder/package.json +++ b/packages/frontend-builder/package.json @@ -11,16 +11,16 @@ }, "devDependencies": { "@types/estree": "1.0.9", - "@types/node": "26.0.0", - "@typescript-eslint/eslint-plugin": "8.61.1", - "@typescript-eslint/parser": "8.61.1", + "@types/node": "26.0.1", + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", "rollup": "4.62.2" }, "dependencies": { "i18n": "workspace:*", "magic-string": "0.30.21", "oxc-walker": "1.0.0", - "rolldown": "1.1.2", + "rolldown": "1.1.3", "vite": "8.1.0" } } diff --git a/packages/frontend-builder/utils.ts b/packages/frontend-builder/utils.ts index f85ae7ea0c..3bb528e25f 100644 --- a/packages/frontend-builder/utils.ts +++ b/packages/frontend-builder/utils.ts @@ -8,5 +8,5 @@ export function assertNever(x: never): never { throw new Error(`Unexpected type: ${(x as any)?.type ?? x}`); } -export function assertType(_node: unknown): asserts node is T { +export function assertType(_node: unknown): asserts _node is T { } diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index 1026a36144..d11a22baf0 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -10,9 +10,6 @@ "lint": "pnpm typecheck && pnpm eslint" }, "dependencies": { - "@rollup/plugin-json": "6.1.0", - "@rollup/pluginutils": "5.4.0", - "@vitejs/plugin-vue": "6.0.7", "buraha": "0.0.1", "frontend-shared": "workspace:*", "i18n": "workspace:*", @@ -21,42 +18,34 @@ "mfm-js": "0.26.0", "misskey-js": "workspace:*", "punycode.js": "2.3.1", - "rollup": "4.62.2", - "shiki": "4.2.0", + "shiki": "4.3.0", "tinycolor2": "1.6.0", - "uuid": "14.0.0", - "vue": "3.5.38" + "uuid": "14.0.1", + "vue": "3.5.39" }, "devDependencies": { "@misskey-dev/emoji-assets": "17.0.3", "@misskey-dev/summaly": "5.5.1", + "@rollup/plugin-json": "6.1.0", + "@rollup/pluginutils": "5.4.0", "@tabler/icons-webfont": "3.35.0", "@testing-library/vue": "8.1.0", - "@types/estree": "1.0.9", - "@types/micromatch": "4.0.10", - "@types/node": "26.0.0", + "@types/node": "26.0.1", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/tinycolor2": "1.4.6", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.61.1", - "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", "@vitest/coverage-v8": "4.1.9", - "@vue/runtime-core": "3.5.38", - "acorn": "8.17.0", - "cross-env": "10.1.0", + "@vitejs/plugin-vue": "6.0.7", + "@vue/runtime-core": "3.5.39", "eslint-plugin-import": "2.32.0", "eslint-plugin-vue": "10.9.2", - "happy-dom": "20.10.6", "intersection-observer": "0.12.2", "lightningcss": "1.32.0", - "micromatch": "4.0.8", - "msw": "2.14.6", - "prettier": "3.8.4", "sass-embedded": "1.100.0", - "start-server-and-test": "3.0.11", "tsx": "4.22.4", "vite": "8.1.0", - "vite-plugin-turbosnap": "1.0.3", "vue-component-type-helpers": "3.3.5", "vue-eslint-parser": "10.4.1", "vue-tsc": "3.3.5" diff --git a/packages/frontend-embed/vite.config.ts b/packages/frontend-embed/vite.config.ts index ff00a7d385..81e5d3a2c6 100644 --- a/packages/frontend-embed/vite.config.ts +++ b/packages/frontend-embed/vite.config.ts @@ -1,7 +1,7 @@ import path from 'path'; import pluginVue from '@vitejs/plugin-vue'; import { defineConfig, type UserConfig } from 'vite'; -import * as yaml from 'js-yaml'; +import { load as loadYaml } from 'js-yaml'; import { promises as fsp } from 'fs'; import locales from 'i18n'; @@ -11,7 +11,7 @@ import pluginJson5 from './lib/vite-plugin-json5.js'; import { pluginRemoveUnrefI18n } from '../frontend-builder/rollup-plugin-remove-unref-i18n'; import { Features } from 'lightningcss'; -const url = process.env.NODE_ENV === 'development' ? (yaml.load(await fsp.readFile('../../.config/default.yml', 'utf-8')) as any).url : null; +const url = process.env.NODE_ENV === 'development' ? (loadYaml(await fsp.readFile('../../.config/default.yml', 'utf-8')) as any).url : null; const host = url ? (new URL(url)).hostname : undefined; const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue']; diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json index d35e380588..8c14cc2c1d 100644 --- a/packages/frontend-shared/package.json +++ b/packages/frontend-shared/package.json @@ -8,10 +8,10 @@ "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { - "@types/node": "26.0.0", + "@types/node": "26.0.1", "@types/tinycolor2": "1.4.6", - "@typescript-eslint/eslint-plugin": "8.61.1", - "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", "eslint-plugin-vue": "10.9.2", "vue-eslint-parser": "10.4.1" }, @@ -23,8 +23,8 @@ "i18n": "workspace:*", "json5": "2.2.3", "misskey-js": "workspace:*", - "shiki": "4.2.0", + "shiki": "4.3.0", "tinycolor2": "1.6.0", - "vue": "3.5.38" + "vue": "3.5.39" } } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 32ced9eaf2..67a7062285 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -20,7 +20,7 @@ "@mcaptcha/core-glue": "0.1.0-alpha-5", "@misskey-dev/browser-image-resizer": "2024.1.0", "@misskey-dev/emoji-data": "17.0.3", - "@sentry/vue": "10.59.0", + "@sentry/vue": "10.62.0", "@simplewebauthn/browser": "13.3.0", "@syuilo/aiscript": "1.2.1", "@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0", @@ -35,12 +35,10 @@ "chartjs-chart-matrix": "3.0.4", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.2.0", - "chromatic": "17.5.0", "compare-versions": "6.1.1", "cropperjs": "2.1.1", "date-fns": "4.4.0", "eventemitter3": "5.0.4", - "execa": "9.6.1", "exifreader": "4.41.0", "frontend-shared": "workspace:*", "i18n": "workspace:*", @@ -61,13 +59,13 @@ "qr-code-styling": "1.9.2", "qr-scanner": "1.4.2", "sanitize-html": "2.17.5", - "shiki": "4.2.0", + "seedrandom": "3.0.5", + "shiki": "4.3.0", "textarea-caret": "3.1.0", - "three": "0.184.0", "throttle-debounce": "5.0.2", "tinycolor2": "1.6.0", "v-code-diff": "1.13.1", - "vue": "3.5.38", + "vue": "3.5.39", "wanakana": "5.3.1" }, "devDependencies": { @@ -97,20 +95,22 @@ "@types/insert-text-at-cursor": "0.3.2", "@types/matter-js": "0.20.2", "@types/micromatch": "4.0.10", - "@types/node": "26.0.0", + "@types/node": "26.0.1", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/sanitize-html": "2.16.1", "@types/seedrandom": "3.0.8", "@types/textarea-caret": "3.0.4", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", - "@typescript-eslint/eslint-plugin": "8.61.1", - "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", "@vitest/coverage-v8": "4.1.9", - "@vue/compiler-core": "3.5.38", + "@vue/compiler-core": "3.5.39", "astring": "1.9.0", + "chromatic": "17.7.2", "eslint-plugin-import": "2.32.0", "eslint-plugin-vue": "10.9.2", + "execa": "9.6.1", "happy-dom": "20.10.6", "intersection-observer": "0.12.2", "lightningcss": "1.32.0", @@ -120,13 +120,12 @@ "msw-storybook-addon": "2.0.7", "nodemon": "3.1.14", "oxc-walker": "1.0.0", - "prettier": "3.8.4", + "prettier": "3.9.1", "react": "19.2.7", "react-dom": "19.2.7", - "rolldown": "1.1.2", + "rolldown": "1.1.3", "rollup-plugin-visualizer": "7.0.1", "sass-embedded": "1.100.0", - "seedrandom": "3.0.5", "storybook": "10.4.6", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "tsx": "4.22.4", diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index fa60ec4b58..4c5e601dae 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -5,7 +5,7 @@ import { watch, version as vueVersion } from 'vue'; import { compareVersions } from 'compare-versions'; -import { version, lang, apiUrl, isSafeMode } from '@@/js/config.js'; +import { version, lang, isSafeMode } from '@@/js/config.js'; import defaultLightTheme from '@@/themes/l-light.json5'; import defaultDarkTheme from '@@/themes/d-green-lime.json5'; import { storeBootloaderErrors } from '@@/js/store-boot-errors'; @@ -30,6 +30,7 @@ import { fetchCustomEmojis } from '@/custom-emojis.js'; import { prefer } from '@/preferences.js'; import { $i } from '@/i.js'; import { launchPlugins } from '@/plugin.js'; +import { initTelemetry } from '@/telemetry.js'; export async function common(createVue: () => Promise>) { console.info(`Misskey v${version}`); @@ -286,40 +287,7 @@ export async function common(createVue: () => Promise>) { return root; })(); - if (instance.sentryForFrontend) { - const Sentry = await import('@sentry/vue'); - Sentry.init({ - app, - integrations: [ - ...(instance.sentryForFrontend.vueIntegration !== undefined ? [ - Sentry.vueIntegration(instance.sentryForFrontend.vueIntegration ?? undefined), - ] : []), - ...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? [ - Sentry.browserTracingIntegration(instance.sentryForFrontend.browserTracingIntegration ?? undefined), - ] : []), - ...(instance.sentryForFrontend.replayIntegration !== undefined ? [ - Sentry.replayIntegration(instance.sentryForFrontend.replayIntegration ?? undefined), - ] : []), - ], - - // Set tracesSampleRate to 1.0 to capture 100% - tracesSampleRate: 1.0, - - // Set `tracePropagationTargets` to control for which URLs distributed tracing should be enabled - ...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? { - tracePropagationTargets: [apiUrl], - } : {}), - - // Capture Replay for 10% of all sessions, - // plus for 100% of sessions with an error - ...(instance.sentryForFrontend.replayIntegration !== undefined ? { - replaysSessionSampleRate: 0.1, - replaysOnErrorSampleRate: 1.0, - } : {}), - - ...instance.sentryForFrontend.options, - }); - } + await initTelemetry(instance, app); try { await launchPlugins(); diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue index f2cf33eb65..016257ba9f 100644 --- a/packages/frontend/src/components/MkMention.vue +++ b/packages/frontend/src/components/MkMention.vue @@ -33,7 +33,7 @@ const canonical = props.host === localHost ? `@${props.username}` : `@${props.us const url = `/${canonical}`; const isMe = $i && ( - `@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() + `@${props.username}@${toUnicode(props.host)}`.toLowerCase() === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() ); const avatarUrl = computed(() => prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index e55a496bfe..4017924fa4 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -192,25 +192,21 @@ SPDX-License-Identifier: AGPL-3.0-only MkDateSeparatedList uses TransitionGroup which requires single element in the child elements so MkNote create empty div instead of no elements --> - +