diff --git a/.config/cypress-devcontainer.yml b/.config/cypress-devcontainer.yml index 3907615f73..e75e32a17a 100644 --- a/.config/cypress-devcontainer.yml +++ b/.config/cypress-devcontainer.yml @@ -220,5 +220,10 @@ allowedPrivateNetworks: [ '127.0.0.1/32' ] +# Disable automatic redirect for ActivityPub object lookup. (default: false) +# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation. +# However it will make it impossible for other instances to lookup third-party user and notes through your URL. +#disallowExternalApRedirect: true + # Upload or download file size limits (bytes) #maxFileSize: 262144000 diff --git a/.config/docker_example.yml b/.config/docker_example.yml index ad9ae4fd9a..1ffed00cc7 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -235,6 +235,11 @@ signToActivityPubGet: true # '127.0.0.1/32' #] +# Disable automatic redirect for ActivityPub object lookup. (default: false) +# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation. +# However it will make it impossible for other instances to lookup third-party user and notes through your URL. +#disallowExternalApRedirect: true + # Upload or download file size limits (bytes) #maxFileSize: 262144000 diff --git a/.config/example.yml b/.config/example.yml index 349c2e9730..71427c84bc 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -334,6 +334,11 @@ signToActivityPubGet: true # '127.0.0.1/32' #] +# Disable automatic redirect for ActivityPub object lookup. (default: false) +# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation. +# However it will make it impossible for other instances to lookup third-party user and notes through your URL. +#disallowExternalApRedirect: true + # Upload or download file size limits (bytes) #maxFileSize: 262144000 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0f9d25ff35..a34ac38d74 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -79,7 +79,7 @@ jobs: - run: corepack enable - run: pnpm i --frozen-lockfile - name: Restore eslint cache - uses: actions/cache@v4.2.0 + uses: actions/cache@v4.2.1 with: path: ${{ env.eslint-cache-path }} key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }} diff --git a/CHANGELOG.md b/CHANGELOG.md index b058a742d5..fb9a6bce2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ -## Unreleased +## 2025.2.1 ### General - Feat: アクセストークン発行時に通知するように +- Feat: 実験的なGoogleAnalyticsサポートを追加 - 依存関係の更新 ### Client @@ -13,17 +14,22 @@ - Enhance: ノートに埋め込まれたメディアのコンテキストメニューから管理者用のファイル管理画面を開けるように ( #15440 ) - Enhance: リアクションする際に確認ダイアログを表示できるように - Enhance: Blueskyの投稿埋め込みプレビューに対応 +- Enhance: CWの注釈で入力済みの文字数を表示 - Fix: コンディショナルロールを手動で割り当てできる導線を削除 `#13529` - Fix: 埋め込みプレイヤーから外部ページに移動できない問題を修正 - Fix: Play の再読込時に UI が以前の状態を引き継いでしまう問題を修正 `#14378` - Fix: カスタム絵文字管理画面(beta)にてisSensitive/localOnlyの絞り込みが上手くいかない問題の修正 ( #15445 ) +- Fix: CWの注釈が100文字を超えている場合、ノート投稿ボタンを非アクティブに ### Server +- Enhance: 成り済まし対策として、ActivityPub照会された時にリモートのリダイレクトを拒否できるように (config.disallowExternalApRedirect) - Fix: `following/invalidate`でフォロワーを解除しようとしているユーザーの情報を返すように - Fix: オブジェクトストレージの設定でPrefixを設定していなかった場合nullまたは空文字になる問題を修正 - Fix: pgroongaでの検索時にはじめのキーワードのみが検索に使用される問題を修正 (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/886) - +- Fix: メールアドレスの形式が正しくなければ以降の処理を行わないように +- Fix: クリップの説明欄を更新する際に空にできない問題を修正 +- Fix: フォロワーではないユーザーにリノートもしくは返信された場合にノートのDeleteアクティビティが送られていない問題を修正 ## 2025.2.0 diff --git a/locales/index.d.ts b/locales/index.d.ts index 1b1f389359..7e9cf5e55b 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -10896,13 +10896,7 @@ export interface Locale extends ILocale { */ "title": string; /** - * このサーバーと通信することはできましたが、得られたデータが不正なものでした。 - */ - "description": string; - }; - "_responseInvalidIdHostNotMatch": { - /** - * 入力されたURIのドメインと最終的に得られたURIのドメインとが異なります。第三者のサーバーを介してリモートのコンテンツを照会している場合は、発信元のサーバーで取得できるURIを使用して照会し直してください。 + * このサーバーと通信することはできましたが、得られたデータが不正なものでした。第三者のサーバーを介してリモートのコンテンツを照会している場合は、発信元のサーバーで取得できるURIを使用して照会し直してください。 */ "description": string; }; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 7392b7aa20..ddbad3dab1 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2911,9 +2911,7 @@ _remoteLookupErrors: description: "このサーバーとの通信に失敗しました。相手サーバーがダウンしている可能性があります。また、不正なURIや存在しないURIを入力していないか確認してください。" _responseInvalid: title: "レスポンスが不正です" - description: "このサーバーと通信することはできましたが、得られたデータが不正なものでした。" - _responseInvalidIdHostNotMatch: - description: "入力されたURIのドメインと最終的に得られたURIのドメインとが異なります。第三者のサーバーを介してリモートのコンテンツを照会している場合は、発信元のサーバーで取得できるURIを使用して照会し直してください。" + description: "このサーバーと通信することはできましたが、得られたデータが不正なものでした。第三者のサーバーを介してリモートのコンテンツを照会している場合は、発信元のサーバーで取得できるURIを使用して照会し直してください。" _noSuchObject: title: "見つかりません" description: "要求されたリソースは見つかりませんでした。URIをもう一度お確かめください。" diff --git a/package.json b/package.json index e84b2dcf02..2d2a906f78 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2025.2.0", + "version": "2025.2.1-beta.1", "codename": "nasubi", "repository": { "type": "git", 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 56cb906822..cee5c7205b 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -103,7 +103,7 @@ "bcryptjs": "2.4.3", "blurhash": "2.0.5", "body-parser": "1.20.3", - "bullmq": "5.41.0", + "bullmq": "5.41.1", "cacheable-lookup": "7.0.0", "cbor": "9.0.2", "chalk": "5.4.1", diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index d5fd2ba558..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; @@ -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; @@ -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/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/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index 083153940a..8085bbf961 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'; @@ -215,7 +215,7 @@ export class HttpRequestService { } @bindThis - public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise { + public async getActivityJson(url: string, isLocalAddressAllowed = false, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict): Promise { const res = await this.send(url, { method: 'GET', headers: { @@ -232,7 +232,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/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'; @@ -189,13 +189,27 @@ export class NoteDeleteService { }) as MiRemoteUser[]; } + @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/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/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 { + 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 { + public async signedGet(url: string, user: { id: MiUser['id'] }, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict, followAlternate?: boolean): Promise { 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; @@ -72,7 +73,7 @@ export class Resolver { } @bindThis - public async resolve(value: string | IObject): Promise { + public async resolve(value: string | IObject, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict): Promise { 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); - - const idOk = activity.id !== undefined && hosts.includes(new URL(activity.id).host); - - // 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); - - if (!idOk && !urlOk) { - throw new Error(`bad Activity: neither id(${activity?.id}) nor url(${activity?.url}) match location(${urls})`); - } +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; + } + + if (`.${candidateHost}`.endsWith(`.${requestHost}`)) { + return FetchAllowSoftFailMask.MisalignedOrigin; + } + + return FetchAllowSoftFailMask.CrossOrigin; +} + +// 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/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/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/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/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/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 912c8defbe..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, @@ -572,6 +576,7 @@ export default class extends Endpoint { // 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..1cfa9cffa6 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' }, @@ -371,6 +372,12 @@ export default class extends Endpoint { // 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 { // 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 { // 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 { // 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 { // 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/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 + span.button-label-big(data-i18n-reload) Reload - p.dont-worry Don't worry, it's (probably) not your fault. - - 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/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/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/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/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(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'); + }); }); diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index 7ce2f1b14f..4768356865 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -25,16 +25,16 @@ "misskey-js": "workspace:*", "frontend-shared": "workspace:*", "punycode.js": "2.3.1", - "rollup": "4.34.7", + "rollup": "4.34.8", "sass": "1.85.0", - "shiki": "2.3.2", + "shiki": "3.0.0", "tinycolor2": "1.6.0", "tsc-alias": "1.8.10", "tsconfig-paths": "4.2.0", "typescript": "5.7.3", - "uuid": "11.0.5", + "uuid": "11.1.0", "json5": "2.2.3", - "vite": "6.1.0", + "vite": "6.1.1", "vue": "3.5.13" }, "devDependencies": { @@ -42,29 +42,29 @@ "@testing-library/vue": "8.1.0", "@types/estree": "1.0.6", "@types/micromatch": "4.0.9", - "@types/node": "22.13.4", + "@types/node": "22.13.5", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/tinycolor2": "1.4.6", "@types/ws": "8.5.14", - "@typescript-eslint/eslint-plugin": "8.24.0", - "@typescript-eslint/parser": "8.24.0", - "@vitest/coverage-v8": "3.0.5", + "@typescript-eslint/eslint-plugin": "8.24.1", + "@typescript-eslint/parser": "8.24.1", + "@vitest/coverage-v8": "3.0.6", "@vue/runtime-core": "3.5.13", "acorn": "8.14.0", "cross-env": "7.0.3", "eslint-plugin-import": "2.31.0", "eslint-plugin-vue": "9.32.0", "fast-glob": "3.3.3", - "happy-dom": "17.1.0", + "happy-dom": "17.1.4", "intersection-observer": "0.12.2", "micromatch": "4.0.8", - "msw": "2.7.0", + "msw": "2.7.1", "nodemon": "3.1.9", - "prettier": "3.5.1", + "prettier": "3.5.2", "start-server-and-test": "2.0.10", "vite-plugin-turbosnap": "1.0.3", - "vue-component-type-helpers": "2.2.0", + "vue-component-type-helpers": "2.2.4", "vue-eslint-parser": "9.4.3", - "vue-tsc": "2.2.0" + "vue-tsc": "2.2.4" } } diff --git a/packages/frontend-shared/eslint.config.js b/packages/frontend-shared/eslint.config.js index 63323aeddc..ac5c67d0b6 100644 --- a/packages/frontend-shared/eslint.config.js +++ b/packages/frontend-shared/eslint.config.js @@ -103,6 +103,7 @@ export default [ // TODO: Error while loading rule '@typescript-eslint/naming-convention': Cannot use 'in' operator to search for 'type' in undefined のため一時的に無効化 // See https://github.com/misskey-dev/misskey/pull/15311 'js/i18n.ts', + 'js-built/', ], }, ]; diff --git a/packages/frontend-shared/js/scroll.ts b/packages/frontend-shared/js/scroll.ts index 4f2e9105c3..508864b12c 100644 --- a/packages/frontend-shared/js/scroll.ts +++ b/packages/frontend-shared/js/scroll.ts @@ -134,7 +134,6 @@ export function scrollToBottom( export function isTopVisible(el: HTMLElement, tolerance = 1): boolean { const scrollTop = getScrollPosition(el); - if (_DEV_) console.log(scrollTop, tolerance, scrollTop <= tolerance); return scrollTop <= tolerance; } diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json index b9ed5e00cc..ca7bee467b 100644 --- a/packages/frontend-shared/package.json +++ b/packages/frontend-shared/package.json @@ -21,9 +21,9 @@ "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { - "@types/node": "22.13.4", - "@typescript-eslint/eslint-plugin": "8.24.0", - "@typescript-eslint/parser": "8.24.0", + "@types/node": "22.13.5", + "@typescript-eslint/eslint-plugin": "8.24.1", + "@typescript-eslint/parser": "8.24.1", "esbuild": "0.25.0", "eslint-plugin-vue": "9.32.0", "nodemon": "3.1.9", diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 9ed6aa708f..10fbc58cf0 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -16,6 +16,7 @@ "lint": "pnpm typecheck && pnpm eslint" }, "dependencies": { + "@analytics/google-analytics": "1.1.0", "@discordapp/twemoji": "15.1.0", "@github/webauthn-json": "2.1.1", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", @@ -29,11 +30,12 @@ "@vitejs/plugin-vue": "5.2.1", "@vue/compiler-sfc": "3.5.13", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15", + "analytics": "0.8.16", "astring": "1.9.0", "broadcast-channel": "7.0.0", "buraha": "0.0.1", "canvas-confetti": "1.9.3", - "chart.js": "4.4.7", + "chart.js": "4.4.8", "chartjs-adapter-date-fns": "3.0.0", "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", @@ -56,10 +58,10 @@ "misskey-reversi": "workspace:*", "photoswipe": "5.4.4", "punycode.js": "2.3.1", - "rollup": "4.34.7", + "rollup": "4.34.8", "sanitize-html": "2.14.0", "sass": "1.85.0", - "shiki": "2.3.2", + "shiki": "3.0.0", "strict-event-emitter-types": "2.0.0", "textarea-caret": "3.1.0", "three": "0.173.0", @@ -68,47 +70,47 @@ "tsc-alias": "1.8.10", "tsconfig-paths": "4.2.0", "typescript": "5.7.3", - "uuid": "11.0.5", + "uuid": "11.1.0", "v-code-diff": "1.13.1", - "vite": "6.1.0", + "vite": "6.1.1", "vue": "3.5.13", "vuedraggable": "next" }, "devDependencies": { "@misskey-dev/summaly": "5.2.0", - "@storybook/addon-actions": "8.5.6", - "@storybook/addon-essentials": "8.5.6", - "@storybook/addon-interactions": "8.5.6", - "@storybook/addon-links": "8.5.6", - "@storybook/addon-mdx-gfm": "8.5.6", - "@storybook/addon-storysource": "8.5.6", - "@storybook/blocks": "8.5.6", - "@storybook/components": "8.5.6", - "@storybook/core-events": "8.5.6", - "@storybook/manager-api": "8.5.6", - "@storybook/preview-api": "8.5.6", - "@storybook/react": "8.5.6", - "@storybook/react-vite": "8.5.6", - "@storybook/test": "8.5.6", - "@storybook/theming": "8.5.6", - "@storybook/types": "8.5.6", - "@storybook/vue3": "8.5.6", - "@storybook/vue3-vite": "8.5.6", + "@storybook/addon-actions": "8.5.8", + "@storybook/addon-essentials": "8.5.8", + "@storybook/addon-interactions": "8.5.8", + "@storybook/addon-links": "8.5.8", + "@storybook/addon-mdx-gfm": "8.5.8", + "@storybook/addon-storysource": "8.5.8", + "@storybook/blocks": "8.5.8", + "@storybook/components": "8.5.8", + "@storybook/core-events": "8.5.8", + "@storybook/manager-api": "8.5.8", + "@storybook/preview-api": "8.5.8", + "@storybook/react": "8.5.8", + "@storybook/react-vite": "8.5.8", + "@storybook/test": "8.5.8", + "@storybook/theming": "8.5.8", + "@storybook/types": "8.5.8", + "@storybook/vue3": "8.5.8", + "@storybook/vue3-vite": "8.5.8", "@testing-library/vue": "8.1.0", "@types/canvas-confetti": "1.9.0", "@types/estree": "1.0.6", "@types/matter-js": "0.19.8", "@types/micromatch": "4.0.9", - "@types/node": "22.13.4", + "@types/node": "22.13.5", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/sanitize-html": "2.13.0", "@types/seedrandom": "3.0.8", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", "@types/ws": "8.5.14", - "@typescript-eslint/eslint-plugin": "8.24.0", - "@typescript-eslint/parser": "8.24.0", - "@vitest/coverage-v8": "3.0.5", + "@typescript-eslint/eslint-plugin": "8.24.1", + "@typescript-eslint/parser": "8.24.1", + "@vitest/coverage-v8": "3.0.6", "@vue/runtime-core": "3.5.13", "acorn": "8.14.0", "cross-env": "7.0.3", @@ -116,24 +118,24 @@ "eslint-plugin-import": "2.31.0", "eslint-plugin-vue": "9.32.0", "fast-glob": "3.3.3", - "happy-dom": "17.1.0", + "happy-dom": "17.1.4", "intersection-observer": "0.12.2", "micromatch": "4.0.8", - "msw": "2.7.0", + "msw": "2.7.1", "msw-storybook-addon": "2.0.4", "nodemon": "3.1.9", - "prettier": "3.5.1", + "prettier": "3.5.2", "react": "19.0.0", "react-dom": "19.0.0", "seedrandom": "3.0.5", "start-server-and-test": "2.0.10", - "storybook": "8.5.6", + "storybook": "8.5.8", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "vite-plugin-turbosnap": "1.0.3", - "vitest": "3.0.5", + "vitest": "3.0.6", "vitest-fetch-mock": "0.4.3", - "vue-component-type-helpers": "2.2.0", + "vue-component-type-helpers": "2.2.4", "vue-eslint-parser": "9.4.3", - "vue-tsc": "2.2.0" + "vue-tsc": "2.2.4" } } diff --git a/packages/frontend/src/analytics.ts b/packages/frontend/src/analytics.ts new file mode 100644 index 0000000000..e07a4e9258 --- /dev/null +++ b/packages/frontend/src/analytics.ts @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import type { AnalyticsInstance, AnalyticsPlugin } from 'analytics'; + +/** + * analytics moduleを読み込まなくても動作するようにするためのラッパー + */ +class AnalyticsProxy implements AnalyticsInstance { + private analytics?: AnalyticsInstance; + + constructor(analytics?: AnalyticsInstance) { + if (analytics) { + this.analytics = analytics; + } + } + + public setAnalytics(analytics: AnalyticsInstance) { + if (this.analytics) { + throw new Error('Analytics instance already exists.'); + } + this.analytics = analytics; + } + + public identify(...args: Parameters) { + return this.analytics?.identify(...args) ?? Promise.resolve(); + } + + public track(...args: Parameters) { + return this.analytics?.track(...args) ?? Promise.resolve(); + } + + public page(...args: Parameters) { + return this.analytics?.page(...args) ?? Promise.resolve(); + } + + public user(...args: Parameters) { + return this.analytics?.user(...args) ?? Promise.resolve(); + } + + public reset(...args: Parameters) { + return this.analytics?.reset(...args) ?? Promise.resolve(); + } + + public ready(...args: Parameters) { + return this.analytics?.ready(...args) ?? function () { void 0; }; + } + + public on(...args: Parameters) { + return this.analytics?.on(...args) ?? function () { void 0; }; + } + + public once(...args: Parameters) { + return this.analytics?.once(...args) ?? function () { void 0; }; + } + + public getState(...args: Parameters) { + return this.analytics?.getState(...args) ?? Promise.resolve(); + } + + public get storage() { + return this.analytics?.storage ?? { + getItem: () => null, + setItem: () => void 0, + removeItem: () => void 0, + }; + } + + public get plugins() { + return this.analytics?.plugins ?? { + enable: (p, c) => Promise.resolve(c ? c() : void 0), + disable: (p, c) => Promise.resolve(c ? c() : void 0), + }; + } +} + +export const analytics = new AnalyticsProxy(); + +export async function initAnalytics(instance: Misskey.entities.MetaDetailed) { + // アナリティクスプロバイダに関する設定がひとつもない場合は、アナリティクスモジュールを読み込まない + if (!instance.googleAnalyticsMeasurementId) { + return; + } + + const { default: Analytics } = await import('analytics'); + const plugins: AnalyticsPlugin[] = []; + + // Google Analytics + if (instance.googleAnalyticsMeasurementId) { + const { default: googleAnalytics } = await import('@analytics/google-analytics'); + + plugins.push(googleAnalytics({ + measurementIds: [instance.googleAnalyticsMeasurementId], + debug: _DEV_, + })); + } + + analytics.setAnalytics(Analytics({ + app: 'misskey', + version: _VERSION_, + debug: _DEV_, + plugins, + })); +} diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 1d8e40a12d..d09b98efe0 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -4,9 +4,9 @@ */ import { computed, watch, version as vueVersion } from 'vue'; -import type { App } from 'vue'; import { compareVersions } from 'compare-versions'; import { version, lang, updateLocale, locale } from '@@/js/config.js'; +import type { App } from 'vue'; import widgets from '@/widgets/index.js'; import directives from '@/directives/index.js'; import components from '@/components/index.js'; @@ -21,6 +21,7 @@ import { reloadChannel } from '@/scripts/unison-reload.js'; import { getUrlWithoutLoginId } from '@/scripts/login-id.js'; import { getAccountFromId } from '@/scripts/get-account-from-id.js'; import { deckStore } from '@/ui/deck/deck-store.js'; +import { analytics, initAnalytics } from '@/analytics.js'; import { miLocalStorage } from '@/local-storage.js'; import { fetchCustomEmojis } from '@/custom-emojis.js'; import { setupRouter } from '@/router/main.js'; @@ -241,6 +242,19 @@ export async function common(createVue: () => App) { await fetchCustomEmojis(); } catch (err) { /* empty */ } + // analytics + fetchInstanceMetaPromise.then(async () => { + await initAnalytics(instance); + + if ($i) { + analytics.identify($i.id); + } + + analytics.page({ + path: window.location.pathname, + }); + }); + const app = createVue(); setupRouter(app, createMainRouter); diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 1420b9c26f..e725d2a15d 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -32,6 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue'; import { url } from '@@/js/config.js'; import { getScrollContainer } from '@@/js/scroll.js'; +import type { PageMetadata } from '@/scripts/page-metadata.js'; import RouterView from '@/components/global/RouterView.vue'; import MkWindow from '@/components/MkWindow.vue'; import { popout as _popout } from '@/scripts/popout.js'; @@ -39,11 +40,11 @@ import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { useScrollPositionManager } from '@/nirax.js'; import { i18n } from '@/i18n.js'; import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; -import type { PageMetadata } from '@/scripts/page-metadata.js'; import { openingWindowsCount } from '@/os.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { useRouterFactory } from '@/router/supplier.js'; import { mainRouter } from '@/router/main.js'; +import { analytics } from '@/analytics.js'; const props = defineProps<{ initialPath: string; @@ -99,6 +100,14 @@ windowRouter.addListener('replace', ctx => { history.value.push({ path: ctx.path, key: ctx.key }); }); +windowRouter.addListener('change', ctx => { + console.log('windowRouter: change', ctx.path); + analytics.page({ + path: ctx.path, + title: ctx.path, + }); +}); + windowRouter.init(); provide('router', windowRouter); @@ -160,6 +169,11 @@ function popout() { useScrollPositionManager(() => getScrollContainer(contents.value), windowRouter); onMounted(() => { + analytics.page({ + path: props.initialPath, + title: props.initialPath, + }); + openingWindowsCount.value++; if (openingWindowsCount.value >= 3) { claimAchievement('open3windows'); diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 49ed4197de..ad0a332f99 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -65,7 +65,10 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.notSpecifiedMentionWarning }} - - +
+ +
{{ maxCwTextLength - cwTextLength }}
+