From 04b221409cac8e54e9f392b1a50094799c866441 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sat, 23 Nov 2024 04:44:33 +0900 Subject: fix(backend): use atomic command to improve security --- packages/backend/src/core/WebAuthnService.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) (limited to 'packages/backend/src/core') diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts index ad53192f18..ed75e4f467 100644 --- a/packages/backend/src/core/WebAuthnService.ts +++ b/packages/backend/src/core/WebAuthnService.ts @@ -189,14 +189,12 @@ export class WebAuthnService { */ @bindThis public async verifySignInWithPasskeyAuthentication(context: string, response: AuthenticationResponseJSON): Promise { - const challenge = await this.redisClient.get(`webauthn:challenge:${context}`); + const challenge = await this.redisClient.getdel(`webauthn:challenge:${context}`); if (!challenge) { throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', `challenge '${context}' not found`); } - await this.redisClient.del(`webauthn:challenge:${context}`); - const key = await this.userSecurityKeysRepository.findOneBy({ id: response.id, }); -- cgit v1.2.3-freya From dd56623cded7cc7a355d1dc59e46e2d8f1ed3660 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Sun, 24 Nov 2024 20:44:59 +0900 Subject: fix: unable to upload to local object storage (#15040) --- packages/backend/src/core/S3Service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/core') diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts index bb2a463354..37721d2bf1 100644 --- a/packages/backend/src/core/S3Service.ts +++ b/packages/backend/src/core/S3Service.ts @@ -28,7 +28,7 @@ export class S3Service { ? `${meta.objectStorageUseSSL ? 'https' : 'http'}://${meta.objectStorageEndpoint}` : `${meta.objectStorageUseSSL ? 'https' : 'http'}://example.net`; // dummy url to select http(s) agent - const agent = this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy); + const agent = this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy, true); const handlerOption: NodeHttpHandlerOptions = {}; if (meta.objectStorageUseSSL) { handlerOption.httpsAgent = agent as https.Agent; -- cgit v1.2.3-freya From f123be38b93339f405468c8ed1aaa39d340b7791 Mon Sep 17 00:00:00 2001 From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Thu, 19 Dec 2024 16:05:33 +0900 Subject: enhance(frontend): 照会の際にエラーを表示するように (#15147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance: 照会の失敗理由を表示するように * Update Changelog * fix * fix test * lookupErrors-> remoteLookupErrors --- CHANGELOG.md | 1 + locales/index.d.ts | 59 ++++++++++++++++++++ locales/ja-JP.yml | 19 +++++++ .../src/core/activitypub/ApResolverService.ts | 25 +++++---- .../backend/src/server/api/endpoints/ap/show.ts | 65 +++++++++++++++++++++- packages/backend/test-federation/test/note.test.ts | 6 +- packages/frontend/src/scripts/lookup.ts | 38 ++++++++++++- 7 files changed, 193 insertions(+), 20 deletions(-) (limited to 'packages/backend/src/core') diff --git a/CHANGELOG.md b/CHANGELOG.md index ee82834de7..fd56700e1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Client - Enhance: PC画面でチャンネルが複数列で表示されるように (Cherry-picked from https://github.com/Otaku-Social/maniakey/pull/13) +- Enhance: 照会に失敗した場合、その理由を表示するように - Fix: 画面サイズが変わった際にナビゲーションバーが自動で折りたたまれない問題を修正 - Fix: サーバー情報メニューに区切り線が不足していたのを修正 - Fix: ノートがログインしているユーザーしか見れない場合にログインダイアログを閉じるとその後の動線がなくなる問題を修正 diff --git a/locales/index.d.ts b/locales/index.d.ts index 0ae188f1f7..63878d3d47 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -10601,6 +10601,65 @@ export interface Locale extends ILocale { */ "sent": string; }; + "_remoteLookupErrors": { + "_federationNotAllowed": { + /** + * このサーバーとは通信できません + */ + "title": string; + /** + * このサーバーとの通信が無効化されているか、このサーバーをブロックしている・ブロックされている可能性があります。 + * サーバー管理者にお問い合わせください。 + */ + "description": string; + }; + "_uriInvalid": { + /** + * URIが不正です + */ + "title": string; + /** + * 入力されたURIに問題があります。URIに使用できない文字を入力していないか確認してください。 + */ + "description": string; + }; + "_requestFailed": { + /** + * リクエストに失敗しました + */ + "title": string; + /** + * このサーバーとの通信に失敗しました。相手サーバーがダウンしている可能性があります。また、不正なURIや存在しないURIを入力していないか確認してください。 + */ + "description": string; + }; + "_responseInvalid": { + /** + * レスポンスが不正です + */ + "title": string; + /** + * このサーバーと通信することはできましたが、得られたデータが不正なものでした。 + */ + "description": string; + }; + "_responseInvalidIdHostNotMatch": { + /** + * 入力されたURIのドメインと最終的に得られたURIのドメインとが異なります。第三者のサーバーを介してリモートのコンテンツを照会している場合は、発信元のサーバーで取得できるURIを使用して照会し直してください。 + */ + "description": string; + }; + "_noSuchObject": { + /** + * 見つかりません + */ + "title": string; + /** + * 要求されたリソースは見つかりませんでした。URIをもう一度お確かめください。 + */ + "description": string; + }; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 1b59708d85..d78bd4ee65 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2826,3 +2826,22 @@ _selfXssPrevention: _followRequest: recieved: "受け取った申請" sent: "送った申請" + +_remoteLookupErrors: + _federationNotAllowed: + title: "このサーバーとは通信できません" + description: "このサーバーとの通信が無効化されているか、このサーバーをブロックしている・ブロックされている可能性があります。\nサーバー管理者にお問い合わせください。" + _uriInvalid: + title: "URIが不正です" + description: "入力されたURIに問題があります。URIに使用できない文字を入力していないか確認してください。" + _requestFailed: + title: "リクエストに失敗しました" + description: "このサーバーとの通信に失敗しました。相手サーバーがダウンしている可能性があります。また、不正なURIや存在しないURIを入力していないか確認してください。" + _responseInvalid: + title: "レスポンスが不正です" + description: "このサーバーと通信することはできましたが、得られたデータが不正なものでした。" + _responseInvalidIdHostNotMatch: + description: "入力されたURIのドメインと最終的に得られたURIのドメインとが異なります。第三者のサーバーを介してリモートのコンテンツを照会している場合は、発信元のサーバーで取得できるURIを使用して照会し直してください。" + _noSuchObject: + title: "見つかりません" + description: "要求されたリソースは見つかりませんでした。URIをもう一度お確かめください。" diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index b0b35274ea..52cc569140 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -20,6 +20,7 @@ import { ApDbResolverService } from './ApDbResolverService.js'; import { ApRendererService } from './ApRendererService.js'; import { ApRequestService } from './ApRequestService.js'; import type { IObject, ICollection, IOrderedCollection } from './type.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; export class Resolver { private history: Set; @@ -66,7 +67,7 @@ export class Resolver { if (isCollectionOrOrderedCollection(collection)) { return collection; } else { - throw new Error(`unrecognized collection type: ${collection.type}`); + throw new IdentifiableError('f100eccf-f347-43fb-9b45-96a0831fb635', `unrecognized collection type: ${collection.type}`); } } @@ -80,15 +81,15 @@ export class Resolver { // URLs with fragment parts cannot be resolved correctly because // the fragment part does not get transmitted over HTTP(S). // Avoid strange behaviour by not trying to resolve these at all. - throw new Error(`cannot resolve URL with fragment: ${value}`); + throw new IdentifiableError('b94fd5b1-0e3b-4678-9df2-dad4cd515ab2', `cannot resolve URL with fragment: ${value}`); } if (this.history.has(value)) { - throw new Error('cannot resolve already resolved one'); + throw new IdentifiableError('0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5', 'cannot resolve already resolved one'); } if (this.history.size > this.recursionLimit) { - throw new Error(`hit recursion limit: ${this.utilityService.extractDbHost(value)}`); + throw new IdentifiableError('d592da9f-822f-4d91-83d7-4ceefabcf3d2', `hit recursion limit: ${this.utilityService.extractDbHost(value)}`); } this.history.add(value); @@ -99,7 +100,7 @@ export class Resolver { } if (!this.utilityService.isFederationAllowedHost(host)) { - throw new Error('Instance is blocked'); + throw new IdentifiableError('09d79f9e-64f1-4316-9cfa-e75c4d091574', 'Instance is blocked'); } if (this.config.signToActivityPubGet && !this.user) { @@ -115,7 +116,7 @@ export class Resolver { !(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') : object['@context'] !== 'https://www.w3.org/ns/activitystreams' ) { - throw new Error('invalid response'); + throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', 'invalid response'); } // HttpRequestService / ApRequestService have already checked that @@ -123,11 +124,11 @@ export class Resolver { // object after redirects; here we double-check that no redirects // bounced between hosts if (object.id == null) { - throw new Error('invalid AP object: missing id'); + 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 Error(`invalid AP object ${value}: id ${object.id} has different host`); + throw new IdentifiableError('fd93c2fa-69a8-440f-880b-bf178e0ec877', `invalid AP object ${value}: id ${object.id} has different host`); } return object; @@ -136,7 +137,7 @@ export class Resolver { @bindThis private resolveLocal(url: string): Promise { const parsed = this.apDbResolverService.parseUri(url); - if (!parsed.local) throw new Error('resolveLocal: not local'); + if (!parsed.local) throw new IdentifiableError('02b40cd0-fa92-4b0c-acc9-fb2ada952ab8', 'resolveLocal: not local'); switch (parsed.type) { case 'notes': @@ -165,7 +166,7 @@ export class Resolver { case 'follows': return this.followRequestsRepository.findOneBy({ id: parsed.id }) .then(async followRequest => { - if (followRequest == null) throw new Error('resolveLocal: invalid follow request ID'); + if (followRequest == null) throw new IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', 'resolveLocal: invalid follow request ID'); const [follower, followee] = await Promise.all([ this.usersRepository.findOneBy({ id: followRequest.followerId, @@ -177,12 +178,12 @@ export class Resolver { }), ]); if (follower == null || followee == null) { - throw new Error('resolveLocal: follower or followee does not exist'); + throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', 'resolveLocal: follower or followee does not exist'); } return this.apRendererService.addContext(this.apRendererService.renderFollow(follower as MiLocalUser | MiRemoteUser, followee as MiLocalUser | MiRemoteUser, url)); }); default: - throw new Error(`resolveLocal: type ${parsed.type} unhandled`); + throw new IdentifiableError('7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0', `resolveLocal: type ${parsed.type} unhandled`); } } } diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 24d5a7b0f1..5c2e82da88 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -19,6 +19,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { ApiError } from '../../error.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; export const meta = { tags: ['federation'], @@ -32,6 +33,31 @@ export const meta = { }, errors: { + federationNotAllowed: { + message: 'Federation for this host is not allowed.', + code: 'FEDERATION_NOT_ALLOWED', + id: '974b799e-1a29-4889-b706-18d4dd93e266', + }, + uriInvalid: { + message: 'URI is invalid.', + code: 'URI_INVALID', + id: '1a5eab56-e47b-48c2-8d5e-217b897d70db', + }, + requestFailed: { + message: 'Request failed.', + code: 'REQUEST_FAILED', + id: '81b539cf-4f57-4b29-bc98-032c33c0792e', + }, + responseInvalid: { + message: 'Response from remote server is invalid.', + code: 'RESPONSE_INVALID', + id: '70193c39-54f3-4813-82f0-70a680f7495b', + }, + responseInvalidIdHostNotMatch: { + message: 'Requested URI and response URI host does not match.', + code: 'RESPONSE_INVALID_ID_HOST_NOT_MATCH', + id: 'a2c9c61a-cb72-43ab-a964-3ca5fddb410a', + }, noSuchObject: { message: 'No such object.', code: 'NO_SUCH_OBJECT', @@ -110,7 +136,9 @@ export default class extends Endpoint { // eslint- */ @bindThis private async fetchAny(uri: string, me: MiLocalUser | null | undefined): Promise | null> { - if (!this.utilityService.isFederationAllowedUri(uri)) return null; + if (!this.utilityService.isFederationAllowedUri(uri)) { + throw new ApiError(meta.errors.federationNotAllowed); + } let local = await this.mergePack(me, ...await Promise.all([ this.apDbResolverService.getUserFromApId(uri), @@ -125,7 +153,40 @@ export default class extends Endpoint { // eslint- // リモートから一旦オブジェクトフェッチ const resolver = this.apResolverService.createResolver(); - const object = await resolver.resolve(uri) as any; + const object = await resolver.resolve(uri).catch((err) => { + if (err instanceof IdentifiableError) { + switch (err.id) { + // resolve + case 'b94fd5b1-0e3b-4678-9df2-dad4cd515ab2': + throw new ApiError(meta.errors.uriInvalid); + case '0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5': + case 'd592da9f-822f-4d91-83d7-4ceefabcf3d2': + throw new ApiError(meta.errors.requestFailed); + case '09d79f9e-64f1-4316-9cfa-e75c4d091574': + throw new ApiError(meta.errors.federationNotAllowed); + case '72180409-793c-4973-868e-5a118eb5519b': + case 'ad2dc287-75c1-44c4-839d-3d2e64576675': + throw new ApiError(meta.errors.responseInvalid); + case 'fd93c2fa-69a8-440f-880b-bf178e0ec877': + throw new ApiError(meta.errors.responseInvalidIdHostNotMatch); + + // resolveLocal + case '02b40cd0-fa92-4b0c-acc9-fb2ada952ab8': + throw new ApiError(meta.errors.uriInvalid); + case 'a9d946e5-d276-47f8-95fb-f04230289bb0': + case '06ae3170-1796-4d93-a697-2611ea6d83b6': + throw new ApiError(meta.errors.noSuchObject); + case '7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0': + throw new ApiError(meta.errors.responseInvalid); + } + } + + throw new ApiError(meta.errors.requestFailed); + }); + + if (object.id == null) { + throw new ApiError(meta.errors.responseInvalid); + } // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する // これはDBに存在する可能性があるため再度DB検索 diff --git a/packages/backend/test-federation/test/note.test.ts b/packages/backend/test-federation/test/note.test.ts index bacc4cc54f..220c22e198 100644 --- a/packages/backend/test-federation/test/note.test.ts +++ b/packages/backend/test-federation/test/note.test.ts @@ -131,11 +131,7 @@ describe('Note', () => { rejects( async () => await bob.client.request('ap/show', { uri: `https://a.test/notes/${note.id}` }), (err: any) => { - /** - * FIXME: this error is not handled - * @see https://github.com/misskey-dev/misskey/issues/12736 - */ - strictEqual(err.code, 'INTERNAL_ERROR'); + strictEqual(err.code, 'REQUEST_FAILED'); return true; }, ); diff --git a/packages/frontend/src/scripts/lookup.ts b/packages/frontend/src/scripts/lookup.ts index a261ec0669..ddcbfe1a8d 100644 --- a/packages/frontend/src/scripts/lookup.ts +++ b/packages/frontend/src/scripts/lookup.ts @@ -33,7 +33,43 @@ export async function lookup(router?: Router) { uri: query, }); - os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); + os.promiseDialog(promise, null, (err) => { + let title = i18n.ts.somethingHappened; + let text = err.message + '\n' + err.id; + + switch (err.id) { + case '974b799e-1a29-4889-b706-18d4dd93e266': + title = i18n.ts._remoteLookupErrors._federationNotAllowed.title; + text = i18n.ts._remoteLookupErrors._federationNotAllowed.description; + break; + case '1a5eab56-e47b-48c2-8d5e-217b897d70db': + title = i18n.ts._remoteLookupErrors._uriInvalid.title; + text = i18n.ts._remoteLookupErrors._uriInvalid.description; + break; + case '81b539cf-4f57-4b29-bc98-032c33c0792e': + title = i18n.ts._remoteLookupErrors._requestFailed.title; + text = i18n.ts._remoteLookupErrors._requestFailed.description; + break; + case '70193c39-54f3-4813-82f0-70a680f7495b': + title = i18n.ts._remoteLookupErrors._responseInvalid.title; + text = i18n.ts._remoteLookupErrors._responseInvalid.description; + 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': + title = i18n.ts._remoteLookupErrors._noSuchObject.title; + text = i18n.ts._remoteLookupErrors._noSuchObject.description; + break; + } + + os.alert({ + type: 'error', + title, + text, + }); + }, i18n.ts.fetchingAsApObject); const res = await promise; -- cgit v1.2.3-freya From 79b3d2a711cc659b9e6c1668b4d182906986bc50 Mon Sep 17 00:00:00 2001 From: Yuba Date: Sat, 4 Jan 2025 15:03:00 +0900 Subject: pg_bigmが利用できるよう、ノートの検索をILIKE演算子でなくLIKE演算子でLOWER()をかけたテキストに対して行うように (#15205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use LIKE-LOWER instead of ILIKE, which pg_bigm doesn't support. * changelog: Enhance: pg_bigmが利用できるよう、ノートの検索をILIKE演算子でなくLIKE演算子でLOWER()をかけたテキストに対して行うように --- CHANGELOG.md | 1 + packages/backend/src/core/SearchService.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) (limited to 'packages/backend/src/core') diff --git a/CHANGELOG.md b/CHANGELOG.md index fd56700e1f..fc0d1e89c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Fix: ユーザーのプロフィール画面をアドレス入力などで直接表示した際に概要タブの描画に失敗する問題の修正( #15032 ) - Fix: 起動前の疎通チェックが機能しなくなっていた問題を修正 (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/737) +- Enhance: pg_bigmが利用できるよう、ノートの検索をILIKE演算子でなくLIKE演算子でLOWER()をかけたテキストに対して行うように ## 2024.11.0 diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index edfc470375..f3c11277c5 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -215,7 +215,7 @@ export class SearchService { } query - .andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` }) + .andWhere('LOWER(note.text) LIKE :q', { q: `%${ sqlLikeEscape(q.toLowerCase()) }%` }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') -- cgit v1.2.3-freya From d7835313c35be8565d2cfb1a7cc724f95ddf3db7 Mon Sep 17 00:00:00 2001 From: taichan <40626578+tai-cha@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:33:08 +0900 Subject: fix(backend): ロックダウンされた期間指定のノートがStreaming経由でLTLに出現するのを修正 (#15200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(backend): skipHideなときにもロックダウンされたノートのprivate化をするように * fix linting * Update packages/backend/src/core/entities/NoteEntityService.ts * Fix: type error * Remove unneeded await * Fix: typo * Remove skipTreatVisibillity --- packages/backend/src/core/entities/NoteEntityService.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) (limited to 'packages/backend/src/core') diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 96cc6b028e..97f1c3d739 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -102,8 +102,7 @@ export class NoteEntityService implements OnModuleInit { } @bindThis - private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise { - // FIXME: このvisibility変更処理が当関数にあるのは若干不自然かもしれない(関数名を treatVisibility とかに変える手もある) + private treatVisibility(packedNote: Packed<'Note'>): Packed<'Note'>['visibility'] { if (packedNote.visibility === 'public' || packedNote.visibility === 'home') { const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore; if ((followersOnlyBefore != null) @@ -115,7 +114,11 @@ export class NoteEntityService implements OnModuleInit { packedNote.visibility = 'followers'; } } + return packedNote.visibility; + } + @bindThis + private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise { if (meId === packedNote.userId) return; // TODO: isVisibleForMe を使うようにしても良さそう(型違うけど) @@ -458,6 +461,8 @@ export class NoteEntityService implements OnModuleInit { } : {}), }); + this.treatVisibility(packed); + if (!opts.skipHide) { await this.hideNote(packed, meId); } -- cgit v1.2.3-freya From c4192e81ed30292a06b85e7b00578ca87c97d076 Mon Sep 17 00:00:00 2001 From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 11 Jan 2025 22:43:42 +0900 Subject: enhance(backend): チャートの処理を一つずつ行うことでDBの同時接続とタイムアウトを削減 (#15239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * sync charts one-at-a-time to reduce database contention and timeouts * fix merge resolve failure * Update Changelog * update changelog * add comments --------- Co-authored-by: Hazelnoot --- CHANGELOG.md | 2 ++ .../src/core/chart/ChartManagementService.ts | 10 ++++---- .../processors/CleanChartsProcessorService.ts | 27 +++++++++++----------- .../processors/ResyncChartsProcessorService.ts | 9 ++++---- .../queue/processors/TickChartsProcessorService.ts | 27 +++++++++++----------- 5 files changed, 37 insertions(+), 38 deletions(-) (limited to 'packages/backend/src/core') diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8eaf00e8..75dd37bc24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ ### Server - Enhance: pg_bigmが利用できるよう、ノートの検索をILIKE演算子でなくLIKE演算子でLOWER()をかけたテキストに対して行うように +- Enhance: チャート更新時にDBに同時接続しないように + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/830) - Fix: ユーザーのプロフィール画面をアドレス入力などで直接表示した際に概要タブの描画に失敗する問題の修正( #15032 ) - Fix: 起動前の疎通チェックが機能しなくなっていた問題を修正 (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/737) diff --git a/packages/backend/src/core/chart/ChartManagementService.ts b/packages/backend/src/core/chart/ChartManagementService.ts index 79681370a1..f04c561063 100644 --- a/packages/backend/src/core/chart/ChartManagementService.ts +++ b/packages/backend/src/core/chart/ChartManagementService.ts @@ -58,9 +58,9 @@ export class ChartManagementService implements OnApplicationShutdown { @bindThis public async start() { // 20分おきにメモリ情報をDBに書き込み - this.saveIntervalId = setInterval(() => { + this.saveIntervalId = setInterval(async () => { for (const chart of this.charts) { - chart.save(); + await chart.save(); } }, 1000 * 60 * 20); } @@ -69,9 +69,9 @@ export class ChartManagementService implements OnApplicationShutdown { public async dispose(): Promise { clearInterval(this.saveIntervalId); if (process.env.NODE_ENV !== 'test') { - await Promise.all( - this.charts.map(chart => chart.save()), - ); + for (const chart of this.charts) { + await chart.save(); + } } } diff --git a/packages/backend/src/queue/processors/CleanChartsProcessorService.ts b/packages/backend/src/queue/processors/CleanChartsProcessorService.ts index 110468801c..8c5faa8d07 100644 --- a/packages/backend/src/queue/processors/CleanChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanChartsProcessorService.ts @@ -48,20 +48,19 @@ export class CleanChartsProcessorService { public async process(): Promise { this.logger.info('Clean charts...'); - await Promise.all([ - this.federationChart.clean(), - this.notesChart.clean(), - this.usersChart.clean(), - this.activeUsersChart.clean(), - this.instanceChart.clean(), - this.perUserNotesChart.clean(), - this.perUserPvChart.clean(), - this.driveChart.clean(), - this.perUserReactionsChart.clean(), - this.perUserFollowingChart.clean(), - this.perUserDriveChart.clean(), - this.apRequestChart.clean(), - ]); + // DBへの同時接続を避けるためにPromise.allを使わずひとつずつ実行する + await this.federationChart.clean(); + await this.notesChart.clean(); + await this.usersChart.clean(); + await this.activeUsersChart.clean(); + await this.instanceChart.clean(); + await this.perUserNotesChart.clean(); + await this.perUserPvChart.clean(); + await this.driveChart.clean(); + await this.perUserReactionsChart.clean(); + await this.perUserFollowingChart.clean(); + await this.perUserDriveChart.clean(); + await this.apRequestChart.clean(); this.logger.succ('All charts successfully cleaned.'); } diff --git a/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts b/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts index 570cdf9a75..0c47fdedb3 100644 --- a/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts @@ -29,13 +29,12 @@ export class ResyncChartsProcessorService { public async process(): Promise { this.logger.info('Resync charts...'); + // DBへの同時接続を避けるためにPromise.allを使わずひとつずつ実行する // TODO: ユーザーごとのチャートも更新する // TODO: インスタンスごとのチャートも更新する - await Promise.all([ - this.driveChart.resync(), - this.notesChart.resync(), - this.usersChart.resync(), - ]); + await this.driveChart.resync(); + await this.notesChart.resync(); + await this.usersChart.resync(); this.logger.succ('All charts successfully resynced.'); } diff --git a/packages/backend/src/queue/processors/TickChartsProcessorService.ts b/packages/backend/src/queue/processors/TickChartsProcessorService.ts index 93ec34162d..fc8856a271 100644 --- a/packages/backend/src/queue/processors/TickChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/TickChartsProcessorService.ts @@ -48,20 +48,19 @@ export class TickChartsProcessorService { public async process(): Promise { this.logger.info('Tick charts...'); - await Promise.all([ - this.federationChart.tick(false), - this.notesChart.tick(false), - this.usersChart.tick(false), - this.activeUsersChart.tick(false), - this.instanceChart.tick(false), - this.perUserNotesChart.tick(false), - this.perUserPvChart.tick(false), - this.driveChart.tick(false), - this.perUserReactionsChart.tick(false), - this.perUserFollowingChart.tick(false), - this.perUserDriveChart.tick(false), - this.apRequestChart.tick(false), - ]); + // DBへの同時接続を避けるためにPromise.allを使わずひとつずつ実行する + await this.federationChart.tick(false); + await this.notesChart.tick(false); + await this.usersChart.tick(false); + await this.activeUsersChart.tick(false); + await this.instanceChart.tick(false); + await this.perUserNotesChart.tick(false); + await this.perUserPvChart.tick(false); + await this.driveChart.tick(false); + await this.perUserReactionsChart.tick(false); + await this.perUserFollowingChart.tick(false); + await this.perUserDriveChart.tick(false); + await this.apRequestChart.tick(false); this.logger.succ('All charts successfully ticked.'); } -- cgit v1.2.3-freya From 6820878676f5f531bc596076ed5c5ea022051631 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Tue, 14 Jan 2025 19:36:35 +0900 Subject: fix: unable to use AiService on arm64 (#15261) --- CHANGELOG.md | 1 + packages/backend/src/core/AiService.ts | 21 ++++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) (limited to 'packages/backend/src/core') diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c63226c58..b9f0d6495a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ - Fix: disableClustering設定時の初期化ロジックを調整( #15223 ) - Fix: ActivityPubリクエストかどうかの判定が正しくない問題を修正 (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/869) +- Fix: AIセンシティブ判定が arm64 環境で動作しない問題を修正 ## 2024.11.0 diff --git a/packages/backend/src/core/AiService.ts b/packages/backend/src/core/AiService.ts index ad852fdd6e..33ddabb5e0 100644 --- a/packages/backend/src/core/AiService.ts +++ b/packages/backend/src/core/AiService.ts @@ -15,7 +15,7 @@ import { bindThis } from '@/decorators.js'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); -const REQUIRED_CPU_FLAGS = ['avx2', 'fma']; +const REQUIRED_CPU_FLAGS_X64 = ['avx2', 'fma']; let isSupportedCpu: undefined | boolean = undefined; @Injectable() @@ -31,8 +31,7 @@ export class AiService { public async detectSensitive(path: string): Promise { try { if (isSupportedCpu === undefined) { - const cpuFlags = await this.getCpuFlags(); - isSupportedCpu = REQUIRED_CPU_FLAGS.every(required => cpuFlags.includes(required)); + isSupportedCpu = await this.computeIsSupportedCpu(); } if (!isSupportedCpu) { @@ -64,6 +63,22 @@ export class AiService { } } + private async computeIsSupportedCpu(): Promise { + switch (process.arch) { + case 'x64': { + const cpuFlags = await this.getCpuFlags(); + return REQUIRED_CPU_FLAGS_X64.every(required => cpuFlags.includes(required)); + } + case 'arm64': { + // As far as I know, no required CPU flags for ARM64. + return true; + } + default: { + return false; + } + } + } + @bindThis private async getCpuFlags(): Promise { const str = await si.cpuFlags(); -- cgit v1.2.3-freya From 7fbfc2e046c978c8d544b3e6e8c8e77fc7ba8da6 Mon Sep 17 00:00:00 2001 From: おさむのひと <46447427+samunohito@users.noreply.github.com> Date: Tue, 14 Jan 2025 19:47:02 +0900 Subject: ApPersonServiceとApNoteServiceのuri <-> url比較を緩和 (#15233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * https://github.com/misskey-dev/misskey/issues/15039#issuecomment-2576411861 の反映 Co-authored-by: Kagami Sascha Rosylight * fix CHANGELOG.md * remove inspection --------- Co-authored-by: Kagami Sascha Rosylight --- CHANGELOG.md | 1 + .../src/core/activitypub/misc/check-against-url.ts | 6 ++++-- .../src/core/activitypub/models/ApNoteService.ts | 10 ++-------- .../src/core/activitypub/models/ApPersonService.ts | 20 +++++++++----------- 4 files changed, 16 insertions(+), 21 deletions(-) (limited to 'packages/backend/src/core') diff --git a/CHANGELOG.md b/CHANGELOG.md index f5fd739232..eb9f9aaeeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/737) - Fix: ロックダウンされた期間指定のノートがStreaming経由でLTLに出現するのを修正 ( #15200 ) - Fix: disableClustering設定時の初期化ロジックを調整( #15223 ) +- Fix: URLとURIが異なるエンティティの照会に失敗する問題を修正( #15039 ) - Fix: ActivityPubリクエストかどうかの判定が正しくない問題を修正 (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/869) - Fix: AIセンシティブ判定が arm64 環境で動作しない問題を修正 diff --git a/packages/backend/src/core/activitypub/misc/check-against-url.ts b/packages/backend/src/core/activitypub/misc/check-against-url.ts index 78ba891a2e..d679bd8180 100644 --- a/packages/backend/src/core/activitypub/misc/check-against-url.ts +++ b/packages/backend/src/core/activitypub/misc/check-against-url.ts @@ -5,13 +5,15 @@ import type { IObject } from '../type.js'; export function assertActivityMatchesUrls(activity: IObject, urls: string[]) { - const idOk = activity.id !== undefined && urls.includes(activity.id); + const hosts = urls.map(it => new URL(it).host); + + const idOk = activity.id !== undefined && hosts.includes(new URL(activity.id).host); // technically `activity.url` could be an `ApObject = IObject | // string | (IObject | string)[]`, but if it's a complicated thing // and the `activity.id` doesn't match, I think we're fine // rejecting the activity - const urlOk = typeof(activity.url) === 'string' && urls.includes(activity.url); + const urlOk = typeof(activity.url) === 'string' && hosts.includes(new URL(activity.url).host); if (!idOk && !urlOk) { throw new Error(`bad Activity: neither id(${activity?.id}) nor url(${activity?.url}) match location(${urls})`); diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index eb2e771a38..cb25aa54b0 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -154,14 +154,8 @@ export class ApNoteService { const url = getOneApHrefNullable(note.url); - if (url != null) { - if (!checkHttps(url)) { - throw new Error('unexpected schema of note url: ' + url); - } - - if (this.utilityService.punyHost(url) !== this.utilityService.punyHost(note.id)) { - throw new Error(`note url & uri host mismatch: note url: ${url}, note uri: ${note.id}`); - } + if (url && !checkHttps(url)) { + throw new Error('unexpected schema of note url: ' + url); } this.logger.info(`Creating the Note: ${note.id}`); diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 8590861ca0..6019906add 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -157,8 +157,12 @@ export class ApPersonService implements OnModuleInit { const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined); if (sharedInboxObject != null) { const sharedInbox = getApId(sharedInboxObject); - if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHost(sharedInbox) === expectHost)) { - throw new Error('invalid Actor: wrong shared inbox'); + if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && new URL(sharedInbox).host === expectHost)) { + this.logger.warn(`invalid Actor: skipping wrong shared inbox, expected host: ${expectHost}, actual URL: ${sharedInbox}`); + x.sharedInbox = undefined; + if (x.endpoints?.sharedInbox) { + x.endpoints.sharedInbox = undefined; + } } } @@ -257,7 +261,7 @@ export class ApPersonService implements OnModuleInit { if (Array.isArray(img)) { img = img.find(item => item && item.url) ?? null; } - + // if we have an explicitly missing image, return an // explicitly-null set of values if ((img == null) || (typeof img === 'object' && img.url == null)) { @@ -344,14 +348,8 @@ export class ApPersonService implements OnModuleInit { throw new Error('Refusing to create person without id'); } - if (url != null) { - if (!checkHttps(url)) { - throw new Error('unexpected schema of person url: ' + url); - } - - if (this.utilityService.punyHost(url) !== this.utilityService.punyHost(person.id)) { - throw new Error(`person url <> uri host mismatch: ${url} <> ${person.id}`); - } + if (url && !checkHttps(url)) { + throw new Error('unexpected schema of person url: ' + url); } // Create user -- cgit v1.2.3-freya From 64501c69a10323067dee739790b5a4fc5104e50d Mon Sep 17 00:00:00 2001 From: おさむのひと <46447427+samunohito@users.noreply.github.com> Date: Tue, 14 Jan 2025 19:57:58 +0900 Subject: feat(frontend): Botプロテクションの設定変更時は実際に検証を通過しないと保存できないようにする (#15151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(frontend): CAPTCHAの設定変更時は実際に検証を通過しないと保存できないようにする * なしでも保存できるようにした * fix CHANGELOG.md * フォームが増殖するのを修正 * add comment * add server-side verify * fix ci * fix * fix * fix i18n * add current.ts * fix text * fix * regenerate locales * fix MkFormFooter.vue --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- CHANGELOG.md | 1 + locales/index.d.ts | 43 ++ locales/ja-JP.yml | 14 + packages/backend/src/core/CaptchaService.ts | 299 +++++++++- packages/backend/src/server/api/EndpointsModule.ts | 8 + packages/backend/src/server/api/endpoints.ts | 4 + .../server/api/endpoints/admin/captcha/current.ts | 70 +++ .../src/server/api/endpoints/admin/captcha/save.ts | 129 +++++ packages/backend/test/unit/CaptchaService.ts | 622 +++++++++++++++++++++ packages/frontend/src/components/MkCaptcha.vue | 64 ++- packages/frontend/src/components/MkFormFooter.vue | 9 +- packages/frontend/src/index.html | 2 +- packages/frontend/src/os.ts | 5 +- .../frontend/src/pages/admin/bot-protection.vue | 240 +++++--- packages/misskey-js/etc/misskey-js.api.md | 8 + packages/misskey-js/src/autogen/apiClientJSDoc.ts | 22 + packages/misskey-js/src/autogen/endpoint.ts | 4 + packages/misskey-js/src/autogen/entities.ts | 2 + packages/misskey-js/src/autogen/types.ts | 140 +++++ 19 files changed, 1597 insertions(+), 89 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/admin/captcha/current.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/captcha/save.ts create mode 100644 packages/backend/test/unit/CaptchaService.ts (limited to 'packages/backend/src/core') diff --git a/CHANGELOG.md b/CHANGELOG.md index eb9f9aaeeb..af5d333927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Fix: 公開範囲がホームのノートの埋め込みウィジェットが読み込まれない問題を修正 (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/803) - Fix: 絵文字管理画面で一部の絵文字が表示されない問題を修正 +- Fix: Botプロテクションの設定変更時は実際に検証を通過しないと保存できないように( #15137 ) - Fix: ノート検索が使用できない場合でもチャンネルのノート検索欄がでていた問題を修正 - Fix: `Ui:C:select`で値の変更が画面に反映されない問題を修正 - Fix: MiAuth認可画面で、認可処理に失敗した場合でもコールバックURLに遷移してしまう問題を修正 diff --git a/locales/index.d.ts b/locales/index.d.ts index e85d6a3bd5..7c3ef5d93c 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -10668,6 +10668,49 @@ export interface Locale extends ILocale { "description": string; }; }; + "_captcha": { + /** + * CAPTCHAを通過してください + */ + "verify": string; + /** + * サイトキーとシークレットキーにテスト用の値を入力することでプレビューを確認できます。 + * 詳細は下記ページをご確認ください。 + */ + "testSiteKeyMessage": string; + "_error": { + "_requestFailed": { + /** + * CAPTCHAのリクエストに失敗しました + */ + "title": string; + /** + * しばらく後に実行するか、設定をもう一度ご確認ください。 + */ + "text": string; + }; + "_verificationFailed": { + /** + * CAPTCHAの検証に失敗しました + */ + "title": string; + /** + * 設定が正しいかどうかもう一度確認ください。 + */ + "text": string; + }; + "_unknown": { + /** + * CAPTCHAエラー + */ + "title": string; + /** + * 想定外のエラーが発生しました。 + */ + "text": string; + }; + }; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 37e51b9398..57a88062c1 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2847,3 +2847,17 @@ _remoteLookupErrors: _noSuchObject: title: "見つかりません" description: "要求されたリソースは見つかりませんでした。URIをもう一度お確かめください。" + +_captcha: + verify: "CAPTCHAを通過してください" + testSiteKeyMessage: "サイトキーとシークレットキーにテスト用の値を入力することでプレビューを確認できます。\n詳細は下記ページをご確認ください。" + _error: + _requestFailed: + title: "CAPTCHAのリクエストに失敗しました" + text: "しばらく後に実行するか、設定をもう一度ご確認ください。" + _verificationFailed: + title: "CAPTCHAの検証に失敗しました" + text: "設定が正しいかどうかもう一度確認ください。" + _unknown: + title: "CAPTCHAエラー" + text: "想定外のエラーが発生しました。" diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts index 206d0dbe0a..8c7f66236e 100644 --- a/packages/backend/src/core/CaptchaService.ts +++ b/packages/backend/src/core/CaptchaService.ts @@ -6,6 +6,65 @@ import { Injectable } from '@nestjs/common'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; +import { MetaService } from '@/core/MetaService.js'; +import { MiMeta } from '@/models/Meta.js'; +import Logger from '@/logger.js'; +import { LoggerService } from './LoggerService.js'; + +export const supportedCaptchaProviders = ['none', 'hcaptcha', 'mcaptcha', 'recaptcha', 'turnstile', 'testcaptcha'] as const; +export type CaptchaProvider = typeof supportedCaptchaProviders[number]; + +export const captchaErrorCodes = { + invalidProvider: Symbol('invalidProvider'), + invalidParameters: Symbol('invalidParameters'), + noResponseProvided: Symbol('noResponseProvided'), + requestFailed: Symbol('requestFailed'), + verificationFailed: Symbol('verificationFailed'), + unknown: Symbol('unknown'), +} as const; +export type CaptchaErrorCode = typeof captchaErrorCodes[keyof typeof captchaErrorCodes]; + +export type CaptchaSetting = { + provider: CaptchaProvider; + hcaptcha: { + siteKey: string | null; + secretKey: string | null; + } + mcaptcha: { + siteKey: string | null; + secretKey: string | null; + instanceUrl: string | null; + } + recaptcha: { + siteKey: string | null; + secretKey: string | null; + } + turnstile: { + siteKey: string | null; + secretKey: string | null; + } +} + +export class CaptchaError extends Error { + public readonly code: CaptchaErrorCode; + public readonly cause?: unknown; + + constructor(code: CaptchaErrorCode, message: string, cause?: unknown) { + super(message); + this.code = code; + this.cause = cause; + this.name = 'CaptchaError'; + } +} + +export type CaptchaSaveSuccess = { + success: true; +} +export type CaptchaSaveFailure = { + success: false; + error: CaptchaError; +} +export type CaptchaSaveResult = CaptchaSaveSuccess | CaptchaSaveFailure; type CaptchaResponse = { success: boolean; @@ -14,9 +73,14 @@ type CaptchaResponse = { @Injectable() export class CaptchaService { + private readonly logger: Logger; + constructor( private httpRequestService: HttpRequestService, + private metaService: MetaService, + loggerService: LoggerService, ) { + this.logger = loggerService.getLogger('captcha'); } @bindThis @@ -44,32 +108,32 @@ export class CaptchaService { @bindThis public async verifyRecaptcha(secret: string, response: string | null | undefined): Promise { if (response == null) { - throw new Error('recaptcha-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'recaptcha-failed: no response provided'); } const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => { - throw new Error(`recaptcha-request-failed: ${err}`); + throw new CaptchaError(captchaErrorCodes.requestFailed, `recaptcha-request-failed: ${err}`); }); if (result.success !== true) { const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; - throw new Error(`recaptcha-failed: ${errorCodes}`); + throw new CaptchaError(captchaErrorCodes.verificationFailed, `recaptcha-failed: ${errorCodes}`); } } @bindThis public async verifyHcaptcha(secret: string, response: string | null | undefined): Promise { if (response == null) { - throw new Error('hcaptcha-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'hcaptcha-failed: no response provided'); } const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => { - throw new Error(`hcaptcha-request-failed: ${err}`); + throw new CaptchaError(captchaErrorCodes.requestFailed, `hcaptcha-request-failed: ${err}`); }); if (result.success !== true) { const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; - throw new Error(`hcaptcha-failed: ${errorCodes}`); + throw new CaptchaError(captchaErrorCodes.verificationFailed, `hcaptcha-failed: ${errorCodes}`); } } @@ -77,7 +141,7 @@ export class CaptchaService { @bindThis public async verifyMcaptcha(secret: string, siteKey: string, instanceHost: string, response: string | null | undefined): Promise { if (response == null) { - throw new Error('mcaptcha-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'mcaptcha-failed: no response provided'); } const endpointUrl = new URL('/api/v1/pow/siteverify', instanceHost); @@ -91,46 +155,251 @@ export class CaptchaService { headers: { 'Content-Type': 'application/json', }, - }); + }, { throwErrorWhenResponseNotOk: false }); if (result.status !== 200) { - throw new Error('mcaptcha-failed: mcaptcha didn\'t return 200 OK'); + throw new CaptchaError(captchaErrorCodes.requestFailed, 'mcaptcha-failed: mcaptcha didn\'t return 200 OK'); } const resp = (await result.json()) as { valid: boolean }; if (!resp.valid) { - throw new Error('mcaptcha-request-failed'); + throw new CaptchaError(captchaErrorCodes.verificationFailed, 'mcaptcha-request-failed'); } } @bindThis public async verifyTurnstile(secret: string, response: string | null | undefined): Promise { if (response == null) { - throw new Error('turnstile-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'turnstile-failed: no response provided'); } const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => { - throw new Error(`turnstile-request-failed: ${err}`); + throw new CaptchaError(captchaErrorCodes.requestFailed, `turnstile-request-failed: ${err}`); }); if (result.success !== true) { const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; - throw new Error(`turnstile-failed: ${errorCodes}`); + throw new CaptchaError(captchaErrorCodes.verificationFailed, `turnstile-failed: ${errorCodes}`); } } @bindThis public async verifyTestcaptcha(response: string | null | undefined): Promise { if (response == null) { - throw new Error('testcaptcha-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'testcaptcha-failed: no response provided'); } const success = response === 'testcaptcha-passed'; if (!success) { - throw new Error('testcaptcha-failed'); + throw new CaptchaError(captchaErrorCodes.verificationFailed, 'testcaptcha-failed'); + } + } + + @bindThis + public async get(): Promise { + const meta = await this.metaService.fetch(true); + + let provider: CaptchaProvider; + switch (true) { + case meta.enableHcaptcha: { + provider = 'hcaptcha'; + break; + } + case meta.enableMcaptcha: { + provider = 'mcaptcha'; + break; + } + case meta.enableRecaptcha: { + provider = 'recaptcha'; + break; + } + case meta.enableTurnstile: { + provider = 'turnstile'; + break; + } + case meta.enableTestcaptcha: { + provider = 'testcaptcha'; + break; + } + default: { + provider = 'none'; + break; + } + } + + return { + provider: provider, + hcaptcha: { + siteKey: meta.hcaptchaSiteKey, + secretKey: meta.hcaptchaSecretKey, + }, + mcaptcha: { + siteKey: meta.mcaptchaSitekey, + secretKey: meta.mcaptchaSecretKey, + instanceUrl: meta.mcaptchaInstanceUrl, + }, + recaptcha: { + siteKey: meta.recaptchaSiteKey, + secretKey: meta.recaptchaSecretKey, + }, + turnstile: { + siteKey: meta.turnstileSiteKey, + secretKey: meta.turnstileSecretKey, + }, + }; + } + + /** + * captchaの設定を更新します. その際、フロントエンド側で受け取ったcaptchaからの戻り値を検証し、passした場合のみ設定を更新します. + * 実際の検証処理はサービス内で定義されている各captchaプロバイダの検証関数に委譲します. + * + * @param provider 検証するcaptchaのプロバイダ + * @param params + * @param params.sitekey hcaptcha, recaptcha, turnstile, mcaptchaの場合に指定するsitekey. それ以外のプロバイダでは無視されます + * @param params.secret hcaptcha, recaptcha, turnstile, mcaptchaの場合に指定するsecret. それ以外のプロバイダでは無視されます + * @param params.instanceUrl mcaptchaの場合に指定するインスタンスのURL. それ以外のプロバイダでは無視されます + * @param params.captchaResult フロントエンド側で受け取ったcaptchaプロバイダからの戻り値. この値を使ってサーバサイドでの検証を行います + * @see verifyHcaptcha + * @see verifyMcaptcha + * @see verifyRecaptcha + * @see verifyTurnstile + * @see verifyTestcaptcha + */ + @bindThis + public async save( + provider: CaptchaProvider, + params?: { + sitekey?: string | null; + secret?: string | null; + instanceUrl?: string | null; + captchaResult?: string | null; + }, + ): Promise { + if (!supportedCaptchaProviders.includes(provider)) { + return { + success: false, + error: new CaptchaError(captchaErrorCodes.invalidProvider, `Invalid captcha provider: ${provider}`), + }; + } + + const operation = { + none: async () => { + await this.updateMeta(provider, params); + }, + hcaptcha: async () => { + if (!params?.secret || !params.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'hcaptcha-failed: secret and captureResult are required'); + } + + await this.verifyHcaptcha(params.secret, params.captchaResult); + await this.updateMeta(provider, params); + }, + mcaptcha: async () => { + if (!params?.secret || !params.sitekey || !params.instanceUrl || !params.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'mcaptcha-failed: secret, sitekey, instanceUrl and captureResult are required'); + } + + await this.verifyMcaptcha(params.secret, params.sitekey, params.instanceUrl, params.captchaResult); + await this.updateMeta(provider, params); + }, + recaptcha: async () => { + if (!params?.secret || !params.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'recaptcha-failed: secret and captureResult are required'); + } + + await this.verifyRecaptcha(params.secret, params.captchaResult); + await this.updateMeta(provider, params); + }, + turnstile: async () => { + if (!params?.secret || !params.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: secret and captureResult are required'); + } + + await this.verifyTurnstile(params.secret, params.captchaResult); + await this.updateMeta(provider, params); + }, + testcaptcha: async () => { + if (!params?.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: captureResult are required'); + } + + await this.verifyTestcaptcha(params.captchaResult); + await this.updateMeta(provider, params); + }, + }[provider]; + + return operation() + .then(() => ({ success: true }) as CaptchaSaveSuccess) + .catch(err => { + this.logger.info(err); + const error = err instanceof CaptchaError + ? err + : new CaptchaError(captchaErrorCodes.unknown, `unknown error: ${err}`); + return { + success: false, + error, + }; + }); + } + + @bindThis + private async updateMeta( + provider: CaptchaProvider, + params?: { + sitekey?: string | null; + secret?: string | null; + instanceUrl?: string | null; + }, + ) { + const metaPartial: Partial< + Pick< + MiMeta, + ('enableHcaptcha' | 'hcaptchaSiteKey' | 'hcaptchaSecretKey') | + ('enableMcaptcha' | 'mcaptchaSitekey' | 'mcaptchaSecretKey' | 'mcaptchaInstanceUrl') | + ('enableRecaptcha' | 'recaptchaSiteKey' | 'recaptchaSecretKey') | + ('enableTurnstile' | 'turnstileSiteKey' | 'turnstileSecretKey') | + ('enableTestcaptcha') + > + > = { + enableHcaptcha: provider === 'hcaptcha', + enableMcaptcha: provider === 'mcaptcha', + enableRecaptcha: provider === 'recaptcha', + enableTurnstile: provider === 'turnstile', + enableTestcaptcha: provider === 'testcaptcha', + }; + + const updateIfNotUndefined = (key: K, value: typeof metaPartial[K]) => { + if (value !== undefined) { + metaPartial[key] = value; + } + }; + switch (provider) { + case 'hcaptcha': { + updateIfNotUndefined('hcaptchaSiteKey', params?.sitekey); + updateIfNotUndefined('hcaptchaSecretKey', params?.secret); + break; + } + case 'mcaptcha': { + updateIfNotUndefined('mcaptchaSitekey', params?.sitekey); + updateIfNotUndefined('mcaptchaSecretKey', params?.secret); + updateIfNotUndefined('mcaptchaInstanceUrl', params?.instanceUrl); + break; + } + case 'recaptcha': { + updateIfNotUndefined('recaptchaSiteKey', params?.sitekey); + updateIfNotUndefined('recaptchaSecretKey', params?.secret); + break; + } + case 'turnstile': { + updateIfNotUndefined('turnstileSiteKey', params?.sitekey); + updateIfNotUndefined('turnstileSecretKey', params?.secret); + break; + } } + + await this.metaService.update(metaPartial); } } diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 5bb194313d..c2462d8b3d 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -28,6 +28,8 @@ import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-d import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; +import * as ep___admin_captcha_current from './endpoints/admin/captcha/current.js'; +import * as ep___admin_captcha_save from './endpoints/admin/captcha/save.js'; import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; @@ -416,6 +418,8 @@ const $admin_avatarDecorations_create: Provider = { provide: 'ep:admin/avatar-de const $admin_avatarDecorations_delete: Provider = { provide: 'ep:admin/avatar-decorations/delete', useClass: ep___admin_avatarDecorations_delete.default }; const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-decorations/list', useClass: ep___admin_avatarDecorations_list.default }; const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default }; +const $admin_captcha_current: Provider = { provide: 'ep:admin/captcha/current', useClass: ep___admin_captcha_current.default }; +const $admin_captcha_save: Provider = { provide: 'ep:admin/captcha/save', useClass: ep___admin_captcha_save.default }; const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.default }; const $admin_unsetUserAvatar: Provider = { provide: 'ep:admin/unset-user-avatar', useClass: ep___admin_unsetUserAvatar.default }; const $admin_unsetUserBanner: Provider = { provide: 'ep:admin/unset-user-banner', useClass: ep___admin_unsetUserBanner.default }; @@ -808,6 +812,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_avatarDecorations_delete, $admin_avatarDecorations_list, $admin_avatarDecorations_update, + $admin_captcha_current, + $admin_captcha_save, $admin_deleteAllFilesOfAUser, $admin_unsetUserAvatar, $admin_unsetUserBanner, @@ -1194,6 +1200,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_avatarDecorations_delete, $admin_avatarDecorations_list, $admin_avatarDecorations_update, + $admin_captcha_current, + $admin_captcha_save, $admin_deleteAllFilesOfAUser, $admin_unsetUserAvatar, $admin_unsetUserBanner, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 15809b2678..86728ef381 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -33,6 +33,8 @@ import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-d import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; +import * as ep___admin_captcha_current from './endpoints/admin/captcha/current.js'; +import * as ep___admin_captcha_save from './endpoints/admin/captcha/save.js'; import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; @@ -420,6 +422,8 @@ const eps = [ ['admin/avatar-decorations/delete', ep___admin_avatarDecorations_delete], ['admin/avatar-decorations/list', ep___admin_avatarDecorations_list], ['admin/avatar-decorations/update', ep___admin_avatarDecorations_update], + ['admin/captcha/current', ep___admin_captcha_current], + ['admin/captcha/save', ep___admin_captcha_save], ['admin/delete-all-files-of-a-user', ep___admin_deleteAllFilesOfAUser], ['admin/unset-user-avatar', ep___admin_unsetUserAvatar], ['admin/unset-user-banner', ep___admin_unsetUserBanner], diff --git a/packages/backend/src/server/api/endpoints/admin/captcha/current.ts b/packages/backend/src/server/api/endpoints/admin/captcha/current.ts new file mode 100644 index 0000000000..63ec740348 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/captcha/current.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { CaptchaService, supportedCaptchaProviders } from '@/core/CaptchaService.js'; + +export const meta = { + tags: ['admin', 'captcha'], + + requireCredential: true, + requireAdmin: true, + + // 実態はmetaの取得であるため + kind: 'read:admin:meta', + + res: { + type: 'object', + properties: { + provider: { + type: 'string', + enum: supportedCaptchaProviders, + }, + hcaptcha: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + }, + }, + mcaptcha: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + instanceUrl: { type: 'string', nullable: true }, + }, + }, + recaptcha: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + }, + }, + turnstile: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + }, + }, + }, + }, +} as const; + +export const paramDef = {} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private captchaService: CaptchaService, + ) { + super(meta, paramDef, async () => { + return this.captchaService.get(); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/captcha/save.ts b/packages/backend/src/server/api/endpoints/admin/captcha/save.ts new file mode 100644 index 0000000000..98ec278ebe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/captcha/save.ts @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { captchaErrorCodes, CaptchaService, supportedCaptchaProviders } from '@/core/CaptchaService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['admin', 'captcha'], + + requireCredential: true, + requireAdmin: true, + + // 実態はmetaの更新であるため + kind: 'write:admin:meta', + + errors: { + invalidProvider: { + message: 'Invalid provider.', + code: 'INVALID_PROVIDER', + id: '14bf7ae1-80cc-4363-acb2-4fd61d086af0', + httpStatusCode: 400, + }, + invalidParameters: { + message: 'Invalid parameters.', + code: 'INVALID_PARAMETERS', + id: '26654194-410e-44e2-b42e-460ff6f92476', + httpStatusCode: 400, + }, + noResponseProvided: { + message: 'No response provided.', + code: 'NO_RESPONSE_PROVIDED', + id: '40acbba8-0937-41fb-bb3f-474514d40afe', + httpStatusCode: 400, + }, + requestFailed: { + message: 'Request failed.', + code: 'REQUEST_FAILED', + id: '0f4fe2f1-2c15-4d6e-b714-efbfcde231cd', + httpStatusCode: 500, + }, + verificationFailed: { + message: 'Verification failed.', + code: 'VERIFICATION_FAILED', + id: 'c41c067f-24f3-4150-84b2-b5a3ae8c2214', + httpStatusCode: 400, + }, + unknown: { + message: 'unknown', + code: 'UNKNOWN', + id: 'f868d509-e257-42a9-99c1-42614b031a97', + httpStatusCode: 500, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + provider: { + type: 'string', + enum: supportedCaptchaProviders, + }, + captchaResult: { + type: 'string', nullable: true, + }, + sitekey: { + type: 'string', nullable: true, + }, + secret: { + type: 'string', nullable: true, + }, + instanceUrl: { + type: 'string', nullable: true, + }, + }, + required: ['provider'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private captchaService: CaptchaService, + ) { + super(meta, paramDef, async (ps) => { + const result = await this.captchaService.save(ps.provider, { + sitekey: ps.sitekey, + secret: ps.secret, + instanceUrl: ps.instanceUrl, + captchaResult: ps.captchaResult, + }); + + if (!result.success) { + switch (result.error.code) { + case captchaErrorCodes.invalidProvider: + throw new ApiError({ + ...meta.errors.invalidProvider, + message: result.error.message, + }); + case captchaErrorCodes.invalidParameters: + throw new ApiError({ + ...meta.errors.invalidParameters, + message: result.error.message, + }); + case captchaErrorCodes.noResponseProvided: + throw new ApiError({ + ...meta.errors.noResponseProvided, + message: result.error.message, + }); + case captchaErrorCodes.requestFailed: + throw new ApiError({ + ...meta.errors.requestFailed, + message: result.error.message, + }); + case captchaErrorCodes.verificationFailed: + throw new ApiError({ + ...meta.errors.verificationFailed, + message: result.error.message, + }); + default: + throw new ApiError(meta.errors.unknown); + } + } + }); + } +} diff --git a/packages/backend/test/unit/CaptchaService.ts b/packages/backend/test/unit/CaptchaService.ts new file mode 100644 index 0000000000..51b70b05a1 --- /dev/null +++ b/packages/backend/test/unit/CaptchaService.ts @@ -0,0 +1,622 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, jest } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Response } from 'node-fetch'; +import { + CaptchaError, + CaptchaErrorCode, + captchaErrorCodes, + CaptchaSaveResult, + CaptchaService, +} from '@/core/CaptchaService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { MiMeta } from '@/models/Meta.js'; +import { LoggerService } from '@/core/LoggerService.js'; + +describe('CaptchaService', () => { + let app: TestingModule; + let service: CaptchaService; + let httpRequestService: jest.Mocked; + let metaService: jest.Mocked; + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + CaptchaService, + LoggerService, + { + provide: HttpRequestService, useFactory: () => ({ send: jest.fn() }), + }, + { + provide: MetaService, useFactory: () => ({ + fetch: jest.fn(), + update: jest.fn(), + }), + }, + ], + }).compile(); + + app.enableShutdownHooks(); + + service = app.get(CaptchaService); + httpRequestService = app.get(HttpRequestService) as jest.Mocked; + metaService = app.get(MetaService) as jest.Mocked; + }); + + beforeEach(() => { + httpRequestService.send.mockClear(); + metaService.update.mockClear(); + metaService.fetch.mockClear(); + }); + + afterAll(async () => { + await app.close(); + }); + + function successMock(result: object) { + httpRequestService.send.mockResolvedValue({ + ok: true, + status: 200, + json: async () => (result), + } as Response); + } + + function failureHttpMock() { + httpRequestService.send.mockResolvedValue({ + ok: false, + status: 400, + } as Response); + } + + function failureVerificationMock(result: object) { + httpRequestService.send.mockResolvedValue({ + ok: true, + status: 200, + json: async () => (result), + } as Response); + } + + async function testCaptchaError(code: CaptchaErrorCode, test: () => Promise) { + try { + await test(); + expect(false).toBe(true); + } catch (e) { + expect(e instanceof CaptchaError).toBe(true); + + const _e = e as CaptchaError; + expect(_e.code).toBe(code); + } + } + + describe('verifyRecaptcha', () => { + test('success', async () => { + successMock({ success: true }); + await service.verifyRecaptcha('secret', 'response'); + }); + + test('noResponseProvided', async () => { + await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyRecaptcha('secret', null)); + }); + + test('requestFailed', async () => { + failureHttpMock(); + await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyRecaptcha('secret', 'response')); + }); + + test('verificationFailed', async () => { + failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] }); + await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyRecaptcha('secret', 'response')); + }); + }); + + describe('verifyHcaptcha', () => { + test('success', async () => { + successMock({ success: true }); + await service.verifyHcaptcha('secret', 'response'); + }); + + test('noResponseProvided', async () => { + await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyHcaptcha('secret', null)); + }); + + test('requestFailed', async () => { + failureHttpMock(); + await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyHcaptcha('secret', 'response')); + }); + + test('verificationFailed', async () => { + failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] }); + await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyHcaptcha('secret', 'response')); + }); + }); + + describe('verifyMcaptcha', () => { + const host = 'https://localhost'; + + test('success', async () => { + successMock({ valid: true }); + await service.verifyMcaptcha('secret', 'sitekey', host, 'response'); + }); + + test('noResponseProvided', async () => { + await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyMcaptcha('secret', 'sitekey', host, null)); + }); + + test('requestFailed', async () => { + failureHttpMock(); + await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyMcaptcha('secret', 'sitekey', host, 'response')); + }); + + test('verificationFailed', async () => { + failureVerificationMock({ valid: false }); + await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyMcaptcha('secret', 'sitekey', host, 'response')); + }); + }); + + describe('verifyTurnstile', () => { + test('success', async () => { + successMock({ success: true }); + await service.verifyTurnstile('secret', 'response'); + }); + + test('noResponseProvided', async () => { + await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyTurnstile('secret', null)); + }); + + test('requestFailed', async () => { + failureHttpMock(); + await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyTurnstile('secret', 'response')); + }); + + test('verificationFailed', async () => { + failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] }); + await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyTurnstile('secret', 'response')); + }); + }); + + describe('verifyTestcaptcha', () => { + test('success', async () => { + await service.verifyTestcaptcha('testcaptcha-passed'); + }); + + test('noResponseProvided', async () => { + await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyTestcaptcha(null)); + }); + + test('verificationFailed', async () => { + await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyTestcaptcha('testcaptcha-failed')); + }); + }); + + describe('get', () => { + function setupMeta(meta: Partial) { + metaService.fetch.mockResolvedValue(meta as MiMeta); + } + + test('values', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + hcaptchaSiteKey: 'hcaptcha-sitekey', + hcaptchaSecretKey: 'hcaptcha-secret', + mcaptchaSitekey: 'mcaptcha-sitekey', + mcaptchaSecretKey: 'mcaptcha-secret', + mcaptchaInstanceUrl: 'https://localhost', + recaptchaSiteKey: 'recaptcha-sitekey', + recaptchaSecretKey: 'recaptcha-secret', + turnstileSiteKey: 'turnstile-sitekey', + turnstileSecretKey: 'turnstile-secret', + }); + + const result = await service.get(); + expect(result.provider).toBe('none'); + expect(result.hcaptcha.siteKey).toBe('hcaptcha-sitekey'); + expect(result.hcaptcha.secretKey).toBe('hcaptcha-secret'); + expect(result.mcaptcha.siteKey).toBe('mcaptcha-sitekey'); + expect(result.mcaptcha.secretKey).toBe('mcaptcha-secret'); + expect(result.mcaptcha.instanceUrl).toBe('https://localhost'); + expect(result.recaptcha.siteKey).toBe('recaptcha-sitekey'); + expect(result.recaptcha.secretKey).toBe('recaptcha-secret'); + expect(result.turnstile.siteKey).toBe('turnstile-sitekey'); + expect(result.turnstile.secretKey).toBe('turnstile-secret'); + }); + + describe('provider', () => { + test('none', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + }); + + const result = await service.get(); + expect(result.provider).toBe('none'); + }); + + test('hcaptcha', async () => { + setupMeta({ + enableHcaptcha: true, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + }); + + const result = await service.get(); + expect(result.provider).toBe('hcaptcha'); + }); + + test('mcaptcha', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: true, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + }); + + const result = await service.get(); + expect(result.provider).toBe('mcaptcha'); + }); + + test('recaptcha', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: true, + enableTurnstile: false, + enableTestcaptcha: false, + }); + + const result = await service.get(); + expect(result.provider).toBe('recaptcha'); + }); + + test('turnstile', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: true, + enableTestcaptcha: false, + }); + + const result = await service.get(); + expect(result.provider).toBe('turnstile'); + }); + + test('testcaptcha', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: true, + }); + + const result = await service.get(); + expect(result.provider).toBe('testcaptcha'); + }); + }); + }); + + describe('save', () => { + const host = 'https://localhost'; + + describe('[success] 検証に成功した時だけ保存できる+他のプロバイダの設定値を誤って更新しない', () => { + beforeEach(() => { + successMock({ success: true, valid: true }); + }); + + async function assertSuccess(promise: Promise, expectMeta: Partial) { + await expect(promise) + .resolves + .toStrictEqual({ success: true }); + const partialParams = metaService.update.mock.calls[0][0]; + expect(partialParams).toStrictEqual(expectMeta); + } + + test('none', async () => { + await assertSuccess( + service.save('none'), + { + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + }, + ); + }); + + test('hcaptcha', async () => { + await assertSuccess( + service.save('hcaptcha', { + sitekey: 'hcaptcha-sitekey', + secret: 'hcaptcha-secret', + captchaResult: 'hcaptcha-passed', + }), + { + enableHcaptcha: true, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + hcaptchaSiteKey: 'hcaptcha-sitekey', + hcaptchaSecretKey: 'hcaptcha-secret', + }, + ); + }); + + test('mcaptcha', async () => { + await assertSuccess( + service.save('mcaptcha', { + sitekey: 'mcaptcha-sitekey', + secret: 'mcaptcha-secret', + instanceUrl: host, + captchaResult: 'mcaptcha-passed', + }), + { + enableHcaptcha: false, + enableMcaptcha: true, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + mcaptchaSitekey: 'mcaptcha-sitekey', + mcaptchaSecretKey: 'mcaptcha-secret', + mcaptchaInstanceUrl: host, + }, + ); + }); + + test('recaptcha', async () => { + await assertSuccess( + service.save('recaptcha', { + sitekey: 'recaptcha-sitekey', + secret: 'recaptcha-secret', + captchaResult: 'recaptcha-passed', + }), + { + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: true, + enableTurnstile: false, + enableTestcaptcha: false, + recaptchaSiteKey: 'recaptcha-sitekey', + recaptchaSecretKey: 'recaptcha-secret', + }, + ); + }); + + test('turnstile', async () => { + await assertSuccess( + service.save('turnstile', { + sitekey: 'turnstile-sitekey', + secret: 'turnstile-secret', + captchaResult: 'turnstile-passed', + }), + { + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: true, + enableTestcaptcha: false, + turnstileSiteKey: 'turnstile-sitekey', + turnstileSecretKey: 'turnstile-secret', + }, + ); + }); + + test('testcaptcha', async () => { + await assertSuccess( + service.save('testcaptcha', { + sitekey: 'testcaptcha-sitekey', + secret: 'testcaptcha-secret', + captchaResult: 'testcaptcha-passed', + }), + { + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: true, + }, + ); + }); + }); + + describe('[failure] 検証に失敗した場合は保存できない+設定値の更新そのものが発生しない', () => { + async function assertFailure(code: CaptchaErrorCode, promise: Promise) { + const res = await promise; + expect(res.success).toBe(false); + if (!res.success) { + expect(res.error.code).toBe(code); + } + expect(metaService.update).not.toBeCalled(); + } + + describe('invalidParameters', () => { + test('hcaptcha', async () => { + await assertFailure( + captchaErrorCodes.invalidParameters, + service.save('hcaptcha', { + sitekey: 'hcaptcha-sitekey', + secret: 'hcaptcha-secret', + captchaResult: null, + }), + ); + }); + + test('mcaptcha', async () => { + await assertFailure( + captchaErrorCodes.invalidParameters, + service.save('mcaptcha', { + sitekey: 'mcaptcha-sitekey', + secret: 'mcaptcha-secret', + instanceUrl: host, + captchaResult: null, + }), + ); + }); + + test('recaptcha', async () => { + await assertFailure( + captchaErrorCodes.invalidParameters, + service.save('recaptcha', { + sitekey: 'recaptcha-sitekey', + secret: 'recaptcha-secret', + captchaResult: null, + }), + ); + }); + + test('turnstile', async () => { + await assertFailure( + captchaErrorCodes.invalidParameters, + service.save('turnstile', { + sitekey: 'turnstile-sitekey', + secret: 'turnstile-secret', + captchaResult: null, + }), + ); + }); + + test('testcaptcha', async () => { + await assertFailure( + captchaErrorCodes.invalidParameters, + service.save('testcaptcha', { + captchaResult: null, + }), + ); + }); + }); + + describe('requestFailed', () => { + beforeEach(() => { + failureHttpMock(); + }); + + test('hcaptcha', async () => { + await assertFailure( + captchaErrorCodes.requestFailed, + service.save('hcaptcha', { + sitekey: 'hcaptcha-sitekey', + secret: 'hcaptcha-secret', + captchaResult: 'hcaptcha-passed', + }), + ); + }); + + test('mcaptcha', async () => { + await assertFailure( + captchaErrorCodes.requestFailed, + service.save('mcaptcha', { + sitekey: 'mcaptcha-sitekey', + secret: 'mcaptcha-secret', + instanceUrl: host, + captchaResult: 'mcaptcha-passed', + }), + ); + }); + + test('recaptcha', async () => { + await assertFailure( + captchaErrorCodes.requestFailed, + service.save('recaptcha', { + sitekey: 'recaptcha-sitekey', + secret: 'recaptcha-secret', + captchaResult: 'recaptcha-passed', + }), + ); + }); + + test('turnstile', async () => { + await assertFailure( + captchaErrorCodes.requestFailed, + service.save('turnstile', { + sitekey: 'turnstile-sitekey', + secret: 'turnstile-secret', + captchaResult: 'turnstile-passed', + }), + ); + }); + + // testchapchaはrequestFailedがない + }); + + describe('verificationFailed', () => { + beforeEach(() => { + failureVerificationMock({ success: false, valid: false, 'error-codes': ['code01', 'code02'] }); + }); + + test('hcaptcha', async () => { + await assertFailure( + captchaErrorCodes.verificationFailed, + service.save('hcaptcha', { + sitekey: 'hcaptcha-sitekey', + secret: 'hcaptcha-secret', + captchaResult: 'hccaptcha-passed', + }), + ); + }); + + test('mcaptcha', async () => { + await assertFailure( + captchaErrorCodes.verificationFailed, + service.save('mcaptcha', { + sitekey: 'mcaptcha-sitekey', + secret: 'mcaptcha-secret', + instanceUrl: host, + captchaResult: 'mcaptcha-passed', + }), + ); + }); + + test('recaptcha', async () => { + await assertFailure( + captchaErrorCodes.verificationFailed, + service.save('recaptcha', { + sitekey: 'recaptcha-sitekey', + secret: 'recaptcha-secret', + captchaResult: 'recaptcha-passed', + }), + ); + }); + + test('turnstile', async () => { + await assertFailure( + captchaErrorCodes.verificationFailed, + service.save('turnstile', { + sitekey: 'turnstile-sitekey', + secret: 'turnstile-secret', + captchaResult: 'turnstile-passed', + }), + ); + }); + + test('testcaptcha', async () => { + await assertFailure( + captchaErrorCodes.verificationFailed, + service.save('testcaptcha', { + captchaResult: 'testcaptcha-failed', + }), + ); + }); + }); + }); + }); +}); diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index 264cf9af06..b1167bbac6 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -30,6 +30,9 @@ import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmount import { defaultStore } from '@/store.js'; // APIs provided by Captcha services +// see: https://docs.hcaptcha.com/configuration/#javascript-api +// see: https://developers.google.com/recaptcha/docs/display?hl=ja +// see: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#explicitly-render-the-turnstile-widget export type Captcha = { render(container: string | Node, options: { readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown; @@ -53,6 +56,7 @@ declare global { const props = defineProps<{ provider: CaptchaProvider; sitekey: string | null; // null will show error on request + secretKey?: string | null; instanceUrl?: string | null; modelValue?: string | null; }>(); @@ -64,7 +68,7 @@ const emit = defineEmits<{ const available = ref(false); const captchaEl = shallowRef(); - +const captchaWidgetId = ref(undefined); const testcaptchaInput = ref(''); const testcaptchaPassed = ref(false); @@ -94,6 +98,15 @@ const scriptId = computed(() => `script-${props.provider}`); const captcha = computed(() => window[variable.value] || {} as unknown as Captcha); +watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => { + // 変更があったときはリフレッシュと再レンダリングをしておかないと、変更後の値で再検証が出来ない + if (available.value) { + callback(undefined); + clearWidget(); + await requestRender(); + } +}); + if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') { available.value = true; } else if (src.value !== null) { @@ -106,14 +119,38 @@ if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') } function reset() { - if (captcha.value.reset) captcha.value.reset(); + if (captcha.value.reset && captchaWidgetId.value !== undefined) { + try { + captcha.value.reset(captchaWidgetId.value); + } catch (error: unknown) { + // ignore + if (_DEV_) console.warn(error); + } + } testcaptchaPassed.value = false; testcaptchaInput.value = ''; } +function remove() { + if (captcha.value.remove && captchaWidgetId.value) { + try { + if (_DEV_) console.log('remove', props.provider, captchaWidgetId.value); + captcha.value.remove(captchaWidgetId.value); + } catch (error: unknown) { + // ignore + if (_DEV_) console.warn(error); + } + } +} + async function requestRender() { - if (captcha.value.render && captchaEl.value instanceof Element) { - captcha.value.render(captchaEl.value, { + if (captcha.value.render && captchaEl.value instanceof Element && props.sitekey) { + // reCAPTCHAのレンダリング重複判定を回避するため、captchaEl配下に仮のdivを用意する. + // (同じdivに対して複数回renderを呼び出すとreCAPTCHAはエラーを返すので) + const elem = document.createElement('div'); + captchaEl.value.appendChild(elem); + + captchaWidgetId.value = captcha.value.render(elem, { sitekey: props.sitekey, theme: defaultStore.state.darkMode ? 'dark' : 'light', callback: callback, @@ -133,6 +170,23 @@ async function requestRender() { } } +function clearWidget() { + if (props.provider === 'mcaptcha') { + const container = document.getElementById('mcaptcha__widget-container'); + if (container) { + container.innerHTML = ''; + } + } else { + reset(); + remove(); + + if (captchaEl.value) { + // レンダリング先のコンテナの中身を掃除し、フォームが増殖するのを抑止 + captchaEl.value.innerHTML = ''; + } + } +} + function callback(response?: string) { emit('update:modelValue', typeof response === 'string' ? response : null); } @@ -165,7 +219,7 @@ onUnmounted(() => { }); onBeforeUnmount(() => { - reset(); + clearWidget(); }); defineExpose({ diff --git a/packages/frontend/src/components/MkFormFooter.vue b/packages/frontend/src/components/MkFormFooter.vue index f409f6ce50..96214a9542 100644 --- a/packages/frontend/src/components/MkFormFooter.vue +++ b/packages/frontend/src/components/MkFormFooter.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.tsx.thereAreNChanges({ n: form.modifiedCount.value }) }}
{{ i18n.ts.discard }} - {{ i18n.ts.save }} + {{ i18n.ts.save }}
@@ -18,7 +18,7 @@ import { } from 'vue'; import MkButton from './MkButton.vue'; import { i18n } from '@/i18n.js'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ form: { modifiedCount: { value: number; @@ -26,7 +26,10 @@ const props = defineProps<{ discard: () => void; save: () => void; }; -}>(); + canSaving?: boolean; +}>(), { + canSaving: true, +}); diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 50002f1983..3ff8fdc844 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -136,6 +136,12 @@ type AdminAvatarDecorationsListResponse = operations['admin___avatar-decorations // @public (undocumented) type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json']; +// @public (undocumented) +type AdminCaptchaCurrentResponse = operations['admin___captcha___current']['responses']['200']['content']['application/json']; + +// @public (undocumented) +type AdminCaptchaSaveRequest = operations['admin___captcha___save']['requestBody']['content']['application/json']; + // @public (undocumented) type AdminDeleteAccountRequest = operations['admin___delete-account']['requestBody']['content']['application/json']; @@ -1261,6 +1267,8 @@ declare namespace entities { AdminAvatarDecorationsListRequest, AdminAvatarDecorationsListResponse, AdminAvatarDecorationsUpdateRequest, + AdminCaptchaCurrentResponse, + AdminCaptchaSaveRequest, AdminDeleteAllFilesOfAUserRequest, AdminUnsetUserAvatarRequest, AdminUnsetUserBannerRequest, diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 1837f3db4f..3bcdae6a4a 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -250,6 +250,28 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:meta* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:meta* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index cb1f4dbe96..b016d5bbcf 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -36,6 +36,8 @@ import type { AdminAvatarDecorationsListRequest, AdminAvatarDecorationsListResponse, AdminAvatarDecorationsUpdateRequest, + AdminCaptchaCurrentResponse, + AdminCaptchaSaveRequest, AdminDeleteAllFilesOfAUserRequest, AdminUnsetUserAvatarRequest, AdminUnsetUserBannerRequest, @@ -604,6 +606,8 @@ export type Endpoints = { 'admin/avatar-decorations/delete': { req: AdminAvatarDecorationsDeleteRequest; res: EmptyResponse }; 'admin/avatar-decorations/list': { req: AdminAvatarDecorationsListRequest; res: AdminAvatarDecorationsListResponse }; 'admin/avatar-decorations/update': { req: AdminAvatarDecorationsUpdateRequest; res: EmptyResponse }; + 'admin/captcha/current': { req: EmptyRequest; res: AdminCaptchaCurrentResponse }; + 'admin/captcha/save': { req: AdminCaptchaSaveRequest; res: EmptyResponse }; 'admin/delete-all-files-of-a-user': { req: AdminDeleteAllFilesOfAUserRequest; res: EmptyResponse }; 'admin/unset-user-avatar': { req: AdminUnsetUserAvatarRequest; res: EmptyResponse }; 'admin/unset-user-banner': { req: AdminUnsetUserBannerRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index a8f474c25c..02be4848c7 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -39,6 +39,8 @@ export type AdminAvatarDecorationsDeleteRequest = operations['admin___avatar-dec export type AdminAvatarDecorationsListRequest = operations['admin___avatar-decorations___list']['requestBody']['content']['application/json']; export type AdminAvatarDecorationsListResponse = operations['admin___avatar-decorations___list']['responses']['200']['content']['application/json']; export type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json']; +export type AdminCaptchaCurrentResponse = operations['admin___captcha___current']['responses']['200']['content']['application/json']; +export type AdminCaptchaSaveRequest = operations['admin___captcha___save']['requestBody']['content']['application/json']; export type AdminDeleteAllFilesOfAUserRequest = operations['admin___delete-all-files-of-a-user']['requestBody']['content']['application/json']; export type AdminUnsetUserAvatarRequest = operations['admin___unset-user-avatar']['requestBody']['content']['application/json']; export type AdminUnsetUserBannerRequest = operations['admin___unset-user-banner']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 42ca05e057..e6a9df3f5a 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -215,6 +215,24 @@ export type paths = { */ post: operations['admin___avatar-decorations___update']; }; + '/admin/captcha/current': { + /** + * admin/captcha/current + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:meta* + */ + post: operations['admin___captcha___current']; + }; + '/admin/captcha/save': { + /** + * admin/captcha/save + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:meta* + */ + post: operations['admin___captcha___save']; + }; '/admin/delete-all-files-of-a-user': { /** * admin/delete-all-files-of-a-user @@ -6564,6 +6582,128 @@ export type operations = { }; }; }; + /** + * admin/captcha/current + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:meta* + */ + admin___captcha___current: { + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': { + /** @enum {string} */ + provider: 'none' | 'hcaptcha' | 'mcaptcha' | 'recaptcha' | 'turnstile' | 'testcaptcha'; + hcaptcha: { + siteKey: string | null; + secretKey: string | null; + }; + mcaptcha: { + siteKey: string | null; + secretKey: string | null; + instanceUrl: string | null; + }; + recaptcha: { + siteKey: string | null; + secretKey: string | null; + }; + turnstile: { + siteKey: string | null; + secretKey: string | null; + }; + }; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * admin/captcha/save + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:meta* + */ + admin___captcha___save: { + requestBody: { + content: { + 'application/json': { + /** @enum {string} */ + provider: 'none' | 'hcaptcha' | 'mcaptcha' | 'recaptcha' | 'turnstile' | 'testcaptcha'; + captchaResult?: string | null; + sitekey?: string | null; + secret?: string | null; + instanceUrl?: string | null; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * admin/delete-all-files-of-a-user * @description No description provided. -- cgit v1.2.3-freya From 5445b023e5cedb7228710637c895c63328e3db74 Mon Sep 17 00:00:00 2001 From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Tue, 14 Jan 2025 20:08:54 +0900 Subject: enhance: 連合モードにあわせてフロントエンドを変化させるように (#15112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance(backend): metaにfederation modeに関する情報を公開 * enhance(frontend): 登録画面の注意書きを追加 * enhance(frontend): aboutページ・サーバー情報 * enhance(frontend): サーバー統計 * enhance(frontend): みつけるページ * enhance(frontend): 検索 * enhance(frontend): ユーザー選択 * enhance(frontend): 設定画面 * enhance(frontend): ウィジェット * enhance(frontend): リモートで開くオプション * Update Changelog * enhance(frontend): ステータスバー * i18n --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- CHANGELOG.md | 1 + locales/index.d.ts | 8 ++++ locales/ja-JP.yml | 2 + .../backend/src/core/entities/MetaEntityService.ts | 1 + packages/backend/src/models/json-schema/meta.ts | 5 +++ .../frontend/src/components/MkInstanceStats.vue | 18 +++++---- .../src/components/MkSignupDialog.rules.vue | 6 ++- .../frontend/src/components/MkUserSelectDialog.vue | 11 +++-- .../frontend/src/components/MkVisitorDashboard.vue | 6 ++- packages/frontend/src/components/MkWidgets.vue | 19 +++++++-- packages/frontend/src/pages/about.vue | 47 ++++++++++++++-------- packages/frontend/src/pages/explore.users.vue | 3 +- packages/frontend/src/pages/search.note.vue | 22 +++++----- packages/frontend/src/pages/search.user.vue | 5 ++- packages/frontend/src/pages/settings/general.vue | 3 +- .../frontend/src/pages/settings/mute-block.vue | 4 +- packages/frontend/src/pages/settings/privacy.vue | 9 +++-- .../src/pages/settings/statusbar.statusbar.vue | 3 +- packages/frontend/src/scripts/please-login.ts | 12 +++++- packages/frontend/src/ui/_common_/common.ts | 18 ++++++--- packages/frontend/src/ui/_common_/statusbars.vue | 3 +- packages/frontend/src/widgets/index.ts | 10 ++++- packages/misskey-js/src/autogen/types.ts | 2 + 23 files changed, 150 insertions(+), 68 deletions(-) (limited to 'packages/backend/src/core') diff --git a/CHANGELOG.md b/CHANGELOG.md index af5d333927..287d390453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Enhance: PC画面でチャンネルが複数列で表示されるように (Cherry-picked from https://github.com/Otaku-Social/maniakey/pull/13) - Enhance: 照会に失敗した場合、その理由を表示するように +- Enhance: 連合がホワイトリスト化・無効化されているサーバー向けのデザイン修正 - Enhance: AiScriptのセーブデータを明示的に削除する関数`Mk:remove`を追加 - Enhance: AiScriptの拡張API関数において引数の型チェックをより厳格に - Fix: 画面サイズが変わった際にナビゲーションバーが自動で折りたたまれない問題を修正 diff --git a/locales/index.d.ts b/locales/index.d.ts index 7c3ef5d93c..453d40feea 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5230,6 +5230,14 @@ export interface Locale extends ILocale { * 注意事項を理解した上でオンにします。 */ "acknowledgeNotesAndEnable": string; + /** + * このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。 + */ + "federationSpecified": string; + /** + * このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。 + */ + "federationDisabled": string; "_accountSettings": { /** * コンテンツの表示にログインを必須にする diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 57a88062c1..a3cb9d052a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1303,6 +1303,8 @@ lockdown: "ロックダウン" pleaseSelectAccount: "アカウントを選択してください" availableRoles: "利用可能なロール" acknowledgeNotesAndEnable: "注意事項を理解した上でオンにします。" +federationSpecified: "このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。" +federationDisabled: "このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。" _accountSettings: requireSigninToViewContents: "コンテンツの表示にログインを必須にする" diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 409dca3426..ec0b5360f4 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -132,6 +132,7 @@ export class MetaEntityService { enableUrlPreview: instance.urlPreviewEnabled, noteSearchableScope: (this.config.meilisearch == null || this.config.meilisearch.scope !== 'local') ? 'global' : 'local', maxFileSize: this.config.maxFileSize, + federation: this.meta.federation, }; return packed; diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index e3fd63464a..e7ae2ee8e5 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -261,6 +261,11 @@ export const packedMetaLiteSchema = { type: 'number', optional: false, nullable: false, }, + federation: { + type: 'string', + enum: ['all', 'specified', 'none'], + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index 8ccbf61e48..d8066857fe 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- + @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only - + @@ -46,9 +46,9 @@ SPDX-License-Identifier: AGPL-3.0-only - - - + + +
@@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
@@ -84,13 +84,15 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts b/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts new file mode 100644 index 0000000000..411d62edf9 --- /dev/null +++ b/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { http, HttpResponse } from 'msw'; +import { role } from '../../.storybook/fakes.js'; +import { commonHandlers } from '../../.storybook/mocks.js'; +import MkRoleSelectDialog from '@/components/MkRoleSelectDialog.vue'; + +const roles = [ + role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'), + role({ displayOrder: 2 }, '2'), role({ displayOrder: 2 }, '2'), role({ displayOrder: 3 }, '3'), role({ displayOrder: 3 }, '3'), + role({ displayOrder: 4 }, '4'), role({ displayOrder: 5 }, '5'), role({ displayOrder: 6 }, '6'), role({ displayOrder: 7 }, '7'), + role({ displayOrder: 999, name: 'privateRole', isPublic: false }, '999'), +]; + +export const Default = { + render(args) { + return { + components: { + MkRoleSelectDialog, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + initialRoleIds: undefined, + infoMessage: undefined, + title: undefined, + publicOnly: true, + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/admin/roles/list', ({ params }) => { + return HttpResponse.json(roles); + }), + ], + }, + }, + decorators: [() => ({ + template: '
', + })], +} satisfies StoryObj; + +export const InitialIds = { + ...Default, + args: { + ...Default.args, + initialRoleIds: [roles[0].id, roles[1].id, roles[4].id, roles[6].id, roles[8].id, roles[10].id], + }, +} satisfies StoryObj; + +export const InfoMessage = { + ...Default, + args: { + ...Default.args, + infoMessage: 'This is a message.', + }, +} satisfies StoryObj; + +export const Title = { + ...Default, + args: { + ...Default.args, + title: 'Select roles', + }, +} satisfies StoryObj; + +export const Full = { + ...Default, + args: { + ...Default.args, + initialRoleIds: roles.map(it => it.id), + infoMessage: InfoMessage.args.infoMessage, + title: Title.args.title, + }, +} satisfies StoryObj; + +export const FullWithPrivate = { + ...Default, + args: { + ...Default.args, + initialRoleIds: roles.map(it => it.id), + infoMessage: InfoMessage.args.infoMessage, + title: Title.args.title, + publicOnly: false, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue new file mode 100644 index 0000000000..67a7a3f752 --- /dev/null +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -0,0 +1,200 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkSortOrderEditor.define.ts b/packages/frontend/src/components/MkSortOrderEditor.define.ts new file mode 100644 index 0000000000..f023b5d72b --- /dev/null +++ b/packages/frontend/src/components/MkSortOrderEditor.define.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type SortOrderDirection = '+' | '-' + +export type SortOrder = { + key: T; + direction: SortOrderDirection; +} diff --git a/packages/frontend/src/components/MkSortOrderEditor.vue b/packages/frontend/src/components/MkSortOrderEditor.vue new file mode 100644 index 0000000000..da08f12297 --- /dev/null +++ b/packages/frontend/src/components/MkSortOrderEditor.vue @@ -0,0 +1,112 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkTagItem.stories.impl.ts b/packages/frontend/src/components/MkTagItem.stories.impl.ts new file mode 100644 index 0000000000..3f243ff651 --- /dev/null +++ b/packages/frontend/src/components/MkTagItem.stories.impl.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import MkTagItem from './MkTagItem.vue'; + +export const Default = { + render(args) { + return { + components: { + MkTagItem: MkTagItem, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + click: action('click'), + exButtonClick: action('exButtonClick'), + }; + }, + }, + template: '', + }; + }, + args: { + content: 'name', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; + +export const Icon = { + ...Default, + args: { + ...Default.args, + iconClass: 'ti ti-arrow-up', + }, +} satisfies StoryObj; + +export const ExButton = { + ...Default, + args: { + ...Default.args, + exButtonIconClass: 'ti ti-x', + }, +} satisfies StoryObj; + +export const IconExButton = { + ...Default, + args: { + ...Default.args, + iconClass: 'ti ti-arrow-up', + exButtonIconClass: 'ti ti-x', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkTagItem.vue b/packages/frontend/src/components/MkTagItem.vue new file mode 100644 index 0000000000..98f2411392 --- /dev/null +++ b/packages/frontend/src/components/MkTagItem.vue @@ -0,0 +1,76 @@ + + + + + + + diff --git a/packages/frontend/src/components/grid/MkCellTooltip.vue b/packages/frontend/src/components/grid/MkCellTooltip.vue new file mode 100644 index 0000000000..fd289c6cd9 --- /dev/null +++ b/packages/frontend/src/components/grid/MkCellTooltip.vue @@ -0,0 +1,35 @@ + + + + + + + diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue new file mode 100644 index 0000000000..0ffd42abda --- /dev/null +++ b/packages/frontend/src/components/grid/MkDataCell.vue @@ -0,0 +1,391 @@ + + + + + + + diff --git a/packages/frontend/src/components/grid/MkDataRow.vue b/packages/frontend/src/components/grid/MkDataRow.vue new file mode 100644 index 0000000000..280a14bc4a --- /dev/null +++ b/packages/frontend/src/components/grid/MkDataRow.vue @@ -0,0 +1,72 @@ + + + + + + + diff --git a/packages/frontend/src/components/grid/MkGrid.stories.impl.ts b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts new file mode 100644 index 0000000000..5801012f15 --- /dev/null +++ b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts @@ -0,0 +1,223 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import { ref } from 'vue'; +import { commonHandlers } from '../../../.storybook/mocks.js'; +import { boolean, choose, country, date, firstName, integer, lastName, text } from '../../../.storybook/fake-utils.js'; +import MkGrid from './MkGrid.vue'; +import { GridContext, GridEvent } from '@/components/grid/grid-event.js'; +import { DataSource, GridSetting } from '@/components/grid/grid.js'; +import { GridColumnSetting } from '@/components/grid/column.js'; + +function d(p: { + check?: boolean, + name?: string, + email?: string, + age?: number, + birthday?: string, + gender?: string, + country?: string, + reportCount?: number, + createdAt?: string, +}, seed: string) { + const prefix = text(10, seed); + + return { + check: p.check ?? boolean(seed), + name: p.name ?? `${firstName(seed)} ${lastName(seed)}`, + email: p.email ?? `${prefix}@example.com`, + age: p.age ?? integer(20, 80, seed), + birthday: date({}, seed).toISOString(), + gender: p.gender ?? choose(['male', 'female', 'other', 'unknown'], seed), + country: p.country ?? country(seed), + reportCount: p.reportCount ?? integer(0, 9999, seed), + createdAt: p.createdAt ?? date({}, seed).toISOString(), + }; +} + +const defaultCols: GridColumnSetting[] = [ + { bindTo: 'check', icon: 'ti-check', type: 'boolean', width: 50 }, + { bindTo: 'name', title: 'Name', type: 'text', width: 'auto' }, + { bindTo: 'email', title: 'Email', type: 'text', width: 'auto' }, + { bindTo: 'age', title: 'Age', type: 'number', width: 50 }, + { bindTo: 'birthday', title: 'Birthday', type: 'date', width: 'auto' }, + { bindTo: 'gender', title: 'Gender', type: 'text', width: 80 }, + { bindTo: 'country', title: 'Country', type: 'text', width: 120 }, + { bindTo: 'reportCount', title: 'ReportCount', type: 'number', width: 'auto' }, + { bindTo: 'createdAt', title: 'CreatedAt', type: 'date', width: 'auto' }, +]; + +function createArgs(overrides?: { settings?: Partial, data?: DataSource[] }) { + const refData = ref[]>([]); + for (let i = 0; i < 100; i++) { + refData.value.push(d({}, i.toString())); + } + + return { + settings: { + row: overrides?.settings?.row, + cols: [ + ...defaultCols.filter(col => overrides?.settings?.cols?.every(c => c.bindTo !== col.bindTo) ?? true), + ...overrides?.settings?.cols ?? [], + ], + cells: overrides?.settings?.cells, + }, + data: refData.value, + }; +} + +function createRender(params: { settings: GridSetting, data: DataSource[] }) { + return { + render(args) { + return { + components: { + MkGrid, + }, + setup() { + return { + args, + }; + }, + data() { + return { + data: args.data, + }; + }, + computed: { + props() { + return { + ...args, + }; + }, + events() { + return { + event: (event: GridEvent, context: GridContext) => { + switch (event.type) { + case 'cell-value-change': { + args.data[event.row.index][event.column.setting.bindTo] = event.newValue; + } + } + }, + }; + }, + }, + template: '
', + }; + }, + args: { + ...params, + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + ], + }, + }, + } satisfies StoryObj; +} + +export const Default = createRender(createArgs()); + +export const NoNumber = createRender(createArgs({ + settings: { + row: { + showNumber: false, + }, + }, +})); + +export const NoSelectable = createRender(createArgs({ + settings: { + row: { + selectable: false, + }, + }, +})); + +export const Editable = createRender(createArgs({ + settings: { + cols: defaultCols.map(col => ({ ...col, editable: true })), + }, +})); + +export const AdditionalRowStyle = createRender(createArgs({ + settings: { + cols: defaultCols.map(col => ({ ...col, editable: true })), + row: { + styleRules: [ + { + condition: ({ row }) => AdditionalRowStyle.args.data[row.index].check as boolean, + applyStyle: { + style: { + backgroundColor: 'lightgray', + }, + }, + }, + ], + }, + }, +})); + +export const ContextMenu = createRender(createArgs({ + settings: { + cols: [ + { + bindTo: 'check', icon: 'ti-check', type: 'boolean', width: 50, contextMenuFactory: (col, context) => [ + { + type: 'button', + text: 'Check All', + action: () => { + for (const d of ContextMenu.args.data) { + d.check = true; + } + }, + }, + { + type: 'button', + text: 'Uncheck All', + action: () => { + for (const d of ContextMenu.args.data) { + d.check = false; + } + }, + }, + ], + }, + ], + row: { + contextMenuFactory: (row, context) => [ + { + type: 'button', + text: 'Delete', + action: () => { + const idxes = context.rangedRows.map(r => r.index); + const newData = ContextMenu.args.data.filter((d, i) => !idxes.includes(i)); + + ContextMenu.args.data.splice(0); + ContextMenu.args.data.push(...newData); + }, + }, + ], + }, + cells: { + contextMenuFactory: (col, row, value, context) => [ + { + type: 'button', + text: 'Delete', + action: () => { + for (const cell of context.rangedCells) { + ContextMenu.args.data[cell.row.index][cell.column.setting.bindTo] = undefined; + } + }, + }, + ], + }, + }, +})); diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue new file mode 100644 index 0000000000..60738365fb --- /dev/null +++ b/packages/frontend/src/components/grid/MkGrid.vue @@ -0,0 +1,1342 @@ + + + + + + + + + diff --git a/packages/frontend/src/components/grid/MkHeaderCell.vue b/packages/frontend/src/components/grid/MkHeaderCell.vue new file mode 100644 index 0000000000..605d27c6d6 --- /dev/null +++ b/packages/frontend/src/components/grid/MkHeaderCell.vue @@ -0,0 +1,216 @@ + + + + + + + diff --git a/packages/frontend/src/components/grid/MkHeaderRow.vue b/packages/frontend/src/components/grid/MkHeaderRow.vue new file mode 100644 index 0000000000..8affa08fd5 --- /dev/null +++ b/packages/frontend/src/components/grid/MkHeaderRow.vue @@ -0,0 +1,60 @@ + + + + + + + diff --git a/packages/frontend/src/components/grid/MkNumberCell.vue b/packages/frontend/src/components/grid/MkNumberCell.vue new file mode 100644 index 0000000000..674bba96bc --- /dev/null +++ b/packages/frontend/src/components/grid/MkNumberCell.vue @@ -0,0 +1,61 @@ + + + + + + + diff --git a/packages/frontend/src/components/grid/cell-validators.ts b/packages/frontend/src/components/grid/cell-validators.ts new file mode 100644 index 0000000000..949cab2ec6 --- /dev/null +++ b/packages/frontend/src/components/grid/cell-validators.ts @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridColumn } from '@/components/grid/column.js'; +import { GridRow } from '@/components/grid/row.js'; +import { i18n } from '@/i18n.js'; + +export type ValidatorParams = { + column: GridColumn; + row: GridRow; + value: CellValue; + allCells: GridCell[]; +}; + +export type ValidatorResult = { + valid: boolean; + message?: string; +} + +export type GridCellValidator = { + name?: string; + ignoreViolation?: boolean; + validate: (params: ValidatorParams) => ValidatorResult; +} + +export type ValidateViolation = { + valid: boolean; + params: ValidatorParams; + violations: ValidateViolationItem[]; +} + +export type ValidateViolationItem = { + valid: boolean; + validator: GridCellValidator; + result: ValidatorResult; +} + +export function cellValidation(allCells: GridCell[], cell: GridCell, newValue: CellValue): ValidateViolation { + const { column, row } = cell; + const validators = column.setting.validators ?? []; + + const params: ValidatorParams = { + column, + row, + value: newValue, + allCells, + }; + + const violations: ValidateViolationItem[] = validators.map(validator => { + const result = validator.validate(params); + return { + valid: result.valid, + validator, + result, + }; + }); + + return { + valid: violations.every(v => v.result.valid), + params, + violations, + }; +} + +class ValidatorPreset { + required(): GridCellValidator { + return { + name: 'required', + validate: ({ value }): ValidatorResult => { + return { + valid: value !== null && value !== undefined && value !== '', + message: i18n.ts._gridComponent._error.requiredValue, + }; + }, + }; + } + + regex(pattern: RegExp): GridCellValidator { + return { + name: 'regex', + validate: ({ value }): ValidatorResult => { + return { + valid: (typeof value !== 'string') || pattern.test(value.toString() ?? ''), + message: i18n.tsx._gridComponent._error.patternNotMatch({ pattern: pattern.source }), + }; + }, + }; + } + + unique(): GridCellValidator { + return { + name: 'unique', + validate: ({ column, row, value, allCells }): ValidatorResult => { + const bindTo = column.setting.bindTo; + const isUnique = allCells + .filter(it => it.column.setting.bindTo === bindTo && it.row.index !== row.index) + .every(cell => cell.value !== value); + return { + valid: isUnique, + message: i18n.ts._gridComponent._error.notUnique, + }; + }, + }; + } +} + +export const validators = new ValidatorPreset(); diff --git a/packages/frontend/src/components/grid/cell.ts b/packages/frontend/src/components/grid/cell.ts new file mode 100644 index 0000000000..71b7a3e3f1 --- /dev/null +++ b/packages/frontend/src/components/grid/cell.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ValidateViolation } from '@/components/grid/cell-validators.js'; +import { Size } from '@/components/grid/grid.js'; +import { GridColumn } from '@/components/grid/column.js'; +import { GridRow } from '@/components/grid/row.js'; +import { MenuItem } from '@/types/menu.js'; +import { GridContext } from '@/components/grid/grid-event.js'; + +export type CellValue = string | boolean | number | undefined | null | Array | NonNullable; + +export type CellAddress = { + row: number; + col: number; +} + +export const CELL_ADDRESS_NONE: CellAddress = { + row: -1, + col: -1, +}; + +export type GridCell = { + address: CellAddress; + value: CellValue; + column: GridColumn; + row: GridRow; + selected: boolean; + ranged: boolean; + contentSize: Size; + setting: GridCellSetting; + violation: ValidateViolation; +} + +export type GridCellContextMenuFactory = (col: GridColumn, row: GridRow, value: CellValue, context: GridContext) => MenuItem[]; + +export type GridCellSetting = { + contextMenuFactory?: GridCellContextMenuFactory; +} + +export function createCell( + column: GridColumn, + row: GridRow, + value: CellValue, + setting: GridCellSetting, +): GridCell { + const newValue = (row.using && column.setting.valueTransformer) + ? column.setting.valueTransformer(row, column, value) + : value; + + return { + address: { row: row.index, col: column.index }, + value: newValue, + column, + row, + selected: false, + ranged: false, + contentSize: { width: 0, height: 0 }, + violation: { + valid: true, + params: { + column, + row, + value, + allCells: [], + }, + violations: [], + }, + setting, + }; +} + +export function resetCell(cell: GridCell): void { + cell.selected = false; + cell.ranged = false; + cell.violation = { + valid: true, + params: { + column: cell.column, + row: cell.row, + value: cell.value, + allCells: [], + }, + violations: [], + }; +} diff --git a/packages/frontend/src/components/grid/column.ts b/packages/frontend/src/components/grid/column.ts new file mode 100644 index 0000000000..2f505756fe --- /dev/null +++ b/packages/frontend/src/components/grid/column.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { GridCellValidator } from '@/components/grid/cell-validators.js'; +import { Size, SizeStyle } from '@/components/grid/grid.js'; +import { calcCellWidth } from '@/components/grid/grid-utils.js'; +import { CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridRow } from '@/components/grid/row.js'; +import { MenuItem } from '@/types/menu.js'; +import { GridContext } from '@/components/grid/grid-event.js'; + +export type ColumnType = 'text' | 'number' | 'date' | 'boolean' | 'image' | 'hidden'; + +export type CustomValueEditor = (row: GridRow, col: GridColumn, value: CellValue, cellElement: HTMLElement) => Promise; +export type CellValueTransformer = (row: GridRow, col: GridColumn, value: CellValue) => CellValue; +export type GridColumnContextMenuFactory = (col: GridColumn, context: GridContext) => MenuItem[]; + +export type GridColumnSetting = { + bindTo: string; + title?: string; + icon?: string; + type: ColumnType; + width: SizeStyle; + editable?: boolean; + validators?: GridCellValidator[]; + customValueEditor?: CustomValueEditor; + valueTransformer?: CellValueTransformer; + contextMenuFactory?: GridColumnContextMenuFactory; + events?: { + copy?: (value: CellValue) => string; + paste?: (text: string) => CellValue; + delete?: (cell: GridCell, context: GridContext) => void; + } +}; + +export type GridColumn = { + index: number; + setting: GridColumnSetting; + width: string; + contentSize: Size; +} + +export function createColumn(setting: GridColumnSetting, index: number): GridColumn { + return { + index, + setting, + width: calcCellWidth(setting.width), + contentSize: { width: 0, height: 0 }, + }; +} + diff --git a/packages/frontend/src/components/grid/grid-event.ts b/packages/frontend/src/components/grid/grid-event.ts new file mode 100644 index 0000000000..074b72b956 --- /dev/null +++ b/packages/frontend/src/components/grid/grid-event.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridState } from '@/components/grid/grid.js'; +import { ValidateViolation } from '@/components/grid/cell-validators.js'; +import { GridColumn } from '@/components/grid/column.js'; +import { GridRow } from '@/components/grid/row.js'; + +export type GridContext = { + selectedCell?: GridCell; + rangedCells: GridCell[]; + rangedRows: GridRow[]; + randedBounds: { + leftTop: CellAddress; + rightBottom: CellAddress; + }; + availableBounds: { + leftTop: CellAddress; + rightBottom: CellAddress; + }; + state: GridState; + rows: GridRow[]; + columns: GridColumn[]; +}; + +export type GridEvent = + GridCellValueChangeEvent | + GridCellValidationEvent + ; + +export type GridCellValueChangeEvent = { + type: 'cell-value-change'; + column: GridColumn; + row: GridRow; + oldValue: CellValue; + newValue: CellValue; +}; + +export type GridCellValidationEvent = { + type: 'cell-validation'; + violation?: ValidateViolation; + all: ValidateViolation[]; +}; diff --git a/packages/frontend/src/components/grid/grid-utils.ts b/packages/frontend/src/components/grid/grid-utils.ts new file mode 100644 index 0000000000..a45bc88926 --- /dev/null +++ b/packages/frontend/src/components/grid/grid-utils.ts @@ -0,0 +1,215 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { isRef, Ref } from 'vue'; +import { DataSource, SizeStyle } from '@/components/grid/grid.js'; +import { CELL_ADDRESS_NONE, CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridRow } from '@/components/grid/row.js'; +import { GridContext } from '@/components/grid/grid-event.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { GridColumn, GridColumnSetting } from '@/components/grid/column.js'; + +export function isCellElement(elem: HTMLElement): boolean { + return elem.hasAttribute('data-grid-cell'); +} + +export function isRowElement(elem: HTMLElement): boolean { + return elem.hasAttribute('data-grid-row'); +} + +export function calcCellWidth(widthSetting: SizeStyle): string { + switch (widthSetting) { + case undefined: + case 'auto': { + return 'auto'; + } + default: { + return `${widthSetting}px`; + } + } +} + +function getCellRowByAttribute(elem: HTMLElement): number { + const row = elem.getAttribute('data-grid-cell-row'); + if (row === null) { + throw new Error('data-grid-cell-row attribute not found'); + } + return Number(row); +} + +function getCellColByAttribute(elem: HTMLElement): number { + const col = elem.getAttribute('data-grid-cell-col'); + if (col === null) { + throw new Error('data-grid-cell-col attribute not found'); + } + return Number(col); +} + +export function getCellAddress(elem: HTMLElement, parentNodeCount = 10): CellAddress { + let node = elem; + for (let i = 0; i < parentNodeCount; i++) { + if (!node.parentElement) { + break; + } + + if (isCellElement(node) && isRowElement(node.parentElement)) { + const row = getCellRowByAttribute(node); + const col = getCellColByAttribute(node); + + return { row, col }; + } + + node = node.parentElement; + } + + return CELL_ADDRESS_NONE; +} + +export function getCellElement(elem: HTMLElement, parentNodeCount = 10): HTMLElement | null { + let node = elem; + for (let i = 0; i < parentNodeCount; i++) { + if (isCellElement(node)) { + return node; + } + + if (!node.parentElement) { + break; + } + + node = node.parentElement; + } + + return null; +} + +export function equalCellAddress(a: CellAddress, b: CellAddress): boolean { + return a.row === b.row && a.col === b.col; +} + +/** + * グリッドの選択範囲の内容をタブ区切り形式テキストに変換してクリップボードにコピーする。 + */ +export function copyGridDataToClipboard( + gridItems: Ref | DataSource[], + context: GridContext, +) { + const items = isRef(gridItems) ? gridItems.value : gridItems; + const lines = Array.of(); + const bounds = context.randedBounds; + + for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) { + const rowItems = Array.of(); + for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) { + const { bindTo, events } = context.columns[col].setting; + const value = items[row][bindTo]; + const transformValue = events?.copy + ? events.copy(value) + : typeof value === 'object' || Array.isArray(value) + ? JSON.stringify(value) + : value?.toString() ?? ''; + rowItems.push(transformValue); + } + lines.push(rowItems.join('\t')); + } + + const text = lines.join('\n'); + copyToClipboard(text); + + if (_DEV_) { + console.log(`Copied to clipboard: ${text}`); + } +} + +/** + * クリップボードからタブ区切りテキストとして値を読み取り、グリッドの選択範囲に貼り付けるためのユーティリティ関数。 + * …と言いつつも、使用箇所により反映方法に差があるため更新操作はコールバック関数に任せている。 + */ +export async function pasteToGridFromClipboard( + context: GridContext, + callback: (row: GridRow, col: GridColumn, parsedValue: CellValue) => void, +) { + function parseValue(value: string, setting: GridColumnSetting): CellValue { + if (setting.events?.paste) { + return setting.events.paste(value); + } else { + switch (setting.type) { + case 'number': { + return Number(value); + } + case 'boolean': { + return value === 'true'; + } + default: { + return value; + } + } + } + } + + const clipBoardText = await navigator.clipboard.readText(); + if (_DEV_) { + console.log(`Paste from clipboard: ${clipBoardText}`); + } + + const bounds = context.randedBounds; + const lines = clipBoardText.replace(/\r/g, '') + .split('\n') + .map(it => it.split('\t')); + + if (lines.length === 1 && lines[0].length === 1) { + // 単独文字列の場合は選択範囲全体に同じテキストを貼り付ける + const ranges = context.rangedCells; + for (const cell of ranges) { + if (cell.column.setting.editable) { + callback(cell.row, cell.column, parseValue(lines[0][0], cell.column.setting)); + } + } + } else { + // 表形式文字列の場合は表形式にパースし、選択範囲に合うように貼り付ける + const offsetRow = bounds.leftTop.row; + const offsetCol = bounds.leftTop.col; + const { columns, rows } = context; + for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) { + const rowIdx = row - offsetRow; + if (lines.length <= rowIdx) { + // クリップボードから読んだ二次元配列よりも選択範囲の方が大きい場合、貼り付け操作を打ち切る + break; + } + + const items = lines[rowIdx]; + for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) { + const colIdx = col - offsetCol; + if (items.length <= colIdx) { + // クリップボードから読んだ二次元配列よりも選択範囲の方が大きい場合、貼り付け操作を打ち切る + break; + } + + if (columns[col].setting.editable) { + callback(rows[row], columns[col], parseValue(items[colIdx], columns[col].setting)); + } + } + } + } +} + +/** + * グリッドの選択範囲にあるデータを削除するためのユーティリティ関数。 + * …と言いつつも、使用箇所により反映方法に差があるため更新操作はコールバック関数に任せている。 + */ +export function removeDataFromGrid( + context: GridContext, + callback: (cell: GridCell) => void, +) { + for (const cell of context.rangedCells) { + const { editable, events } = cell.column.setting; + if (editable) { + if (events?.delete) { + events.delete(cell, context); + } else { + callback(cell); + } + } + } +} diff --git a/packages/frontend/src/components/grid/grid.ts b/packages/frontend/src/components/grid/grid.ts new file mode 100644 index 0000000000..0cb3b6f28b --- /dev/null +++ b/packages/frontend/src/components/grid/grid.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { EventEmitter } from 'eventemitter3'; +import { CellValue, GridCellSetting } from '@/components/grid/cell.js'; +import { GridColumnSetting } from '@/components/grid/column.js'; +import { GridRowSetting } from '@/components/grid/row.js'; + +export type GridSetting = { + row?: GridRowSetting; + cols: GridColumnSetting[]; + cells?: GridCellSetting; +}; + +export type DataSource = Record; + +export type GridState = + 'normal' | + 'cellSelecting' | + 'cellEditing' | + 'colResizing' | + 'colSelecting' | + 'rowSelecting' | + 'hidden' + ; + +export type Size = { + width: number; + height: number; +} + +export type SizeStyle = number | 'auto' | undefined; + +export type AdditionalStyle = { + className?: string; + style?: Record; +} + +export class GridEventEmitter extends EventEmitter<{ + 'forceRefreshContentSize': void; +}> { +} diff --git a/packages/frontend/src/components/grid/row.ts b/packages/frontend/src/components/grid/row.ts new file mode 100644 index 0000000000..e0a317c9d3 --- /dev/null +++ b/packages/frontend/src/components/grid/row.ts @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { AdditionalStyle } from '@/components/grid/grid.js'; +import { GridCell } from '@/components/grid/cell.js'; +import { GridColumn } from '@/components/grid/column.js'; +import { MenuItem } from '@/types/menu.js'; +import { GridContext } from '@/components/grid/grid-event.js'; + +export const defaultGridRowSetting: Required = { + showNumber: true, + selectable: true, + minimumDefinitionCount: 100, + styleRules: [], + contextMenuFactory: () => [], + events: {}, +}; + +export type GridRowStyleRuleConditionParams = { + row: GridRow, + targetCols: GridColumn[], + cells: GridCell[] +}; + +export type GridRowStyleRule = { + condition: (params: GridRowStyleRuleConditionParams) => boolean; + applyStyle: AdditionalStyle; +} + +export type GridRowContextMenuFactory = (row: GridRow, context: GridContext) => MenuItem[]; + +export type GridRowSetting = { + showNumber?: boolean; + selectable?: boolean; + minimumDefinitionCount?: number; + styleRules?: GridRowStyleRule[]; + contextMenuFactory?: GridRowContextMenuFactory; + events?: { + delete?: (rows: GridRow[]) => void; + } +} + +export type GridRow = { + index: number; + ranged: boolean; + using: boolean; + setting: GridRowSetting; + additionalStyles: AdditionalStyle[]; +} + +export function createRow(index: number, using: boolean, setting: GridRowSetting): GridRow { + return { + index, + ranged: false, + using: using, + setting, + additionalStyles: [], + }; +} + +export function resetRow(row: GridRow): void { + row.ranged = false; + row.using = false; + row.additionalStyles = []; +} + diff --git a/packages/frontend/src/components/hook/useLoading.ts b/packages/frontend/src/components/hook/useLoading.ts new file mode 100644 index 0000000000..6c6ff6ae0d --- /dev/null +++ b/packages/frontend/src/components/hook/useLoading.ts @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { computed, h, ref } from 'vue'; +import MkLoading from '@/components/global/MkLoading.vue'; + +export const useLoading = (props?: { + static?: boolean; + inline?: boolean; + colored?: boolean; + mini?: boolean; + em?: boolean; +}) => { + const showingCnt = ref(0); + + const show = () => { + showingCnt.value++; + }; + + const close = (force?: boolean) => { + if (force) { + showingCnt.value = 0; + } else { + showingCnt.value = Math.max(0, showingCnt.value - 1); + } + }; + + const scope = (fn: () => T) => { + show(); + + const result = fn(); + if (result instanceof Promise) { + return result.finally(() => close()); + } else { + close(); + return result; + } + }; + + const showing = computed(() => showingCnt.value > 0); + const component = computed(() => showing.value ? h(MkLoading, props) : null); + + return { + show, + close, + scope, + component, + showing, + }; +}; diff --git a/packages/frontend/src/index.html b/packages/frontend/src/index.html index 0be589262f..84ba9dfabc 100644 --- a/packages/frontend/src/index.html +++ b/packages/frontend/src/index.html @@ -20,6 +20,7 @@ worker-src 'self'; script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://*.recaptcha.net https://*.gstatic.com https://challenges.cloudflare.com https://esm.sh; style-src 'self' 'unsafe-inline'; + font-src 'self' data:; img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000; media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000; connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com; diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 589ace0155..18c7464d2e 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -602,6 +602,27 @@ export async function selectDriveFolder(multiple: boolean): Promise { + return new Promise((resolve) => { + popup(defineAsyncComponent(() => import('@/components/MkRoleSelectDialog.vue')), params, { + done: roles => { + resolve({ canceled: false, result: roles }); + }, + close: () => { + resolve({ canceled: true, result: undefined }); + }, + }, 'dispose'); + }); +} + export async function pickEmoji(src: HTMLElement, opts: ComponentProps): Promise { return new Promise(resolve => { const { dispose } = popup(MkEmojiPickerDialog, { diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts b/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts new file mode 100644 index 0000000000..de2b2aca8c --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type RequestLogItem = { + failed: boolean; + url: string; + name: string; + error?: string; +}; + +export const gridSortOrderKeys = [ + 'name', + 'category', + 'aliases', + 'type', + 'license', + 'host', + 'uri', + 'publicUrl', + 'isSensitive', + 'localOnly', + 'updatedAt', +]; +export type GridSortOrderKey = typeof gridSortOrderKeys[number]; + +export function emptyStrToUndefined(value: string | null) { + return value ? value : undefined; +} + +export function emptyStrToNull(value: string) { + return value === '' ? null : value; +} + +export function emptyStrToEmptyArray(value: string) { + return value === '' ? [] : value.split(' ').map(it => it.trim()); +} + +export function roleIdsParser(text: string): { id: string, name: string }[] { + // idとnameのペア配列をJSONで受け取る。それ以外の形式は許容しない + try { + const obj = JSON.parse(text); + if (!Array.isArray(obj)) { + return []; + } + if (!obj.every(it => typeof it === 'object' && 'id' in it && 'name' in it)) { + return []; + } + + return obj.map(it => ({ id: it.id, name: it.name })); + } catch (ex) { + console.warn(ex); + return []; + } +} diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue new file mode 100644 index 0000000000..55f9632ce4 --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue @@ -0,0 +1,757 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue new file mode 100644 index 0000000000..a3de5de569 --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue @@ -0,0 +1,477 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue new file mode 100644 index 0000000000..ea4303f342 --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue @@ -0,0 +1,36 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.logs-folder.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.logs-folder.vue new file mode 100644 index 0000000000..f75f6c0da5 --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.logs-folder.vue @@ -0,0 +1,102 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue new file mode 100644 index 0000000000..9a9d2990ba --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue @@ -0,0 +1,441 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts b/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts new file mode 100644 index 0000000000..f62304277a --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts @@ -0,0 +1,160 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { delay, http, HttpResponse } from 'msw'; +import { StoryObj } from '@storybook/vue3'; +import { entities } from 'misskey-js'; +import { commonHandlers } from '../../../.storybook/mocks.js'; +import { emoji } from '../../../.storybook/fakes.js'; +import { fakeId } from '../../../.storybook/fake-utils.js'; +import custom_emojis_manager2 from './custom-emojis-manager2.vue'; + +function createRender(params: { + emojis: entities.EmojiDetailedAdmin[]; +}) { + const storedEmojis: entities.EmojiDetailedAdmin[] = [...params.emojis]; + const storedDriveFiles: entities.DriveFile[] = []; + + return { + render(args) { + return { + components: { + custom_emojis_manager2, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/v2/admin/emoji/list', async ({ request }) => { + await delay(100); + + const bodyStream = request.body as ReadableStream; + const body = await new Response(bodyStream).json() as entities.V2AdminEmojiListRequest; + + const emojis = storedEmojis; + const limit = body.limit ?? 10; + const page = body.page ?? 1; + const result = emojis.slice((page - 1) * limit, page * limit); + + return HttpResponse.json({ + emojis: result, + count: Math.min(emojis.length, limit), + allCount: emojis.length, + allPages: Math.ceil(emojis.length / limit), + }); + }), + http.post('/api/drive/folders', () => { + return HttpResponse.json([]); + }), + http.post('/api/drive/files', () => { + return HttpResponse.json(storedDriveFiles); + }), + http.post('/api/drive/files/create', async ({ request }) => { + const data = await request.formData(); + const file = data.get('file'); + if (!file || !(file instanceof File)) { + return HttpResponse.json({ error: 'file is required' }, { + status: 400, + }); + } + + // FIXME: ファイルのバイナリに0xEF 0xBF 0xBDが混入してしまい、うまく画像ファイルとして表示できない問題がある + const base64 = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + resolve(reader.result as string); + }; + reader.readAsDataURL(new Blob([file], { type: 'image/webp' })); + }); + + const driveFile: entities.DriveFile = { + id: fakeId(file.name), + createdAt: new Date().toISOString(), + name: file.name, + type: file.type, + md5: '', + size: file.size, + isSensitive: false, + blurhash: null, + properties: {}, + url: base64, + thumbnailUrl: null, + comment: null, + folderId: null, + folder: null, + userId: null, + user: null, + }; + + storedDriveFiles.push(driveFile); + + return HttpResponse.json(driveFile); + }), + http.post('api/admin/emoji/add', async ({ request }) => { + await delay(100); + + const bodyStream = request.body as ReadableStream; + const body = await new Response(bodyStream).json() as entities.AdminEmojiAddRequest; + + const fileId = body.fileId; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const file = storedDriveFiles.find(f => f.id === fileId)!; + + const em = emoji({ + id: fakeId(file.name), + name: body.name, + publicUrl: file.url, + originalUrl: file.url, + type: file.type, + aliases: body.aliases, + category: body.category ?? undefined, + license: body.license ?? undefined, + localOnly: body.localOnly, + isSensitive: body.isSensitive, + }); + storedEmojis.push(em); + + return HttpResponse.json(null); + }), + ], + }, + }, + } satisfies StoryObj; +} + +export const Default = createRender({ + emojis: [], +}); + +export const List10 = createRender({ + emojis: Array.from({ length: 10 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())), +}); + +export const List100 = createRender({ + emojis: Array.from({ length: 100 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())), +}); + +export const List1000 = createRender({ + emojis: Array.from({ length: 1000 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())), +}); diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager2.vue b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue new file mode 100644 index 0000000000..a952a5a3d1 --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index fd15ae1d66..969ca8b9e8 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -121,6 +121,11 @@ const menuDef = computed(() => [{ text: i18n.ts.customEmojis, to: '/admin/emojis', active: currentPage.value?.route.name === 'emojis', + }, { + icon: 'ti ti-icons', + text: i18n.ts.customEmojis + '(beta)', + to: '/admin/emojis2', + active: currentPage.value?.route.name === 'emojis2', }, { icon: 'ti ti-sparkles', text: i18n.ts.avatarDecorations, diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts index e98e0b59b1..732b209a36 100644 --- a/packages/frontend/src/router/definition.ts +++ b/packages/frontend/src/router/definition.ts @@ -382,6 +382,10 @@ const routes: RouteDef[] = [{ path: '/emojis', name: 'emojis', component: page(() => import('@/pages/custom-emojis-manager.vue')), + }, { + path: '/emojis2', + name: 'emojis2', + component: page(() => import('@/pages/admin/custom-emojis-manager2.vue')), }, { path: '/avatar-decorations', name: 'avatarDecorations', diff --git a/packages/frontend/src/scripts/file-drop.ts b/packages/frontend/src/scripts/file-drop.ts new file mode 100644 index 0000000000..c2e863c0dc --- /dev/null +++ b/packages/frontend/src/scripts/file-drop.ts @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type DroppedItem = DroppedFile | DroppedDirectory; + +export type DroppedFile = { + isFile: true; + path: string; + file: File; +}; + +export type DroppedDirectory = { + isFile: false; + path: string; + children: DroppedItem[]; +} + +export async function extractDroppedItems(ev: DragEvent): Promise { + const dropItems = ev.dataTransfer?.items; + if (!dropItems || dropItems.length === 0) { + return []; + } + + const apiTestItem = dropItems[0]; + if ('webkitGetAsEntry' in apiTestItem) { + return readDataTransferItems(dropItems); + } else { + // webkitGetAsEntryに対応していない場合はfilesから取得する(ディレクトリのサポートは出来ない) + const dropFiles = ev.dataTransfer.files; + if (dropFiles.length === 0) { + return []; + } + + const droppedFiles = Array.of(); + for (let i = 0; i < dropFiles.length; i++) { + const file = dropFiles.item(i); + if (file) { + droppedFiles.push({ + isFile: true, + path: file.name, + file, + }); + } + } + + return droppedFiles; + } +} + +/** + * ドラッグ&ドロップされたファイルのリストからディレクトリ構造とファイルへの参照({@link File})を取得する。 + */ +export async function readDataTransferItems(itemList: DataTransferItemList): Promise { + async function readEntry(entry: FileSystemEntry): Promise { + if (entry.isFile) { + return { + isFile: true, + path: entry.fullPath, + file: await readFile(entry as FileSystemFileEntry), + }; + } else { + return { + isFile: false, + path: entry.fullPath, + children: await readDirectory(entry as FileSystemDirectoryEntry), + }; + } + } + + function readFile(fileSystemFileEntry: FileSystemFileEntry): Promise { + return new Promise((resolve, reject) => { + fileSystemFileEntry.file(resolve, reject); + }); + } + + function readDirectory(fileSystemDirectoryEntry: FileSystemDirectoryEntry): Promise { + return new Promise(async (resolve) => { + const allEntries = Array.of(); + const reader = fileSystemDirectoryEntry.createReader(); + while (true) { + const entries = await new Promise((res, rej) => reader.readEntries(res, rej)); + if (entries.length === 0) { + break; + } + allEntries.push(...entries); + } + + resolve(await Promise.all(allEntries.map(readEntry))); + }); + } + + // 扱いにくいので配列に変換 + const items = Array.of(); + for (let i = 0; i < itemList.length; i++) { + items.push(itemList[i]); + } + + return Promise.all( + items + .map(it => it.webkitGetAsEntry()) + .filter(it => it) + .map(it => readEntry(it!)), + ); +} + +/** + * {@link DroppedItem}のリストからディレクトリを再帰的に検索し、ファイルのリストを取得する。 + */ +export function flattenDroppedFiles(items: DroppedItem[]): DroppedFile[] { + const result = Array.of(); + for (const item of items) { + if (item.isFile) { + result.push(item); + } else { + result.push(...flattenDroppedFiles(item.children)); + } + } + return result; +} diff --git a/packages/frontend/src/scripts/key-event.ts b/packages/frontend/src/scripts/key-event.ts new file mode 100644 index 0000000000..a72776d48c --- /dev/null +++ b/packages/frontend/src/scripts/key-event.ts @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * {@link KeyboardEvent.code} の値を表す文字列。不足分は適宜追加する + * @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values + */ +export type KeyCode = + | 'Backspace' + | 'Tab' + | 'Enter' + | 'Shift' + | 'Control' + | 'Alt' + | 'Pause' + | 'CapsLock' + | 'Escape' + | 'Space' + | 'PageUp' + | 'PageDown' + | 'End' + | 'Home' + | 'ArrowLeft' + | 'ArrowUp' + | 'ArrowRight' + | 'ArrowDown' + | 'Insert' + | 'Delete' + | 'Digit0' + | 'Digit1' + | 'Digit2' + | 'Digit3' + | 'Digit4' + | 'Digit5' + | 'Digit6' + | 'Digit7' + | 'Digit8' + | 'Digit9' + | 'KeyA' + | 'KeyB' + | 'KeyC' + | 'KeyD' + | 'KeyE' + | 'KeyF' + | 'KeyG' + | 'KeyH' + | 'KeyI' + | 'KeyJ' + | 'KeyK' + | 'KeyL' + | 'KeyM' + | 'KeyN' + | 'KeyO' + | 'KeyP' + | 'KeyQ' + | 'KeyR' + | 'KeyS' + | 'KeyT' + | 'KeyU' + | 'KeyV' + | 'KeyW' + | 'KeyX' + | 'KeyY' + | 'KeyZ' + | 'MetaLeft' + | 'MetaRight' + | 'ContextMenu' + | 'F1' + | 'F2' + | 'F3' + | 'F4' + | 'F5' + | 'F6' + | 'F7' + | 'F8' + | 'F9' + | 'F10' + | 'F11' + | 'F12' + | 'NumLock' + | 'ScrollLock' + | 'Semicolon' + | 'Equal' + | 'Comma' + | 'Minus' + | 'Period' + | 'Slash' + | 'Backquote' + | 'BracketLeft' + | 'Backslash' + | 'BracketRight' + | 'Quote' + | 'Meta' + | 'AltGraph' + ; + +/** + * 修飾キーを表す文字列。不足分は適宜追加する。 + */ +export type KeyModifier = + | 'Shift' + | 'Control' + | 'Alt' + | 'Meta' + ; + +/** + * 押下されたキー以外の状態を表す文字列。不足分は適宜追加する。 + */ +export type KeyState = + | 'composing' + | 'repeat' + ; + +export type KeyEventHandler = { + modifiers?: KeyModifier[]; + states?: KeyState[]; + code: KeyCode | 'any'; + handler: (event: KeyboardEvent) => void; +} + +export function handleKeyEvent(event: KeyboardEvent, handlers: KeyEventHandler[]) { + function checkModifier(ev: KeyboardEvent, modifiers? : KeyModifier[]) { + if (modifiers) { + return modifiers.every(modifier => ev.getModifierState(modifier)); + } + return true; + } + + function checkState(ev: KeyboardEvent, states?: KeyState[]) { + if (states) { + return states.every(state => ev.getModifierState(state)); + } + return true; + } + + let hit = false; + for (const handler of handlers.filter(it => it.code === event.code)) { + if (checkModifier(event, handler.modifiers) && checkState(event, handler.states)) { + handler.handler(event); + hit = true; + break; + } + } + + if (!hit) { + for (const handler of handlers.filter(it => it.code === 'any')) { + handler.handler(event); + } + } +} diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts index b037aa8acc..c25b4d73bd 100644 --- a/packages/frontend/src/scripts/select-file.ts +++ b/packages/frontend/src/scripts/select-file.ts @@ -12,14 +12,28 @@ import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { uploadFile } from '@/scripts/upload.js'; -export function chooseFileFromPc(multiple: boolean, keepOriginal = false): Promise { +export function chooseFileFromPc( + multiple: boolean, + options?: { + uploadFolder?: string | null; + keepOriginal?: boolean; + nameConverter?: (file: File) => string | undefined; + }, +): Promise { + const uploadFolder = options?.uploadFolder ?? defaultStore.state.uploadFolder; + const keepOriginal = options?.keepOriginal ?? defaultStore.state.keepOriginalUploading; + const nameConverter = options?.nameConverter ?? (() => undefined); + return new Promise((res, rej) => { const input = document.createElement('input'); input.type = 'file'; input.multiple = multiple; input.onchange = () => { if (!input.files) return res([]); - const promises = Array.from(input.files, file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal)); + const promises = Array.from( + input.files, + file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal), + ); Promise.all(promises).then(driveFiles => { res(driveFiles); @@ -94,7 +108,7 @@ function select(src: HTMLElement | EventTarget | null, label: string | null, mul }, { text: i18n.ts.upload, icon: 'ti ti-upload', - action: () => chooseFileFromPc(multiple, keepOriginal.value).then(files => res(files)), + action: () => chooseFileFromPc(multiple, { keepOriginal: keepOriginal.value }).then(files => res(files)), }, { text: i18n.ts.fromDrive, icon: 'ti ti-cloud', diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 211ddb8287..7098b52205 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1117,6 +1117,9 @@ type EmojiDeleted = { // @public (undocumented) type EmojiDetailed = components['schemas']['EmojiDetailed']; +// @public (undocumented) +type EmojiDetailedAdmin = components['schemas']['EmojiDetailedAdmin']; + // @public (undocumented) type EmojiRequest = operations['emoji']['requestBody']['content']['application/json']; @@ -1294,6 +1297,8 @@ declare namespace entities { AdminEmojiSetCategoryBulkRequest, AdminEmojiSetLicenseBulkRequest, AdminEmojiUpdateRequest, + V2AdminEmojiListRequest, + V2AdminEmojiListResponse, AdminFederationDeleteAllFilesRequest, AdminFederationRefreshRemoteInstanceMetadataRequest, AdminFederationRemoveAllFollowingRequest, @@ -1847,6 +1852,7 @@ declare namespace entities { GalleryPost, EmojiSimple, EmojiDetailed, + EmojiDetailedAdmin, Flash, Signin, RoleCondFormulaLogics, @@ -3420,6 +3426,12 @@ type UsersShowResponse = operations['users___show']['responses']['200']['content // @public (undocumented) type UsersUpdateMemoRequest = operations['users___update-memo']['requestBody']['content']['application/json']; +// @public (undocumented) +type V2AdminEmojiListRequest = operations['v2___admin___emoji___list']['requestBody']['content']['application/json']; + +// @public (undocumented) +type V2AdminEmojiListResponse = operations['v2___admin___emoji___list']['responses']['200']['content']['application/json']; + // Warnings were encountered during analysis: // // src/entities.ts:50:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 3bcdae6a4a..edaa0498e9 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -493,6 +493,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:emoji* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index b016d5bbcf..982717597b 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -62,6 +62,8 @@ import type { AdminEmojiSetCategoryBulkRequest, AdminEmojiSetLicenseBulkRequest, AdminEmojiUpdateRequest, + V2AdminEmojiListRequest, + V2AdminEmojiListResponse, AdminFederationDeleteAllFilesRequest, AdminFederationRefreshRemoteInstanceMetadataRequest, AdminFederationRemoveAllFollowingRequest, @@ -628,6 +630,7 @@ export type Endpoints = { 'admin/emoji/set-category-bulk': { req: AdminEmojiSetCategoryBulkRequest; res: EmptyResponse }; 'admin/emoji/set-license-bulk': { req: AdminEmojiSetLicenseBulkRequest; res: EmptyResponse }; 'admin/emoji/update': { req: AdminEmojiUpdateRequest; res: EmptyResponse }; + 'v2/admin/emoji/list': { req: V2AdminEmojiListRequest; res: V2AdminEmojiListResponse }; 'admin/federation/delete-all-files': { req: AdminFederationDeleteAllFilesRequest; res: EmptyResponse }; 'admin/federation/refresh-remote-instance-metadata': { req: AdminFederationRefreshRemoteInstanceMetadataRequest; res: EmptyResponse }; 'admin/federation/remove-all-following': { req: AdminFederationRemoveAllFollowingRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 02be4848c7..e4299d62c7 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -65,6 +65,8 @@ export type AdminEmojiSetAliasesBulkRequest = operations['admin___emoji___set-al export type AdminEmojiSetCategoryBulkRequest = operations['admin___emoji___set-category-bulk']['requestBody']['content']['application/json']; export type AdminEmojiSetLicenseBulkRequest = operations['admin___emoji___set-license-bulk']['requestBody']['content']['application/json']; export type AdminEmojiUpdateRequest = operations['admin___emoji___update']['requestBody']['content']['application/json']; +export type V2AdminEmojiListRequest = operations['v2___admin___emoji___list']['requestBody']['content']['application/json']; +export type V2AdminEmojiListResponse = operations['v2___admin___emoji___list']['responses']['200']['content']['application/json']; export type AdminFederationDeleteAllFilesRequest = operations['admin___federation___delete-all-files']['requestBody']['content']['application/json']; export type AdminFederationRefreshRemoteInstanceMetadataRequest = operations['admin___federation___refresh-remote-instance-metadata']['requestBody']['content']['application/json']; export type AdminFederationRemoveAllFollowingRequest = operations['admin___federation___remove-all-following']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts index 04574849d4..1a30da4437 100644 --- a/packages/misskey-js/src/autogen/models.ts +++ b/packages/misskey-js/src/autogen/models.ts @@ -33,6 +33,7 @@ export type FederationInstance = components['schemas']['FederationInstance']; export type GalleryPost = components['schemas']['GalleryPost']; export type EmojiSimple = components['schemas']['EmojiSimple']; export type EmojiDetailed = components['schemas']['EmojiDetailed']; +export type EmojiDetailedAdmin = components['schemas']['EmojiDetailedAdmin']; export type Flash = components['schemas']['Flash']; export type Signin = components['schemas']['Signin']; export type RoleCondFormulaLogics = components['schemas']['RoleCondFormulaLogics']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index ada685604d..75a99263d0 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -414,6 +414,15 @@ export type paths = { */ post: operations['admin___emoji___update']; }; + '/v2/admin/emoji/list': { + /** + * v2/admin/emoji/list + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:emoji* + */ + post: operations['v2___admin___emoji___list']; + }; '/admin/federation/delete-all-files': { /** * admin/federation/delete-all-files @@ -4749,6 +4758,29 @@ export type components = { localOnly: boolean; roleIdsThatCanBeUsedThisEmojiAsReaction: string[]; }; + EmojiDetailedAdmin: { + /** Format: id */ + id: string; + /** Format: date-time */ + updatedAt: string | null; + name: string; + /** @description The local host is represented with `null`. */ + host: string | null; + publicUrl: string; + originalUrl: string; + uri: string | null; + type: string | null; + aliases: string[]; + category: string | null; + license: string | null; + localOnly: boolean; + isSensitive: boolean; + roleIdsThatCanBeUsedThisEmojiAsReaction: { + /** Format: misskey:id */ + id: string; + name: string; + }[]; + }; Flash: { /** * Format: id @@ -7872,6 +7904,97 @@ export type operations = { }; }; }; + /** + * v2/admin/emoji/list + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:emoji* + */ + v2___admin___emoji___list: { + requestBody: { + content: { + 'application/json': { + query?: ({ + updatedAtFrom?: string; + updatedAtTo?: string; + name?: string; + host?: string; + uri?: string; + publicUrl?: string; + originalUrl?: string; + type?: string; + aliases?: string; + category?: string; + license?: string; + isSensitive?: boolean; + localOnly?: boolean; + /** + * @default all + * @enum {string} + */ + hostType?: 'local' | 'remote' | 'all'; + roleIds?: string[]; + }) | null; + /** Format: misskey:id */ + sinceId?: string; + /** Format: misskey:id */ + untilId?: string; + /** @default 10 */ + limit?: number; + page?: number; + /** + * @default [ + * "-id" + * ] + */ + sortKeys?: ('+id' | '-id' | '+updatedAt' | '-updatedAt' | '+name' | '-name' | '+host' | '-host' | '+uri' | '-uri' | '+publicUrl' | '-publicUrl' | '+type' | '-type' | '+aliases' | '-aliases' | '+category' | '-category' | '+license' | '-license' | '+isSensitive' | '-isSensitive' | '+localOnly' | '-localOnly' | '+roleIdsThatCanBeUsedThisEmojiAsReaction' | '-roleIdsThatCanBeUsedThisEmojiAsReaction')[]; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': { + emojis: components['schemas']['EmojiDetailedAdmin'][]; + count: number; + allCount: number; + allPages: number; + }; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * admin/federation/delete-all-files * @description No description provided. -- cgit v1.2.3-freya From d7fdcbc7339966c2f074261d9c99d5000e3fb60c Mon Sep 17 00:00:00 2001 From: Takeshi Umeda Date: Tue, 21 Jan 2025 09:30:39 +0900 Subject: fix: 絵文字のライセンスのActivityPub contextを追加 (#15318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/core/activitypub/misc/contexts.ts | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'packages/backend/src/core') diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts index 94cb0785cb..6611e4b7f9 100644 --- a/packages/backend/src/core/activitypub/misc/contexts.ts +++ b/packages/backend/src/core/activitypub/misc/contexts.ts @@ -558,6 +558,11 @@ const extension_context_definition = { '_misskey_requireSigninToViewContents': 'misskey:_misskey_requireSigninToViewContents', '_misskey_makeNotesFollowersOnlyBefore': 'misskey:_misskey_makeNotesFollowersOnlyBefore', '_misskey_makeNotesHiddenBefore': 'misskey:_misskey_makeNotesHiddenBefore', + '_misskey_license': 'misskey:_misskey_license', + 'freeText': { + '@id': 'misskey:freeText', + '@type': 'schema:text', + }, 'isCat': 'misskey:isCat', // vcard vcard: 'http://www.w3.org/2006/vcard/ns#', -- cgit v1.2.3-freya