Merge branch 'develop' into feat-14931

This commit is contained in:
syuilo 2025-02-26 10:35:29 +09:00 committed by GitHub
commit 030b1e7865
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 3205 additions and 2106 deletions

View File

@ -220,5 +220,10 @@ allowedPrivateNetworks: [
'127.0.0.1/32' '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) # Upload or download file size limits (bytes)
#maxFileSize: 262144000 #maxFileSize: 262144000

View File

@ -235,6 +235,11 @@ signToActivityPubGet: true
# '127.0.0.1/32' # '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) # Upload or download file size limits (bytes)
#maxFileSize: 262144000 #maxFileSize: 262144000

View File

@ -334,6 +334,11 @@ signToActivityPubGet: true
# '127.0.0.1/32' # '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) # Upload or download file size limits (bytes)
#maxFileSize: 262144000 #maxFileSize: 262144000

View File

@ -79,7 +79,7 @@ jobs:
- run: corepack enable - run: corepack enable
- run: pnpm i --frozen-lockfile - run: pnpm i --frozen-lockfile
- name: Restore eslint cache - name: Restore eslint cache
uses: actions/cache@v4.2.0 uses: actions/cache@v4.2.1
with: with:
path: ${{ env.eslint-cache-path }} path: ${{ env.eslint-cache-path }}
key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }} key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }}

View File

@ -1,7 +1,8 @@
## Unreleased ## 2025.2.1
### General ### General
- Feat: アクセストークン発行時に通知するように - Feat: アクセストークン発行時に通知するように
- Feat: 実験的なGoogleAnalyticsサポートを追加
- 依存関係の更新 - 依存関係の更新
### Client ### Client
@ -13,17 +14,22 @@
- Enhance: ノートに埋め込まれたメディアのコンテキストメニューから管理者用のファイル管理画面を開けるように ( #15440 ) - Enhance: ノートに埋め込まれたメディアのコンテキストメニューから管理者用のファイル管理画面を開けるように ( #15440 )
- Enhance: リアクションする際に確認ダイアログを表示できるように - Enhance: リアクションする際に確認ダイアログを表示できるように
- Enhance: Blueskyの投稿埋め込みプレビューに対応 - Enhance: Blueskyの投稿埋め込みプレビューに対応
- Enhance: CWの注釈で入力済みの文字数を表示
- Fix: コンディショナルロールを手動で割り当てできる導線を削除 `#13529` - Fix: コンディショナルロールを手動で割り当てできる導線を削除 `#13529`
- Fix: 埋め込みプレイヤーから外部ページに移動できない問題を修正 - Fix: 埋め込みプレイヤーから外部ページに移動できない問題を修正
- Fix: Play の再読込時に UI が以前の状態を引き継いでしまう問題を修正 `#14378` - Fix: Play の再読込時に UI が以前の状態を引き継いでしまう問題を修正 `#14378`
- Fix: カスタム絵文字管理画面(beta)にてisSensitive/localOnlyの絞り込みが上手くいかない問題の修正 ( #15445 ) - Fix: カスタム絵文字管理画面(beta)にてisSensitive/localOnlyの絞り込みが上手くいかない問題の修正 ( #15445 )
- Fix: CWの注釈が100文字を超えている場合、ート投稿ボタンを非アクティブに
### Server ### Server
- Enhance: 成り済まし対策として、ActivityPub照会された時にリモートのリダイレクトを拒否できるように (config.disallowExternalApRedirect)
- Fix: `following/invalidate`でフォロワーを解除しようとしているユーザーの情報を返すように - Fix: `following/invalidate`でフォロワーを解除しようとしているユーザーの情報を返すように
- Fix: オブジェクトストレージの設定でPrefixを設定していなかった場合nullまたは空文字になる問題を修正 - Fix: オブジェクトストレージの設定でPrefixを設定していなかった場合nullまたは空文字になる問題を修正
- Fix: pgroongaでの検索時にはじめのキーワードのみが検索に使用される問題を修正 - Fix: pgroongaでの検索時にはじめのキーワードのみが検索に使用される問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/886) (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/886)
- Fix: メールアドレスの形式が正しくなければ以降の処理を行わないように
- Fix: クリップの説明欄を更新する際に空にできない問題を修正
- Fix: フォロワーではないユーザーにリートもしくは返信された場合にートのDeleteアクティビティが送られていない問題を修正
## 2025.2.0 ## 2025.2.0

8
locales/index.d.ts vendored
View File

@ -10896,13 +10896,7 @@ export interface Locale extends ILocale {
*/ */
"title": string; "title": string;
/** /**
* * URIを使用して照会し直してください
*/
"description": string;
};
"_responseInvalidIdHostNotMatch": {
/**
* URIのドメインと最終的に得られたURIのドメインとが異なりますURIを使用して照会し直してください
*/ */
"description": string; "description": string;
}; };

View File

@ -2911,9 +2911,7 @@ _remoteLookupErrors:
description: "このサーバーとの通信に失敗しました。相手サーバーがダウンしている可能性があります。また、不正なURIや存在しないURIを入力していないか確認してください。" description: "このサーバーとの通信に失敗しました。相手サーバーがダウンしている可能性があります。また、不正なURIや存在しないURIを入力していないか確認してください。"
_responseInvalid: _responseInvalid:
title: "レスポンスが不正です" title: "レスポンスが不正です"
description: "このサーバーと通信することはできましたが、得られたデータが不正なものでした。" description: "このサーバーと通信することはできましたが、得られたデータが不正なものでした。第三者のサーバーを介してリモートのコンテンツを照会している場合は、発信元のサーバーで取得できるURIを使用して照会し直してください。"
_responseInvalidIdHostNotMatch:
description: "入力されたURIのドメインと最終的に得られたURIのドメインとが異なります。第三者のサーバーを介してリモートのコンテンツを照会している場合は、発信元のサーバーで取得できるURIを使用して照会し直してください。"
_noSuchObject: _noSuchObject:
title: "見つかりません" title: "見つかりません"
description: "要求されたリソースは見つかりませんでした。URIをもう一度お確かめください。" description: "要求されたリソースは見つかりませんでした。URIをもう一度お確かめください。"

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2025.2.0", "version": "2025.2.1-beta.1",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -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"`);
}
}

View File

@ -103,7 +103,7 @@
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"body-parser": "1.20.3", "body-parser": "1.20.3",
"bullmq": "5.41.0", "bullmq": "5.41.1",
"cacheable-lookup": "7.0.0", "cacheable-lookup": "7.0.0",
"cbor": "9.0.2", "cbor": "9.0.2",
"chalk": "5.4.1", "chalk": "5.4.1",

View File

@ -73,6 +73,7 @@ type Source = {
proxyBypassHosts?: string[]; proxyBypassHosts?: string[];
allowedPrivateNetworks?: string[]; allowedPrivateNetworks?: string[];
disallowExternalApRedirect?: boolean;
maxFileSize?: number; maxFileSize?: number;
@ -149,6 +150,7 @@ export type Config = {
proxySmtp: string | undefined; proxySmtp: string | undefined;
proxyBypassHosts: string[] | undefined; proxyBypassHosts: string[] | undefined;
allowedPrivateNetworks: string[] | undefined; allowedPrivateNetworks: string[] | undefined;
disallowExternalApRedirect: boolean;
maxFileSize: number; maxFileSize: number;
clusterLimit: number | undefined; clusterLimit: number | undefined;
id: string; id: string;
@ -287,6 +289,7 @@ export function loadConfig(): Config {
proxySmtp: config.proxySmtp, proxySmtp: config.proxySmtp,
proxyBypassHosts: config.proxyBypassHosts, proxyBypassHosts: config.proxyBypassHosts,
allowedPrivateNetworks: config.allowedPrivateNetworks, allowedPrivateNetworks: config.allowedPrivateNetworks,
disallowExternalApRedirect: config.disallowExternalApRedirect ?? false,
maxFileSize: config.maxFileSize ?? 262144000, maxFileSize: config.maxFileSize ?? 262144000,
clusterLimit: config.clusterLimit, clusterLimit: config.clusterLimit,
outgoingAddress: config.outgoingAddress, outgoingAddress: config.outgoingAddress,

View File

@ -164,6 +164,13 @@ export class EmailService {
available: boolean; available: boolean;
reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp' | 'banned' | 'network' | 'blacklist'; 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({ const exist = await this.userProfilesRepository.countBy({
emailVerified: true, emailVerified: true,
email: emailAddress, email: emailAddress,

View File

@ -16,7 +16,7 @@ import type { Config } from '@/config.js';
import { StatusError } from '@/misc/status-error.js'; import { StatusError } from '@/misc/status-error.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.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 { IObject } from '@/core/activitypub/type.js';
import type { Response } from 'node-fetch'; import type { Response } from 'node-fetch';
import type { URL } from 'node:url'; import type { URL } from 'node:url';
@ -215,7 +215,7 @@ export class HttpRequestService {
} }
@bindThis @bindThis
public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObject> { public async getActivityJson(url: string, isLocalAddressAllowed = false, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict): Promise<IObject> {
const res = await this.send(url, { const res = await this.send(url, {
method: 'GET', method: 'GET',
headers: { headers: {
@ -232,7 +232,7 @@ export class HttpRequestService {
const finalUrl = res.url; // redirects may have been involved const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject; const activity = await res.json() as IObject;
assertActivityMatchesUrls(activity, [finalUrl]); assertActivityMatchesUrls(url, activity, [finalUrl], allowSoftfail);
return activity; return activity;
} }

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * 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 { Injectable, Inject } from '@nestjs/common';
import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
import type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js'; import type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
@ -189,13 +189,27 @@ export class NoteDeleteService {
}) as MiRemoteUser[]; }) 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 @bindThis
private async deliverToConcerned(user: { id: MiLocalUser['id']; host: null; }, note: MiNote, content: any) { private async deliverToConcerned(user: { id: MiLocalUser['id']; host: null; }, note: MiNote, content: any) {
this.apDeliverManagerService.deliverToFollowers(user, content); this.apDeliverManagerService.deliverToFollowers(user, content);
this.relayService.deliverToRelays(user, content); this.relayService.deliverToRelays(user, content);
const remoteUsers = await this.getMentionedRemoteUsers(note); this.apDeliverManagerService.deliverToUsers(user, content, [
for (const remoteUser of remoteUsers) { ...await this.getMentionedRemoteUsers(note),
this.apDeliverManagerService.deliverToUser(user, content, remoteUser); ...await this.getRenotedOrRepliedRemoteUsers(note),
} ]);
} }
} }

View File

@ -74,7 +74,7 @@ export class RemoteUserResolveService {
if (user == null) { if (user == null) {
const self = await this.resolveSelf(acctLower); 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); const local = this.apDbResolverService.parseUri(self.href);
if (local.local && local.type === 'users') { if (local.local && local.type === 'users') {
// the LR points to local // the LR points to local

View File

@ -38,6 +38,14 @@ export class UtilityService {
return this.punyHost(uri) === this.toPuny(this.config.host); 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 @bindThis
public isBlockedHost(blockedHosts: string[], host: string | null): boolean { public isBlockedHost(blockedHosts: string[], host: string | null): boolean {
if (host == null) return false; if (host == null) return false;

View File

@ -196,6 +196,25 @@ export class ApDeliverManagerService {
await manager.execute(); await manager.execute();
} }
/**
* Deliver activity to users
* @param actor
* @param activity Activity
* @param targets Target users
*/
@bindThis
public async deliverToUsers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, targets: MiRemoteUser[]): Promise<void> {
const manager = new DeliverManager(
this.userEntityService,
this.followingsRepository,
this.queueService,
actor,
activity,
);
for (const to of targets) manager.addDirectRecipe(to);
await manager.execute();
}
@bindThis @bindThis
public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager { public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager {
return new DeliverManager( return new DeliverManager(

View File

@ -27,6 +27,7 @@ import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFil
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { JsonLdService } from './JsonLdService.js'; import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js'; import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js'; import { CONTEXT } from './misc/contexts.js';
@ -61,6 +62,7 @@ export class ApRendererService {
private apMfmService: ApMfmService, private apMfmService: ApMfmService,
private mfmService: MfmService, private mfmService: MfmService,
private idService: IdService, private idService: IdService,
private utilityService: UtilityService,
) { ) {
} }
@ -577,7 +579,7 @@ export class ApRendererService {
@bindThis @bindThis
public renderUndo(object: string | IObject, user: { id: MiUser['id'] }): IUndo { 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 { return {
type: 'Undo', type: 'Undo',

View File

@ -17,7 +17,7 @@ import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.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'; import type { IObject } from './type.js';
type Request = { type Request = {
@ -185,7 +185,7 @@ export class ApRequestService {
* @param url URL to fetch * @param url URL to fetch
*/ */
@bindThis @bindThis
public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<unknown> { public async signedGet(url: string, user: { id: MiUser['id'] }, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict, followAlternate?: boolean): Promise<unknown> {
const _followAlternate = followAlternate ?? true; const _followAlternate = followAlternate ?? true;
const keypair = await this.userKeypairService.getUserKeypair(user.id); const keypair = await this.userKeypairService.getUserKeypair(user.id);
@ -243,7 +243,7 @@ export class ApRequestService {
if (alternate) { if (alternate) {
const href = alternate.getAttribute('href'); const href = alternate.getAttribute('href');
if (href && this.utilityService.punyHost(url) === this.utilityService.punyHost(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) { } catch (e) {
@ -258,7 +258,7 @@ export class ApRequestService {
const finalUrl = res.url; // redirects may have been involved const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject; const activity = await res.json() as IObject;
assertActivityMatchesUrls(activity, [finalUrl]); assertActivityMatchesUrls(url, activity, [finalUrl], allowSoftfail);
return activity; return activity;
} }

View File

@ -21,6 +21,7 @@ import { ApRendererService } from './ApRendererService.js';
import { ApRequestService } from './ApRequestService.js'; import { ApRequestService } from './ApRequestService.js';
import type { IObject, ICollection, IOrderedCollection } from './type.js'; import type { IObject, ICollection, IOrderedCollection } from './type.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { FetchAllowSoftFailMask } from './misc/check-against-url.js';
export class Resolver { export class Resolver {
private history: Set<string>; private history: Set<string>;
@ -72,7 +73,7 @@ export class Resolver {
} }
@bindThis @bindThis
public async resolve(value: string | IObject): Promise<IObject> { public async resolve(value: string | IObject, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict): Promise<IObject> {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return value; return value;
} }
@ -108,8 +109,8 @@ export class Resolver {
} }
const object = (this.user const object = (this.user
? await this.apRequestService.signedGet(value, this.user) as IObject ? await this.apRequestService.signedGet(value, this.user, allowSoftfail) as IObject
: await this.httpRequestService.getActivityJson(value)) as IObject; : await this.httpRequestService.getActivityJson(value, undefined, allowSoftfail)) as IObject;
if ( if (
Array.isArray(object['@context']) ? Array.isArray(object['@context']) ?
@ -118,19 +119,7 @@ export class Resolver {
) { ) {
throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', 'invalid response'); 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; return object;
} }

View File

@ -4,18 +4,124 @@
*/ */
import type { IObject } from '../type.js'; import type { IObject } from '../type.js';
export function assertActivityMatchesUrls(activity: IObject, urls: string[]) { export enum FetchAllowSoftFailMask {
const hosts = urls.map(it => new URL(it).host); // Allow no softfail flags
Strict = 0,
const idOk = activity.id !== undefined && hosts.includes(new URL(activity.id).host); // The values in tuple (requestUrl, finalUrl, objectId) are not all identical
//
// technically `activity.url` could be an `ApObject = IObject | // This condition is common for user-initiated lookups but should not be allowed in federation loop
// string | (IObject | string)[]`, but if it's a complicated thing //
// and the `activity.id` doesn't match, I think we're fine // Allow variations:
// rejecting the activity // good example: https://alice.example.com/@user -> https://alice.example.com/user/:userId
const urlOk = typeof(activity.url) === 'string' && hosts.includes(new URL(activity.url).host); // problematic example: https://alice.example.com/redirect?url=https://bad.example.com/ -> https://bad.example.com/ -> https://alice.example.com/somethingElse
NonCanonicalId = 1 << 0,
if (!idOk && !urlOk) { // Allow the final object to be at most one subdomain deeper than the request URL, similar to SPF relaxed alignment
throw new Error(`bad Activity: neither id(${activity?.id}) nor url(${activity?.url}) match location(${urls})`); //
} // 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;
} }

View File

@ -97,6 +97,7 @@ export class MetaEntityService {
enableTurnstile: instance.enableTurnstile, enableTurnstile: instance.enableTurnstile,
turnstileSiteKey: instance.turnstileSiteKey, turnstileSiteKey: instance.turnstileSiteKey,
enableTestcaptcha: instance.enableTestcaptcha, enableTestcaptcha: instance.enableTestcaptcha,
googleAnalyticsMeasurementId: instance.googleAnalyticsMeasurementId,
swPublickey: instance.swPublicKey, swPublickey: instance.swPublicKey,
themeColor: instance.themeColor, themeColor: instance.themeColor,
mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png', mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png',

View File

@ -658,4 +658,10 @@ export class MiMeta {
default: '{}', default: '{}',
}) })
public federationHosts: string[]; public federationHosts: string[];
@Column('varchar', {
length: 64,
nullable: true,
})
public googleAnalyticsMeasurementId: string | null;
} }

View File

@ -119,6 +119,10 @@ export const packedMetaLiteSchema = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
googleAnalyticsMeasurementId: {
type: 'string',
optional: false, nullable: true,
},
swPublickey: { swPublickey: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,

View File

@ -107,12 +107,12 @@ export class InboxProcessorService implements OnApplicationShutdown {
// それでもわからなければ終了 // それでもわからなければ終了
if (authUser == null) { 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 がなくても終了 // publicKey がなくても終了
if (authUser.key == null) { 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の検証 // HTTP-Signatureの検証

View File

@ -103,6 +103,43 @@ export class ServerService implements OnApplicationShutdown {
serve: false, 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.apiServerService.createServer, { prefix: '/api' });
fastify.register(this.openApiServerService.createServer); fastify.register(this.openApiServerService.createServer);
fastify.register(this.fileServerService.createServer); fastify.register(this.fileServerService.createServer);

View File

@ -73,6 +73,10 @@ export const meta = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
googleAnalyticsMeasurementId: {
type: 'string',
optional: false, nullable: true,
},
swPublickey: { swPublickey: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
@ -572,6 +576,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
enableTurnstile: instance.enableTurnstile, enableTurnstile: instance.enableTurnstile,
turnstileSiteKey: instance.turnstileSiteKey, turnstileSiteKey: instance.turnstileSiteKey,
enableTestcaptcha: instance.enableTestcaptcha, enableTestcaptcha: instance.enableTestcaptcha,
googleAnalyticsMeasurementId: instance.googleAnalyticsMeasurementId,
swPublickey: instance.swPublicKey, swPublickey: instance.swPublicKey,
themeColor: instance.themeColor, themeColor: instance.themeColor,
mascotImageUrl: instance.mascotImageUrl, mascotImageUrl: instance.mascotImageUrl,

View File

@ -84,6 +84,7 @@ export const paramDef = {
turnstileSiteKey: { type: 'string', nullable: true }, turnstileSiteKey: { type: 'string', nullable: true },
turnstileSecretKey: { type: 'string', nullable: true }, turnstileSecretKey: { type: 'string', nullable: true },
enableTestcaptcha: { type: 'boolean' }, enableTestcaptcha: { type: 'boolean' },
googleAnalyticsMeasurementId: { type: 'string', nullable: true },
sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] }, sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] },
sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] }, sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] },
setSensitiveFlagAutomatically: { type: 'boolean' }, setSensitiveFlagAutomatically: { type: 'boolean' },
@ -371,6 +372,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.enableTestcaptcha = ps.enableTestcaptcha; 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) { if (ps.sensitiveMediaDetection !== undefined) {
set.sensitiveMediaDetection = ps.sensitiveMediaDetection; set.sensitiveMediaDetection = ps.sensitiveMediaDetection;
} }

View File

@ -20,6 +20,7 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
export const meta = { export const meta = {
tags: ['federation'], tags: ['federation'],
@ -53,11 +54,6 @@ export const meta = {
code: 'RESPONSE_INVALID', code: 'RESPONSE_INVALID',
id: '70193c39-54f3-4813-82f0-70a680f7495b', 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: { noSuchObject: {
message: 'No such object.', message: 'No such object.',
code: 'NO_SUCH_OBJECT', code: 'NO_SUCH_OBJECT',
@ -153,7 +149,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// リモートから一旦オブジェクトフェッチ // リモートから一旦オブジェクトフェッチ
const resolver = this.apResolverService.createResolver(); const 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) { if (err instanceof IdentifiableError) {
switch (err.id) { switch (err.id) {
// resolve // resolve
@ -165,10 +162,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
case '09d79f9e-64f1-4316-9cfa-e75c4d091574': case '09d79f9e-64f1-4316-9cfa-e75c4d091574':
throw new ApiError(meta.errors.federationNotAllowed); throw new ApiError(meta.errors.federationNotAllowed);
case '72180409-793c-4973-868e-5a118eb5519b': case '72180409-793c-4973-868e-5a118eb5519b':
case 'ad2dc287-75c1-44c4-839d-3d2e64576675':
throw new ApiError(meta.errors.responseInvalid); throw new ApiError(meta.errors.responseInvalid);
case 'fd93c2fa-69a8-440f-880b-bf178e0ec877':
throw new ApiError(meta.errors.responseInvalidIdHostNotMatch);
// resolveLocal // resolveLocal
case '02b40cd0-fa92-4b0c-acc9-fb2ada952ab8': case '02b40cd0-fa92-4b0c-acc9-fb2ada952ab8':

View File

@ -39,7 +39,7 @@ export const paramDef = {
properties: { properties: {
name: { type: 'string', minLength: 1, maxLength: 100 }, name: { type: 'string', minLength: 1, maxLength: 100 },
isPublic: { type: 'boolean', default: false }, isPublic: { type: 'boolean', default: false },
description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, description: { type: 'string', nullable: true, maxLength: 2048 },
}, },
required: ['name'], required: ['name'],
} as const; } as const;
@ -53,7 +53,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
let clip: MiClip; let clip: MiClip;
try { 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) { } catch (e) {
if (e instanceof ClipService.TooManyClipsError) { if (e instanceof ClipService.TooManyClipsError) {
throw new ApiError(meta.errors.tooManyClips); throw new ApiError(meta.errors.tooManyClips);

View File

@ -39,7 +39,7 @@ export const paramDef = {
clipId: { type: 'string', format: 'misskey:id' }, clipId: { type: 'string', format: 'misskey:id' },
name: { type: 'string', minLength: 1, maxLength: 100 }, name: { type: 'string', minLength: 1, maxLength: 100 },
isPublic: { type: 'boolean' }, isPublic: { type: 'boolean' },
description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, description: { type: 'string', nullable: true, maxLength: 2048 },
}, },
required: ['clipId'], required: ['clipId'],
} as const; } as const;
@ -53,7 +53,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
try { 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) { } catch (e) {
if (e instanceof ClipService.NoSuchClipError) { if (e instanceof ClipService.NoSuchClipError) {
throw new ApiError(meta.errors.noSuchClip); throw new ApiError(meta.errors.noSuchClip);

View File

@ -5,112 +5,107 @@
*/ */
* { * {
font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
} }
#misskey_app, #misskey_app,
#splash { #splash {
display: none !important; display: none !important;
} }
body, body,
html { html {
background-color: #222; background-color: #222;
color: #dfddcc; color: #dfddcc;
justify-content: center; justify-content: center;
margin: auto; margin: auto;
padding: 10px; padding: 10px;
text-align: center; text-align: center;
} }
button { button {
border-radius: 999px; border-radius: 999px;
padding: 0px 12px 0px 12px; padding: 0px 12px 0px 12px;
border: none; border: none;
cursor: pointer; cursor: pointer;
margin-bottom: 12px; margin-bottom: 12px;
} }
.button-big { .button-big {
background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0)); background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0));
line-height: 50px; line-height: 50px;
} }
.button-big:hover { .button-big:hover {
background: rgb(153, 204, 0); background: rgb(153, 204, 0);
} }
.button-small { .button-small {
background: #444; background: #444;
line-height: 40px; line-height: 40px;
} }
.button-small:hover { .button-small:hover {
background: #555; background: #555;
} }
.button-label-big { .button-label-big {
color: #222; color: #222;
font-weight: bold; font-weight: bold;
font-size: 20px; font-size: 1.2em;
padding: 12px; padding: 12px;
} }
.button-label-small { .button-label-small {
color: rgb(153, 204, 0); color: rgb(153, 204, 0);
font-size: 16px; font-size: 16px;
padding: 12px; padding: 12px;
} }
a { a {
color: rgb(134, 179, 0); color: rgb(134, 179, 0);
text-decoration: none; text-decoration: none;
} }
p, p,
li { li {
font-size: 16px; font-size: 16px;
}
.dont-worry,
#msg {
font-size: 18px;
} }
.icon-warning { .icon-warning {
color: #dec340; color: #dec340;
height: 4rem; height: 4rem;
padding-top: 2rem; padding-top: 2rem;
} }
h1 { h1 {
font-size: 32px; font-size: 1.5em;
margin: 1em;
} }
code { code {
display: block; display: block;
font-family: Fira, FiraCode, monospace; font-family: Fira, FiraCode, monospace;
background: #333; background: #333;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
max-width: 40rem; max-width: 40rem;
border-radius: 10px; border-radius: 10px;
justify-content: center; justify-content: center;
margin: auto; margin: auto;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
summary { #errorInfo summary {
cursor: pointer; cursor: pointer;
} }
summary > * { #errorInfo summary>* {
display: inline; display: inline;
white-space: pre-wrap;
} }
@media screen and (max-width: 500px) { @media screen and (max-width: 500px) {
details { #errorInfo {
width: 50%; width: 50%;
} }
} }

View File

@ -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];
}
}
});
})();

View File

@ -2,15 +2,15 @@ doctype html
// //
- -
_____ _ _ _____ _ _
| |_|___ ___| |_ ___ _ _ | |_|___ ___| |_ ___ _ _
| | | | |_ -|_ -| '_| -_| | | | | | | |_ -|_ -| '_| -_| | |
|_|_|_|_|___|___|_,_|___|_ | |_|_|_|_|___|___|_,_|___|_ |
|___| |___|
Thank you for using Misskey! Thank you for using Misskey!
If you are reading this message... how about joining the development? If you are reading this message... how about joining the development?
https://github.com/misskey-dev/misskey https://github.com/misskey-dev/misskey
html html
@ -27,39 +27,45 @@ html
style style
include ../error.css include ../error.css
script
include ../error.js
body 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") 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(stroke="none", d="M0 0h24v24H0z", fill="none")
path(d="M12 9v2m0 4v.01") 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") 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();") 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(data-i18n="serverError") If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.
p If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.
div#errors div#errors
code. code.
ERROR CODE: #{code} ERROR CODE: #{code}
ERROR ID: #{id} 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(data-i18n="solution1") Update your os and browser
p Disable an adblocker. 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") details(style="color: #86b300;")
button.button-small summary(data-i18n="otherOption") Other options
span.button-label-small Clear preferences and cache a(href="/flush")
br button.button-small
a(href="/cli") span.button-label-small(data-i18n="otherOption1") Clear preferences and cache
button.button-small br
span.button-label-small Start the simple client a(href="/cli")
br button.button-small
a(href="/bios") span.button-label-small(data-i18n="otherOption2") Start the simple client
button.button-small br
span.button-label-small Start the repair tool a(href="/bios")
button.button-small
span.button-label-small(data-i18n="otherOption3") Start the repair tool

View File

@ -139,29 +139,99 @@ describe('Note', () => {
}); });
describe('Deletion', () => { describe('Deletion', () => {
describe('Check Delete consistency', () => { describe('Check Delete is delivered', () => {
let carol: LoginUser; describe('To followers', () => {
let carol: LoginUser;
beforeAll(async () => { beforeAll(async () => {
carol = await createAccount('a.test'); carol = await createAccount('a.test');
await carol.client.request('following/create', { userId: bobInA.id }); await carol.client.request('following/create', { userId: bobInA.id });
await sleep(); 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 () => { describe('To renoted and not followed user', () => {
const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote; test('Check', async () => {
const noteInA = await resolveRemoteNote('b.test', note.id, carol); const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
await bob.client.request('notes/delete', { noteId: note.id }); const noteInA = await resolveRemoteNote('b.test', note.id, alice);
await sleep(); await alice.client.request('notes/create', { renoteId: noteInA.id });
await sleep();
await rejects( await bob.client.request('notes/delete', { noteId: note.id });
async () => await carol.client.request('notes/show', { noteId: noteInA.id }), await sleep();
(err: any) => {
strictEqual(err.code, 'NO_SUCH_NOTE'); await rejects(
return true; 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;
},
);
});
}); });
}); });

View File

@ -182,7 +182,6 @@ describe('クリップ', () => {
{ label: 'nameがnull', parameters: { name: null } }, { label: 'nameがnull', parameters: { name: null } },
{ label: 'nameが最大長+1', parameters: { name: 'x'.repeat(101) } }, { label: 'nameが最大長+1', parameters: { name: 'x'.repeat(101) } },
{ label: 'isPublicがboolじゃない', parameters: { isPublic: 'true' } }, { label: 'isPublicがboolじゃない', parameters: { isPublic: 'true' } },
{ label: 'descriptionがゼロ長', parameters: { description: '' } },
{ label: 'descriptionが最大長+1', parameters: { description: 'a'.repeat(2049) } }, { label: 'descriptionが最大長+1', parameters: { description: 'a'.repeat(2049) } },
]; ];
test.each(createClipDenyPattern)('の作成は$labelならできない', async ({ parameters }) => failedApiCall({ test.each(createClipDenyPattern)('の作成は$labelならできない', async ({ parameters }) => failedApiCall({
@ -199,6 +198,23 @@ describe('クリップ', () => {
id: '3d81ceae-475f-4600-b2a8-2bc116157532', 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 () => { test('の更新ができる', async () => {
const res = await update({ const res = await update({
clipId: (await create()).id, clipId: (await create()).id,
@ -249,6 +265,24 @@ describe('クリップ', () => {
...assertion, ...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 () => { test('の削除ができる', async () => {
await deleteClip({ await deleteClip({
clipId: (await create()).id, clipId: (await create()).id,

View File

@ -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 === bobNote2.id), true);
assert.strictEqual(res.body.some(note => note.id === carolNote1.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote1.id), false);
assert.strictEqual(res.body.some(note => note.id === carolNote2.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote2.id), false);
}, 1000 * 15); }, 1000 * 30);
test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => { test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]); const [alice, bob] = await Promise.all([signup(), signup()]);

View File

@ -3,6 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { UtilityService } from '@/core/UtilityService.js';
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals'; import { jest } from '@jest/globals';
@ -40,6 +42,7 @@ describe('RelayService', () => {
ApRendererService, ApRendererService,
RelayService, RelayService,
UserEntityService, UserEntityService,
UtilityService,
], ],
}) })
.useMocker((token) => { .useMocker((token) => {

View File

@ -8,6 +8,8 @@ import httpSignature from '@peertube/http-signature';
import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.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) => { export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => {
return { return {
@ -24,6 +26,10 @@ export const buildParsedSignature = (signingString: string, signature: string, a
}; };
}; };
function cartesianProduct<T, U>(a: T[], b: U[]): [T, U][] {
return a.flatMap(a => b.map(b => [a, b] as [T, U]));
}
describe('ap-request', () => { describe('ap-request', () => {
test('createSignedPost with verify', async () => { test('createSignedPost with verify', async () => {
const keypair = await genRsaKeyPair(); const keypair = await genRsaKeyPair();
@ -58,4 +64,123 @@ describe('ap-request', () => {
const result = httpSignature.verifySignature(parsed, keypair.publicKey); const result = httpSignature.verifySignature(parsed, keypair.publicKey);
assert.deepStrictEqual(result, true); 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');
});
}); });

View File

@ -25,16 +25,16 @@
"misskey-js": "workspace:*", "misskey-js": "workspace:*",
"frontend-shared": "workspace:*", "frontend-shared": "workspace:*",
"punycode.js": "2.3.1", "punycode.js": "2.3.1",
"rollup": "4.34.7", "rollup": "4.34.8",
"sass": "1.85.0", "sass": "1.85.0",
"shiki": "2.3.2", "shiki": "3.0.0",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tsc-alias": "1.8.10", "tsc-alias": "1.8.10",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "5.7.3", "typescript": "5.7.3",
"uuid": "11.0.5", "uuid": "11.1.0",
"json5": "2.2.3", "json5": "2.2.3",
"vite": "6.1.0", "vite": "6.1.1",
"vue": "3.5.13" "vue": "3.5.13"
}, },
"devDependencies": { "devDependencies": {
@ -42,29 +42,29 @@
"@testing-library/vue": "8.1.0", "@testing-library/vue": "8.1.0",
"@types/estree": "1.0.6", "@types/estree": "1.0.6",
"@types/micromatch": "4.0.9", "@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/punycode.js": "npm:@types/punycode@2.1.4",
"@types/tinycolor2": "1.4.6", "@types/tinycolor2": "1.4.6",
"@types/ws": "8.5.14", "@types/ws": "8.5.14",
"@typescript-eslint/eslint-plugin": "8.24.0", "@typescript-eslint/eslint-plugin": "8.24.1",
"@typescript-eslint/parser": "8.24.0", "@typescript-eslint/parser": "8.24.1",
"@vitest/coverage-v8": "3.0.5", "@vitest/coverage-v8": "3.0.6",
"@vue/runtime-core": "3.5.13", "@vue/runtime-core": "3.5.13",
"acorn": "8.14.0", "acorn": "8.14.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.31.0",
"eslint-plugin-vue": "9.32.0", "eslint-plugin-vue": "9.32.0",
"fast-glob": "3.3.3", "fast-glob": "3.3.3",
"happy-dom": "17.1.0", "happy-dom": "17.1.4",
"intersection-observer": "0.12.2", "intersection-observer": "0.12.2",
"micromatch": "4.0.8", "micromatch": "4.0.8",
"msw": "2.7.0", "msw": "2.7.1",
"nodemon": "3.1.9", "nodemon": "3.1.9",
"prettier": "3.5.1", "prettier": "3.5.2",
"start-server-and-test": "2.0.10", "start-server-and-test": "2.0.10",
"vite-plugin-turbosnap": "1.0.3", "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-eslint-parser": "9.4.3",
"vue-tsc": "2.2.0" "vue-tsc": "2.2.4"
} }
} }

View File

@ -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 のため一時的に無効化 // 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 // See https://github.com/misskey-dev/misskey/pull/15311
'js/i18n.ts', 'js/i18n.ts',
'js-built/',
], ],
}, },
]; ];

View File

@ -134,7 +134,6 @@ export function scrollToBottom(
export function isTopVisible(el: HTMLElement, tolerance = 1): boolean { export function isTopVisible(el: HTMLElement, tolerance = 1): boolean {
const scrollTop = getScrollPosition(el); const scrollTop = getScrollPosition(el);
if (_DEV_) console.log(scrollTop, tolerance, scrollTop <= tolerance);
return scrollTop <= tolerance; return scrollTop <= tolerance;
} }

View File

@ -21,9 +21,9 @@
"lint": "pnpm typecheck && pnpm eslint" "lint": "pnpm typecheck && pnpm eslint"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "22.13.4", "@types/node": "22.13.5",
"@typescript-eslint/eslint-plugin": "8.24.0", "@typescript-eslint/eslint-plugin": "8.24.1",
"@typescript-eslint/parser": "8.24.0", "@typescript-eslint/parser": "8.24.1",
"esbuild": "0.25.0", "esbuild": "0.25.0",
"eslint-plugin-vue": "9.32.0", "eslint-plugin-vue": "9.32.0",
"nodemon": "3.1.9", "nodemon": "3.1.9",

View File

@ -16,6 +16,7 @@
"lint": "pnpm typecheck && pnpm eslint" "lint": "pnpm typecheck && pnpm eslint"
}, },
"dependencies": { "dependencies": {
"@analytics/google-analytics": "1.1.0",
"@discordapp/twemoji": "15.1.0", "@discordapp/twemoji": "15.1.0",
"@github/webauthn-json": "2.1.1", "@github/webauthn-json": "2.1.1",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
@ -29,11 +30,12 @@
"@vitejs/plugin-vue": "5.2.1", "@vitejs/plugin-vue": "5.2.1",
"@vue/compiler-sfc": "3.5.13", "@vue/compiler-sfc": "3.5.13",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
"analytics": "0.8.16",
"astring": "1.9.0", "astring": "1.9.0",
"broadcast-channel": "7.0.0", "broadcast-channel": "7.0.0",
"buraha": "0.0.1", "buraha": "0.0.1",
"canvas-confetti": "1.9.3", "canvas-confetti": "1.9.3",
"chart.js": "4.4.7", "chart.js": "4.4.8",
"chartjs-adapter-date-fns": "3.0.0", "chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-matrix": "2.0.1", "chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-gradient": "0.6.1",
@ -56,10 +58,10 @@
"misskey-reversi": "workspace:*", "misskey-reversi": "workspace:*",
"photoswipe": "5.4.4", "photoswipe": "5.4.4",
"punycode.js": "2.3.1", "punycode.js": "2.3.1",
"rollup": "4.34.7", "rollup": "4.34.8",
"sanitize-html": "2.14.0", "sanitize-html": "2.14.0",
"sass": "1.85.0", "sass": "1.85.0",
"shiki": "2.3.2", "shiki": "3.0.0",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0", "textarea-caret": "3.1.0",
"three": "0.173.0", "three": "0.173.0",
@ -68,47 +70,47 @@
"tsc-alias": "1.8.10", "tsc-alias": "1.8.10",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "5.7.3", "typescript": "5.7.3",
"uuid": "11.0.5", "uuid": "11.1.0",
"v-code-diff": "1.13.1", "v-code-diff": "1.13.1",
"vite": "6.1.0", "vite": "6.1.1",
"vue": "3.5.13", "vue": "3.5.13",
"vuedraggable": "next" "vuedraggable": "next"
}, },
"devDependencies": { "devDependencies": {
"@misskey-dev/summaly": "5.2.0", "@misskey-dev/summaly": "5.2.0",
"@storybook/addon-actions": "8.5.6", "@storybook/addon-actions": "8.5.8",
"@storybook/addon-essentials": "8.5.6", "@storybook/addon-essentials": "8.5.8",
"@storybook/addon-interactions": "8.5.6", "@storybook/addon-interactions": "8.5.8",
"@storybook/addon-links": "8.5.6", "@storybook/addon-links": "8.5.8",
"@storybook/addon-mdx-gfm": "8.5.6", "@storybook/addon-mdx-gfm": "8.5.8",
"@storybook/addon-storysource": "8.5.6", "@storybook/addon-storysource": "8.5.8",
"@storybook/blocks": "8.5.6", "@storybook/blocks": "8.5.8",
"@storybook/components": "8.5.6", "@storybook/components": "8.5.8",
"@storybook/core-events": "8.5.6", "@storybook/core-events": "8.5.8",
"@storybook/manager-api": "8.5.6", "@storybook/manager-api": "8.5.8",
"@storybook/preview-api": "8.5.6", "@storybook/preview-api": "8.5.8",
"@storybook/react": "8.5.6", "@storybook/react": "8.5.8",
"@storybook/react-vite": "8.5.6", "@storybook/react-vite": "8.5.8",
"@storybook/test": "8.5.6", "@storybook/test": "8.5.8",
"@storybook/theming": "8.5.6", "@storybook/theming": "8.5.8",
"@storybook/types": "8.5.6", "@storybook/types": "8.5.8",
"@storybook/vue3": "8.5.6", "@storybook/vue3": "8.5.8",
"@storybook/vue3-vite": "8.5.6", "@storybook/vue3-vite": "8.5.8",
"@testing-library/vue": "8.1.0", "@testing-library/vue": "8.1.0",
"@types/canvas-confetti": "1.9.0", "@types/canvas-confetti": "1.9.0",
"@types/estree": "1.0.6", "@types/estree": "1.0.6",
"@types/matter-js": "0.19.8", "@types/matter-js": "0.19.8",
"@types/micromatch": "4.0.9", "@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/punycode.js": "npm:@types/punycode@2.1.4",
"@types/sanitize-html": "2.13.0", "@types/sanitize-html": "2.13.0",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@types/throttle-debounce": "5.0.2", "@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6", "@types/tinycolor2": "1.4.6",
"@types/ws": "8.5.14", "@types/ws": "8.5.14",
"@typescript-eslint/eslint-plugin": "8.24.0", "@typescript-eslint/eslint-plugin": "8.24.1",
"@typescript-eslint/parser": "8.24.0", "@typescript-eslint/parser": "8.24.1",
"@vitest/coverage-v8": "3.0.5", "@vitest/coverage-v8": "3.0.6",
"@vue/runtime-core": "3.5.13", "@vue/runtime-core": "3.5.13",
"acorn": "8.14.0", "acorn": "8.14.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
@ -116,24 +118,24 @@
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.31.0",
"eslint-plugin-vue": "9.32.0", "eslint-plugin-vue": "9.32.0",
"fast-glob": "3.3.3", "fast-glob": "3.3.3",
"happy-dom": "17.1.0", "happy-dom": "17.1.4",
"intersection-observer": "0.12.2", "intersection-observer": "0.12.2",
"micromatch": "4.0.8", "micromatch": "4.0.8",
"msw": "2.7.0", "msw": "2.7.1",
"msw-storybook-addon": "2.0.4", "msw-storybook-addon": "2.0.4",
"nodemon": "3.1.9", "nodemon": "3.1.9",
"prettier": "3.5.1", "prettier": "3.5.2",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"seedrandom": "3.0.5", "seedrandom": "3.0.5",
"start-server-and-test": "2.0.10", "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", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"vite-plugin-turbosnap": "1.0.3", "vite-plugin-turbosnap": "1.0.3",
"vitest": "3.0.5", "vitest": "3.0.6",
"vitest-fetch-mock": "0.4.3", "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-eslint-parser": "9.4.3",
"vue-tsc": "2.2.0" "vue-tsc": "2.2.4"
} }
} }

View File

@ -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<AnalyticsInstance['identify']>) {
return this.analytics?.identify(...args) ?? Promise.resolve();
}
public track(...args: Parameters<AnalyticsInstance['track']>) {
return this.analytics?.track(...args) ?? Promise.resolve();
}
public page(...args: Parameters<AnalyticsInstance['page']>) {
return this.analytics?.page(...args) ?? Promise.resolve();
}
public user(...args: Parameters<AnalyticsInstance['user']>) {
return this.analytics?.user(...args) ?? Promise.resolve();
}
public reset(...args: Parameters<AnalyticsInstance['reset']>) {
return this.analytics?.reset(...args) ?? Promise.resolve();
}
public ready(...args: Parameters<AnalyticsInstance['ready']>) {
return this.analytics?.ready(...args) ?? function () { void 0; };
}
public on(...args: Parameters<AnalyticsInstance['on']>) {
return this.analytics?.on(...args) ?? function () { void 0; };
}
public once(...args: Parameters<AnalyticsInstance['once']>) {
return this.analytics?.once(...args) ?? function () { void 0; };
}
public getState(...args: Parameters<AnalyticsInstance['getState']>) {
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,
}));
}

View File

@ -4,9 +4,9 @@
*/ */
import { computed, watch, version as vueVersion } from 'vue'; import { computed, watch, version as vueVersion } from 'vue';
import type { App } from 'vue';
import { compareVersions } from 'compare-versions'; import { compareVersions } from 'compare-versions';
import { version, lang, updateLocale, locale } from '@@/js/config.js'; import { version, lang, updateLocale, locale } from '@@/js/config.js';
import type { App } from 'vue';
import widgets from '@/widgets/index.js'; import widgets from '@/widgets/index.js';
import directives from '@/directives/index.js'; import directives from '@/directives/index.js';
import components from '@/components/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 { getUrlWithoutLoginId } from '@/scripts/login-id.js';
import { getAccountFromId } from '@/scripts/get-account-from-id.js'; import { getAccountFromId } from '@/scripts/get-account-from-id.js';
import { deckStore } from '@/ui/deck/deck-store.js'; import { deckStore } from '@/ui/deck/deck-store.js';
import { analytics, initAnalytics } from '@/analytics.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import { fetchCustomEmojis } from '@/custom-emojis.js'; import { fetchCustomEmojis } from '@/custom-emojis.js';
import { setupRouter } from '@/router/main.js'; import { setupRouter } from '@/router/main.js';
@ -241,6 +242,19 @@ export async function common(createVue: () => App<Element>) {
await fetchCustomEmojis(); await fetchCustomEmojis();
} catch (err) { /* empty */ } } 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(); const app = createVue();
setupRouter(app, createMainRouter); setupRouter(app, createMainRouter);

View File

@ -32,6 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue'; import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue';
import { url } from '@@/js/config.js'; import { url } from '@@/js/config.js';
import { getScrollContainer } from '@@/js/scroll.js'; import { getScrollContainer } from '@@/js/scroll.js';
import type { PageMetadata } from '@/scripts/page-metadata.js';
import RouterView from '@/components/global/RouterView.vue'; import RouterView from '@/components/global/RouterView.vue';
import MkWindow from '@/components/MkWindow.vue'; import MkWindow from '@/components/MkWindow.vue';
import { popout as _popout } from '@/scripts/popout.js'; 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 { useScrollPositionManager } from '@/nirax.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
import type { PageMetadata } from '@/scripts/page-metadata.js';
import { openingWindowsCount } from '@/os.js'; import { openingWindowsCount } from '@/os.js';
import { claimAchievement } from '@/scripts/achievements.js'; import { claimAchievement } from '@/scripts/achievements.js';
import { useRouterFactory } from '@/router/supplier.js'; import { useRouterFactory } from '@/router/supplier.js';
import { mainRouter } from '@/router/main.js'; import { mainRouter } from '@/router/main.js';
import { analytics } from '@/analytics.js';
const props = defineProps<{ const props = defineProps<{
initialPath: string; initialPath: string;
@ -99,6 +100,14 @@ windowRouter.addListener('replace', ctx => {
history.value.push({ path: ctx.path, key: ctx.key }); 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(); windowRouter.init();
provide('router', windowRouter); provide('router', windowRouter);
@ -160,6 +169,11 @@ function popout() {
useScrollPositionManager(() => getScrollContainer(contents.value), windowRouter); useScrollPositionManager(() => getScrollContainer(contents.value), windowRouter);
onMounted(() => { onMounted(() => {
analytics.page({
path: props.initialPath,
title: props.initialPath,
});
openingWindowsCount.value++; openingWindowsCount.value++;
if (openingWindowsCount.value >= 3) { if (openingWindowsCount.value >= 3) {
claimAchievement('open3windows'); claimAchievement('open3windows');

View File

@ -65,7 +65,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo> <MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
<input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd"> <div v-show="useCw" :class="$style.cwOuter">
<input ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd">
<div v-if="maxCwTextLength - cwTextLength < 20" :class="['_acrylic', $style.cwTextCount, { [$style.cwTextOver]: cwTextLength > maxCwTextLength }]">{{ maxCwTextLength - cwTextLength }}</div>
</div>
<div :class="[$style.textOuter, { [$style.withCw]: useCw }]"> <div :class="[$style.textOuter, { [$style.withCw]: useCw }]">
<div v-if="channel" :class="$style.colorBar" :style="{ background: channel.color }"></div> <div v-if="channel" :class="$style.colorBar" :style="{ background: channel.color }"></div>
<textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @keyup="onKeyup" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/> <textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @keyup="onKeyup" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
@ -244,6 +247,12 @@ const maxTextLength = computed((): number => {
return instance ? instance.maxNoteTextLength : 1000; return instance ? instance.maxNoteTextLength : 1000;
}); });
const cwTextLength = computed((): number => {
return cw.value?.length ?? 0;
});
const maxCwTextLength = 100;
const canPost = computed((): boolean => { const canPost = computed((): boolean => {
return !props.mock && !posting.value && !posted.value && return !props.mock && !posting.value && !posted.value &&
( (
@ -254,6 +263,7 @@ const canPost = computed((): boolean => {
quoteId.value != null quoteId.value != null
) && ) &&
(textLength.value <= maxTextLength.value) && (textLength.value <= maxTextLength.value) &&
(cwTextLength.value <= maxCwTextLength) &&
(files.value.length <= 16) && (files.value.length <= 16) &&
(!poll.value || poll.value.choices.length >= 2); (!poll.value || poll.value.choices.length >= 2);
}); });
@ -1273,12 +1283,34 @@ html[data-color-scheme=light] .preview {
} }
} }
.cwOuter {
width: 100%;
position: relative;
}
.cw { .cw {
z-index: 1; z-index: 1;
padding-bottom: 8px; padding-bottom: 8px;
border-bottom: solid 0.5px var(--MI_THEME-divider); border-bottom: solid 0.5px var(--MI_THEME-divider);
} }
.cwTextCount {
position: absolute;
top: 0;
right: 2px;
padding: 2px 6px;
font-size: .9em;
color: var(--MI_THEME-warn);
border-radius: 6px;
max-width: 100%;
min-width: 1.6em;
text-align: center;
&.cwTextOver {
color: #ff2a2a;
}
}
.hashtags { .hashtags {
z-index: 1; z-index: 1;
padding-top: 8px; padding-top: 8px;

View File

@ -15,9 +15,9 @@ export type MkABehavior = 'window' | 'browser' | null;
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, shallowRef } from 'vue'; import { computed, inject, shallowRef } from 'vue';
import { url } from '@@/js/config.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { url } from '@@/js/config.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { useRouter } from '@/router/supplier.js'; import { useRouter } from '@/router/supplier.js';

View File

@ -6,8 +6,8 @@
// NIRAX --- A lightweight router // NIRAX --- A lightweight router
import { onMounted, shallowRef } from 'vue'; import { onMounted, shallowRef } from 'vue';
import type { Component, ShallowRef } from 'vue';
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from 'eventemitter3';
import type { Component, ShallowRef } from 'vue';
function safeURIDecode(str: string): string { function safeURIDecode(str: string): string {
try { try {
@ -242,8 +242,6 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter {
hash, hash,
}; };
if (_DEV_) console.log('Routing: ', path, queryString);
function check(routes: RouteDef[], _parts: string[]): Resolved | null { function check(routes: RouteDef[], _parts: string[]): Resolved | null {
forEachRouteLoop: forEachRouteLoop:
for (const route of routes) { for (const route of routes) {

View File

@ -8,20 +8,34 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init"> <FormSuspense :p="init">
<MkFolder> <div class="_gaps_m">
<template #label>DeepL Translation</template> <MkFolder>
<template #label>Google Analytics<span class="_beta">{{ i18n.ts.beta }}</span></template>
<div class="_gaps_m"> <div class="_gaps_m">
<MkInput v-model="deeplAuthKey"> <MkInput v-model="googleAnalyticsMeasurementId">
<template #prefix><i class="ti ti-key"></i></template> <template #prefix><i class="ti ti-key"></i></template>
<template #label>DeepL Auth Key</template> <template #label>Measurement ID</template>
</MkInput> </MkInput>
<MkSwitch v-model="deeplIsPro"> <MkButton primary @click="save_googleAnalytics">Save</MkButton>
<template #label>Pro account</template> </div>
</MkSwitch> </MkFolder>
<MkButton primary @click="save_deepl">Save</MkButton>
</div> <MkFolder>
</MkFolder> <template #label>DeepL Translation</template>
<div class="_gaps_m">
<MkInput v-model="deeplAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>DeepL Auth Key</template>
</MkInput>
<MkSwitch v-model="deeplIsPro">
<template #label>Pro account</template>
</MkSwitch>
<MkButton primary @click="save_deepl">Save</MkButton>
</div>
</MkFolder>
</div>
</FormSuspense> </FormSuspense>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
@ -44,10 +58,13 @@ import MkFolder from '@/components/MkFolder.vue';
const deeplAuthKey = ref<string>(''); const deeplAuthKey = ref<string>('');
const deeplIsPro = ref<boolean>(false); const deeplIsPro = ref<boolean>(false);
const googleAnalyticsMeasurementId = ref<string>('');
async function init() { async function init() {
const meta = await misskeyApi('admin/meta'); const meta = await misskeyApi('admin/meta');
deeplAuthKey.value = meta.deeplAuthKey; deeplAuthKey.value = meta.deeplAuthKey ?? '';
deeplIsPro.value = meta.deeplIsPro; deeplIsPro.value = meta.deeplIsPro;
googleAnalyticsMeasurementId.value = meta.googleAnalyticsMeasurementId ?? '';
} }
function save_deepl() { function save_deepl() {
@ -59,6 +76,14 @@ function save_deepl() {
}); });
} }
function save_googleAnalytics() {
os.apiWithDialog('admin/update-meta', {
googleAnalyticsMeasurementId: googleAnalyticsMeasurementId.value,
}).then(() => {
fetchInstance(true);
});
}
const headerActions = computed(() => []); const headerActions = computed(() => []);
const headerTabs = computed(() => []); const headerTabs = computed(() => []);

View File

@ -6,8 +6,8 @@
// PIZZAX --- A lightweight store // PIZZAX --- A lightweight store
import { onUnmounted, ref, watch } from 'vue'; import { onUnmounted, ref, watch } from 'vue';
import type { Ref } from 'vue';
import { BroadcastChannel } from 'broadcast-channel'; import { BroadcastChannel } from 'broadcast-channel';
import type { Ref } from 'vue';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { get, set } from '@/scripts/idb-proxy.js'; import { get, set } from '@/scripts/idb-proxy.js';
@ -113,7 +113,6 @@ export class Storage<T extends StateDef> {
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceAccountState[k], v.default); this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceAccountState[k], v.default);
} else { } else {
this.reactiveState[k].value = this.state[k] = v.default; this.reactiveState[k].value = this.state[k] = v.default;
if (_DEV_) console.log('Use default value', k, v.default);
} }
} }
@ -180,12 +179,9 @@ export class Storage<T extends StateDef> {
// (JSON.parse(JSON.stringify(value))の代わり) // (JSON.parse(JSON.stringify(value))の代わり)
const rawValue = deepClone(value); const rawValue = deepClone(value);
if (_DEV_) console.log('set', key, rawValue, value);
this.reactiveState[key].value = this.state[key] = rawValue; this.reactiveState[key].value = this.state[key] = rawValue;
return this.addIdbSetJob(async () => { return this.addIdbSetJob(async () => {
if (_DEV_) console.log(`set ${String(key)} start`);
switch (this.def[key].where) { switch (this.def[key].where) {
case 'device': { case 'device': {
this.pizzaxChannel.postMessage({ this.pizzaxChannel.postMessage({
@ -224,7 +220,6 @@ export class Storage<T extends StateDef> {
break; break;
} }
} }
if (_DEV_) console.log(`set ${String(key)} complete`);
}); });
} }
@ -247,9 +242,9 @@ export class Storage<T extends StateDef> {
getter?: (v: T[K]['default']) => R, getter?: (v: T[K]['default']) => R,
setter?: (v: R) => T[K]['default'], setter?: (v: R) => T[K]['default'],
): { ): {
get: () => R; get: () => R;
set: (value: R) => void; set: (value: R) => void;
} { } {
const valueRef = ref(this.state[key]); const valueRef = ref(this.state[key]);
const stop = watch(this.reactiveState[key], val => { const stop = watch(this.reactiveState[key], val => {

View File

@ -7,6 +7,7 @@ import { EventEmitter } from 'eventemitter3';
import type { IRouter, Resolved, RouteDef, RouterEvent, RouterFlag } from '@/nirax.js'; import type { IRouter, Resolved, RouteDef, RouterEvent, RouterFlag } from '@/nirax.js';
import type { App, ShallowRef } from 'vue'; import type { App, ShallowRef } from 'vue';
import { analytics } from '@/analytics.js';
/** /**
* {@link Router}{@link mainRouter} * {@link Router}{@link mainRouter}
@ -29,6 +30,14 @@ export function setupRouter(app: App, routerFactory: ((path: string) => IRouter)
window.history.replaceState({ key: ctx.key }, '', ctx.path); window.history.replaceState({ key: ctx.key }, '', ctx.path);
}); });
mainRouter.addListener('change', ctx => {
console.log('mainRouter: change', ctx.path);
analytics.page({
path: ctx.path,
title: ctx.path,
});
});
mainRouter.init(); mainRouter.init();
setMainRouter(mainRouter); setMainRouter(mainRouter);

View File

@ -54,10 +54,6 @@ export async function lookup(router?: Router) {
title = i18n.ts._remoteLookupErrors._responseInvalid.title; title = i18n.ts._remoteLookupErrors._responseInvalid.title;
text = i18n.ts._remoteLookupErrors._responseInvalid.description; text = i18n.ts._remoteLookupErrors._responseInvalid.description;
break; break;
case 'a2c9c61a-cb72-43ab-a964-3ca5fddb410a':
title = i18n.ts._remoteLookupErrors._responseInvalid.title;
text = i18n.ts._remoteLookupErrors._responseInvalidIdHostNotMatch.description;
break;
case 'dc94d745-1262-4e63-a17d-fecaa57efc82': case 'dc94d745-1262-4e63-a17d-fecaa57efc82':
title = i18n.ts._remoteLookupErrors._noSuchObject.title; title = i18n.ts._remoteLookupErrors._noSuchObject.title;
text = i18n.ts._remoteLookupErrors._noSuchObject.description; text = i18n.ts._remoteLookupErrors._noSuchObject.description;

View File

@ -24,9 +24,9 @@
"devDependencies": { "devDependencies": {
"@types/matter-js": "0.19.8", "@types/matter-js": "0.19.8",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@types/node": "22.13.4", "@types/node": "22.13.5",
"@typescript-eslint/eslint-plugin": "8.24.0", "@typescript-eslint/eslint-plugin": "8.24.1",
"@typescript-eslint/parser": "8.24.0", "@typescript-eslint/parser": "8.24.1",
"nodemon": "3.1.9", "nodemon": "3.1.9",
"execa": "9.5.2", "execa": "9.5.2",
"typescript": "5.7.3", "typescript": "5.7.3",

View File

@ -8,13 +8,13 @@
}, },
"devDependencies": { "devDependencies": {
"@readme/openapi-parser": "2.7.0", "@readme/openapi-parser": "2.7.0",
"@types/node": "22.13.4", "@types/node": "22.13.5",
"@typescript-eslint/eslint-plugin": "8.24.0", "@typescript-eslint/eslint-plugin": "8.24.1",
"@typescript-eslint/parser": "8.24.0", "@typescript-eslint/parser": "8.24.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"openapi-typescript": "6.7.6", "openapi-typescript": "6.7.6",
"ts-case-convert": "2.1.0", "ts-case-convert": "2.1.0",
"tsx": "4.19.2", "tsx": "4.19.3",
"typescript": "5.7.3" "typescript": "5.7.3"
}, },
"files": [ "files": [

View File

@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "misskey-js", "name": "misskey-js",
"version": "2025.2.0", "version": "2025.2.1-beta.1",
"description": "Misskey SDK for JavaScript", "description": "Misskey SDK for JavaScript",
"license": "MIT", "license": "MIT",
"main": "./built/index.js", "main": "./built/index.js",
@ -35,12 +35,12 @@
"directory": "packages/misskey-js" "directory": "packages/misskey-js"
}, },
"devDependencies": { "devDependencies": {
"@microsoft/api-extractor": "7.50.0", "@microsoft/api-extractor": "7.50.1",
"@swc/jest": "0.2.37", "@swc/jest": "0.2.37",
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
"@types/node": "22.13.4", "@types/node": "22.13.5",
"@typescript-eslint/eslint-plugin": "8.24.0", "@typescript-eslint/eslint-plugin": "8.24.1",
"@typescript-eslint/parser": "8.24.0", "@typescript-eslint/parser": "8.24.1",
"jest": "29.7.0", "jest": "29.7.0",
"jest-fetch-mock": "3.0.3", "jest-fetch-mock": "3.0.3",
"jest-websocket-mock": "2.5.0", "jest-websocket-mock": "2.5.0",

View File

@ -5042,6 +5042,7 @@ export type components = {
enableTurnstile: boolean; enableTurnstile: boolean;
turnstileSiteKey: string | null; turnstileSiteKey: string | null;
enableTestcaptcha: boolean; enableTestcaptcha: boolean;
googleAnalyticsMeasurementId: string | null;
swPublickey: string | null; swPublickey: string | null;
/** @default /assets/ai.png */ /** @default /assets/ai.png */
mascotImageUrl: string; mascotImageUrl: string;
@ -8251,6 +8252,7 @@ export type operations = {
enableTurnstile: boolean; enableTurnstile: boolean;
turnstileSiteKey: string | null; turnstileSiteKey: string | null;
enableTestcaptcha: boolean; enableTestcaptcha: boolean;
googleAnalyticsMeasurementId: string | null;
swPublickey: string | null; swPublickey: string | null;
/** @default /assets/ai.png */ /** @default /assets/ai.png */
mascotImageUrl: string | null; mascotImageUrl: string | null;
@ -10617,6 +10619,7 @@ export type operations = {
turnstileSiteKey?: string | null; turnstileSiteKey?: string | null;
turnstileSecretKey?: string | null; turnstileSecretKey?: string | null;
enableTestcaptcha?: boolean; enableTestcaptcha?: boolean;
googleAnalyticsMeasurementId?: string | null;
/** @enum {string} */ /** @enum {string} */
sensitiveMediaDetection?: 'none' | 'all' | 'local' | 'remote'; sensitiveMediaDetection?: 'none' | 'all' | 'local' | 'remote';
/** @enum {string} */ /** @enum {string} */

View File

@ -22,9 +22,9 @@
"lint": "pnpm typecheck && pnpm eslint" "lint": "pnpm typecheck && pnpm eslint"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "22.13.4", "@types/node": "22.13.5",
"@typescript-eslint/eslint-plugin": "8.24.0", "@typescript-eslint/eslint-plugin": "8.24.1",
"@typescript-eslint/parser": "8.24.0", "@typescript-eslint/parser": "8.24.1",
"execa": "9.5.2", "execa": "9.5.2",
"nodemon": "3.1.9", "nodemon": "3.1.9",
"typescript": "5.7.3", "typescript": "5.7.3",

View File

@ -14,7 +14,7 @@
"misskey-js": "workspace:*" "misskey-js": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/parser": "8.24.0", "@typescript-eslint/parser": "8.24.1",
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.74", "@typescript/lib-webworker": "npm:@types/serviceworker@0.0.74",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.31.0",
"nodemon": "3.1.9", "nodemon": "3.1.9",

File diff suppressed because it is too large Load Diff

View File

@ -1,85 +1,85 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", $schema: 'https://docs.renovatebot.com/renovate-schema.json',
"extends": [ extends: [
"config:recommended" 'config:recommended',
], ],
"timezone": "Asia/Tokyo", timezone: 'Asia/Tokyo',
"schedule": [ schedule: [
"* 0 * * *" '* 0 * * *',
], ],
"prHourlyLimit": 5, prHourlyLimit: 5,
"dependencyDashboardApproval": true, dependencyDashboardApproval: true,
"dependencyDashboardAutoclose": true, dependencyDashboardAutoclose: true,
"osvVulnerabilityAlerts": true, osvVulnerabilityAlerts: true,
"dependencyDashboardOSVVulnerabilitySummary": "unresolved", dependencyDashboardOSVVulnerabilitySummary: 'unresolved',
"ignoreDeps": [ ignoreDeps: [
// https://github.com/misskey-dev/misskey/pull/15489#issuecomment-2660717458 // https://github.com/misskey-dev/misskey/pull/15489#issuecomment-2660717458
"@typescript/lib-webworker", '@typescript/lib-webworker',
// https://github.com/misskey-dev/misskey/pull/15494#issuecomment-2660775258 // https://github.com/misskey-dev/misskey/pull/15494#issuecomment-2660775258
"nsfwjs", 'nsfwjs',
], ],
"packageRules": [ packageRules: [
{ {
"groupName": "[Backend] Update dependencies", groupName: '[Backend] Update dependencies',
"matchPaths": [ matchFileNames: [
"packages/backend/**/package.json" 'packages/backend/**/package.json',
] ],
}, },
{ {
"groupName": "[Frontend] Update dependencies", groupName: '[Frontend] Update dependencies',
"matchPaths": [ matchFileNames: [
"packages/frontend/**/package.json", 'packages/frontend/**/package.json',
"packages/frontend-embed/**/package.json", 'packages/frontend-embed/**/package.json',
"packages/frontend-shared/**/package.json", 'packages/frontend-shared/**/package.json',
"packages/misskey-bubble-game/**/package.json", 'packages/misskey-bubble-game/**/package.json',
"packages/misskey-reversi/**/package.json", 'packages/misskey-reversi/**/package.json',
"packages/sw/**/package.json" 'packages/sw/**/package.json',
] ],
}, },
{ {
"groupName": "[misskey-js] Update dependencies", groupName: '[misskey-js] Update dependencies',
"matchPaths": [ matchFileNames: [
"packages/misskey-js/**/package.json" 'packages/misskey-js/**/package.json',
] ],
}, },
{ {
"groupName": "[Root] Update dependencies", groupName: '[Root] Update dependencies',
"matchPaths": [ matchFileNames: [
"package.json" 'package.json',
] ],
}, },
{ {
"groupName": "[Tools] Update dependencies", groupName: '[Tools] Update dependencies',
"matchPaths": [ matchFileNames: [
"scripts/**/package.json" 'scripts/**/package.json',
] ],
}, },
{ {
"groupName": "[GitHub Actions] Update dependencies", groupName: '[GitHub Actions] Update dependencies',
"matchPaths": [ matchFileNames: [
".github/workflows/**/*.yml" '.github/workflows/**/*.yml',
] ],
}, },
{ {
"groupName": "[Node.js] Update dependencies", groupName: '[Node.js] Update dependencies',
"matchPaths": [ matchFileNames: [
".node-version" '.node-version',
] ],
}, },
{ {
"groupName": "[Docker] Update dependencies", groupName: '[Docker] Update dependencies',
"matchPaths": [ matchFileNames: [
"compose.local-db.yml", 'compose.local-db.yml',
"compose_example.yml", 'compose_example.yml',
"packages/backend/test-federation/*.yml", 'packages/backend/test-federation/*.yml',
"Dockerfile" 'Dockerfile',
] ],
}, },
{ {
"groupName": "[devcontainer] Update dependencies", groupName: '[devcontainer] Update dependencies',
"matchPaths": [ matchFileNames: [
".devcontainer/**" '.devcontainer/**',
] ],
} },
] ],
} }

View File

@ -60,7 +60,8 @@ async function buildBackendScript() {
'./packages/backend/src/server/web/boot.js', './packages/backend/src/server/web/boot.js',
'./packages/backend/src/server/web/boot.embed.js', './packages/backend/src/server/web/boot.embed.js',
'./packages/backend/src/server/web/bios.js', './packages/backend/src/server/web/bios.js',
'./packages/backend/src/server/web/cli.js' './packages/backend/src/server/web/cli.js',
'./packages/backend/src/server/web/error.js',
]) { ]) {
let source = await fs.readFile(file, { encoding: 'utf-8' }); let source = await fs.readFile(file, { encoding: 'utf-8' });
source = source.replaceAll('LANGS', JSON.stringify(Object.keys(locales))); source = source.replaceAll('LANGS', JSON.stringify(Object.keys(locales)));

File diff suppressed because it is too large Load Diff

View File

@ -10,15 +10,15 @@
}, },
"devDependencies": { "devDependencies": {
"@types/mdast": "4.0.4", "@types/mdast": "4.0.4",
"@types/node": "22.13.4", "@types/node": "22.13.5",
"@vitest/coverage-v8": "1.6.1", "@vitest/coverage-v8": "3.0.6",
"mdast-util-to-string": "4.0.0", "mdast-util-to-string": "4.0.0",
"remark": "15.0.1", "remark": "15.0.1",
"remark-parse": "11.0.0", "remark-parse": "11.0.0",
"typescript": "5.7.3", "typescript": "5.7.3",
"unified": "11.0.5", "unified": "11.0.5",
"vite": "5.4.14", "vite": "6.1.1",
"vite-node": "1.6.1", "vite-node": "3.0.6",
"vitest": "1.6.1" "vitest": "3.0.6"
} }
} }