From 3ceac893c942b0baad2f8a0c6b51baa2b8477b5e Mon Sep 17 00:00:00 2001 From: dakkar Date: Sun, 12 Jan 2025 12:33:08 +0000 Subject: attribute invite codes to admins/moderators when a regular user (who has the appropriate permissions) creates an invite, we record that user's id in the `createdById` column but when an admin/mod creates an invite via the control panel, we didn't now we do --- packages/backend/src/server/api/endpoints/admin/invite/create.ts | 2 ++ 1 file changed, 2 insertions(+) (limited to 'packages/backend/src/server/api/endpoints/admin') diff --git a/packages/backend/src/server/api/endpoints/admin/invite/create.ts b/packages/backend/src/server/api/endpoints/admin/invite/create.ts index 5ecae3161a..e52b177e2b 100644 --- a/packages/backend/src/server/api/endpoints/admin/invite/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/invite/create.ts @@ -68,6 +68,8 @@ export default class extends Endpoint { // eslint- for (let i = 0; i < ps.count; i++) { ticketsPromises.push(this.registrationTicketsRepository.insertOne({ id: this.idService.gen(), + createdBy: me, + createdById: me.id, expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, code: generateInviteCode(), })); -- 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/server/api/endpoints/admin') 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 f9ad127aaf7875bad8fdf55f5ac98bff05997525 Mon Sep 17 00:00:00 2001 From: おさむのひと <46447427+samunohito@users.noreply.github.com> Date: Mon, 20 Jan 2025 20:35:37 +0900 Subject: feat: 新カスタム絵文字管理画面(β)の追加 (#13473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * wip * wip * wip * wip * wip * wip * wip * fix * fix * fix * fix size * fix register logs * fix img autosize * fix row selection * support delete * fix border rendering * fix display:none * tweak comments * support choose pc file and drive file * support directory drag-drop * fix * fix comment * support context menu on data area * fix autogen * wip イベント整理 * イベントの整理 * refactor grid * fix cell re-render bugs * fix row remove * fix comment * fix validation * fix utils * list maximum * add mimetype check * fix * fix number cell focus * fix over 100 file drop * remove log * fix patchData * fix performance * fix * support update and delete * support remote import * fix layout * heightやめる * fix performance * add list v2 endpoint * support pagination * fix api call * fix no clickable input text * fix limit * fix paging * fix * fix * support search * tweak logs * tweak cell selection * fix range select * block delete * add comment * fix * support import log * fix dialog * refactor * add confirm dialog * fix name * fix autogen * wip * support image change and highlight row * add columns * wip * support sort * add role name * add index to emoji * refine context menu setting * support role select * remove unused buttons * fix url * fix MkRoleSelectDialog.vue * add route * refine remote page * enter key search * fix paste bugs * fix copy/paste * fix keyEvent * fix copy/paste and delete * fix comment * fix MkRoleSelectDialog.vue and storybook scenario * fix MkRoleSelectDialog.vue and storybook scenario * add MkGrid.stories.impl.ts * fix * [wip] add custom-emojis-manager2.stories.impl.ts * [wip] add custom-emojis-manager2.stories.impl.ts * wip * 課題はまだ残っているが、ひとまず完了 * fix validation and register roles * fix upload * optimize import * patch from dev * i18n * revert excess fixes * separate sort order component * add SPDX * revert excess fixes * fix pre test * fix bugs * add type column * fix types * fix CHANGELOG.md * fix lit * lint * tweak style * refactor * fix ci * autogen * Update types.ts * CSS Module化 * fix log * 縦スクロールを無効化 * MkStickyContainer化 * regenerate locales index.d.ts * fix * fix * テスト * ランダム値によるUI変更の抑制 * テスト * tableタグやめる * fix last-child css * fix overflow css * fix endpoint.ts * tweak css * 最新への追従とレイアウト微調整 * ソートキーの指定方法を他と合わせた * fix focus * fix layout * v2エンドポイントのルールに対応 * 表示条件などを微調整 * fix MkDataCell.vue * fix error code * fix error * add comment to MkModal.vue * Update index.d.ts * fix CHANGELOG.md * fix color theme * fix CHANGELOG.md * fix CHANGELOG.md * fix center * fix: テーブルにフォーカスがあり、通常状態であるときはキーイベントの伝搬を止める * fix: ロール選択用のダイアログにてコンディショナルロールを×ボタンで除外できなかったのを修正 * fix remote list folder * sticky footers * chore: fix ci error(just single line-break diff) * fix loading * fix like * comma to space * fix ci * fix ci * removed align-center --------- Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com> Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> Co-authored-by: Sayamame-beans <61457993+Sayamame-beans@users.noreply.github.com> --- CHANGELOG.md | 3 +- locales/index.d.ts | 224 ++++ locales/ja-JP.yml | 64 + .../1709126576000-optimize-emoji-index.js | 18 + packages/backend/src/const.ts | 12 + packages/backend/src/core/CustomEmojiService.ts | 224 +++- .../src/core/entities/EmojiEntityService.ts | 90 +- packages/backend/src/misc/json-schema.ts | 7 +- packages/backend/src/models/json-schema/emoji.ts | 83 ++ .../ImportCustomEmojisProcessorService.ts | 5 +- packages/backend/src/server/api/EndpointsModule.ts | 4 + packages/backend/src/server/api/endpoints.ts | 2 + .../src/server/api/endpoints/admin/emoji/add.ts | 31 +- .../src/server/api/endpoints/admin/emoji/copy.ts | 4 +- .../src/server/api/endpoints/admin/emoji/update.ts | 6 +- .../server/api/endpoints/v2/admin/emoji/list.ts | 126 ++ packages/backend/test/unit/CustomEmojiService.ts | 817 ++++++++++++ packages/frontend/.storybook/fake-utils.ts | 154 +++ packages/frontend/.storybook/fakes.ts | 91 ++ packages/frontend/.storybook/generate.tsx | 4 + packages/frontend/src/components/MkFolder.vue | 6 +- packages/frontend/src/components/MkModal.vue | 31 +- .../frontend/src/components/MkPagingButtons.vue | 124 ++ .../components/MkRoleSelectDialog.stories.impl.ts | 106 ++ .../frontend/src/components/MkRoleSelectDialog.vue | 200 +++ .../src/components/MkSortOrderEditor.define.ts | 11 + .../frontend/src/components/MkSortOrderEditor.vue | 112 ++ .../src/components/MkTagItem.stories.impl.ts | 70 + packages/frontend/src/components/MkTagItem.vue | 76 ++ .../frontend/src/components/grid/MkCellTooltip.vue | 35 + .../frontend/src/components/grid/MkDataCell.vue | 391 ++++++ .../frontend/src/components/grid/MkDataRow.vue | 72 ++ .../src/components/grid/MkGrid.stories.impl.ts | 223 ++++ packages/frontend/src/components/grid/MkGrid.vue | 1342 ++++++++++++++++++++ .../frontend/src/components/grid/MkHeaderCell.vue | 216 ++++ .../frontend/src/components/grid/MkHeaderRow.vue | 60 + .../frontend/src/components/grid/MkNumberCell.vue | 61 + .../src/components/grid/cell-validators.ts | 110 ++ packages/frontend/src/components/grid/cell.ts | 88 ++ packages/frontend/src/components/grid/column.ts | 53 + .../frontend/src/components/grid/grid-event.ts | 46 + .../frontend/src/components/grid/grid-utils.ts | 215 ++++ packages/frontend/src/components/grid/grid.ts | 44 + packages/frontend/src/components/grid/row.ts | 68 + .../frontend/src/components/hook/useLoading.ts | 52 + packages/frontend/src/index.html | 1 + packages/frontend/src/os.ts | 21 + .../src/pages/admin/custom-emojis-manager.impl.ts | 56 + .../admin/custom-emojis-manager.local.list.vue | 757 +++++++++++ .../admin/custom-emojis-manager.local.register.vue | 477 +++++++ .../pages/admin/custom-emojis-manager.local.vue | 36 + .../admin/custom-emojis-manager.logs-folder.vue | 102 ++ .../pages/admin/custom-emojis-manager.remote.vue | 441 +++++++ .../admin/custom-emojis-manager2.stories.impl.ts | 160 +++ .../src/pages/admin/custom-emojis-manager2.vue | 44 + packages/frontend/src/pages/admin/index.vue | 5 + packages/frontend/src/router/definition.ts | 4 + packages/frontend/src/scripts/file-drop.ts | 121 ++ packages/frontend/src/scripts/key-event.ts | 153 +++ packages/frontend/src/scripts/select-file.ts | 20 +- packages/misskey-js/etc/misskey-js.api.md | 12 + packages/misskey-js/src/autogen/apiClientJSDoc.ts | 11 + packages/misskey-js/src/autogen/endpoint.ts | 3 + packages/misskey-js/src/autogen/entities.ts | 2 + packages/misskey-js/src/autogen/models.ts | 1 + packages/misskey-js/src/autogen/types.ts | 123 ++ 66 files changed, 8274 insertions(+), 57 deletions(-) create mode 100644 packages/backend/migration/1709126576000-optimize-emoji-index.js create mode 100644 packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts create mode 100644 packages/backend/test/unit/CustomEmojiService.ts create mode 100644 packages/frontend/.storybook/fake-utils.ts create mode 100644 packages/frontend/src/components/MkPagingButtons.vue create mode 100644 packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts create mode 100644 packages/frontend/src/components/MkRoleSelectDialog.vue create mode 100644 packages/frontend/src/components/MkSortOrderEditor.define.ts create mode 100644 packages/frontend/src/components/MkSortOrderEditor.vue create mode 100644 packages/frontend/src/components/MkTagItem.stories.impl.ts create mode 100644 packages/frontend/src/components/MkTagItem.vue create mode 100644 packages/frontend/src/components/grid/MkCellTooltip.vue create mode 100644 packages/frontend/src/components/grid/MkDataCell.vue create mode 100644 packages/frontend/src/components/grid/MkDataRow.vue create mode 100644 packages/frontend/src/components/grid/MkGrid.stories.impl.ts create mode 100644 packages/frontend/src/components/grid/MkGrid.vue create mode 100644 packages/frontend/src/components/grid/MkHeaderCell.vue create mode 100644 packages/frontend/src/components/grid/MkHeaderRow.vue create mode 100644 packages/frontend/src/components/grid/MkNumberCell.vue create mode 100644 packages/frontend/src/components/grid/cell-validators.ts create mode 100644 packages/frontend/src/components/grid/cell.ts create mode 100644 packages/frontend/src/components/grid/column.ts create mode 100644 packages/frontend/src/components/grid/grid-event.ts create mode 100644 packages/frontend/src/components/grid/grid-utils.ts create mode 100644 packages/frontend/src/components/grid/grid.ts create mode 100644 packages/frontend/src/components/grid/row.ts create mode 100644 packages/frontend/src/components/hook/useLoading.ts create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager.local.vue create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager.logs-folder.vue create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager2.vue create mode 100644 packages/frontend/src/scripts/file-drop.ts create mode 100644 packages/frontend/src/scripts/key-event.ts (limited to 'packages/backend/src/server/api/endpoints/admin') diff --git a/CHANGELOG.md b/CHANGELOG.md index 55833c59a1..2fa49e3456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ - 詳細は #14730 および `.config/example.yml` または `.config/docker_example.yml`の'Fulltext search configuration'をご参照願います. ### General -- +- Feat: カスタム絵文字管理画面をリニューアル #10996 + * β版として公開のため、旧画面も引き続き利用可能です ### Client - Enhance: PC画面でチャンネルが複数列で表示されるように diff --git a/locales/index.d.ts b/locales/index.d.ts index 8bd0e647b1..b98fd5d423 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -36,6 +36,10 @@ export interface Locale extends ILocale { * 検索 */ "search": string; + /** + * リセット + */ + "reset": string; /** * 通知 */ @@ -10543,6 +10547,226 @@ export interface Locale extends ILocale { */ "native": string; }; + "_gridComponent": { + "_error": { + /** + * この値は必須項目です + */ + "requiredValue": string; + /** + * 正規表現によるバリデーションはtype:textのカラムのみサポートします。 + */ + "columnTypeNotSupport": string; + /** + * この値は{pattern}のパターンに一致しません + */ + "patternNotMatch": ParameterizedString<"pattern">; + /** + * この値は一意である必要があります + */ + "notUnique": string; + }; + }; + "_roleSelectDialog": { + /** + * 選択されていません + */ + "notSelected": string; + }; + "_customEmojisManager": { + "_gridCommon": { + /** + * 選択行をコピー + */ + "copySelectionRows": string; + /** + * 選択範囲をコピー + */ + "copySelectionRanges": string; + /** + * 選択行を削除 + */ + "deleteSelectionRows": string; + /** + * 選択範囲の行を削除 + */ + "deleteSelectionRanges": string; + /** + * 検索設定 + */ + "searchSettings": string; + /** + * 検索条件を詳細に設定します。 + */ + "searchSettingCaption": string; + /** + * 並び順 + */ + "sortOrder": string; + /** + * 登録ログ + */ + "registrationLogs": string; + /** + * 絵文字更新・削除時のログが表示されます。更新・削除操作を行ったり、ページを遷移・リロードすると消えます。 + */ + "registrationLogsCaption": string; + /** + * エラー + */ + "alertEmojisRegisterFailedTitle": string; + /** + * 絵文字の更新・削除に失敗しました。詳細は登録ログをご確認ください。 + */ + "alertEmojisRegisterFailedDescription": string; + }; + "_logs": { + /** + * 成功ログを表示 + */ + "showSuccessLogSwitch": string; + /** + * 失敗ログはありません。 + */ + "failureLogNothing": string; + /** + * ログはありません。 + */ + "logNothing": string; + }; + "_remote": { + /** + * 選択行をインポート + */ + "importSelectionRows": string; + /** + * 選択範囲の行をインポート + */ + "importSelectionRangesRows": string; + /** + * チェックされた絵文字をインポート + */ + "importEmojisButton": string; + /** + * 絵文字のインポート + */ + "confirmImportEmojisTitle": string; + /** + * リモートから受信した{count}個の絵文字のインポートを行います。絵文字のライセンスに十分な注意を払ってください。実行しますか? + */ + "confirmImportEmojisDescription": ParameterizedString<"count">; + }; + "_local": { + /** + * 登録済み絵文字一覧 + */ + "tabTitleList": string; + /** + * 絵文字の登録 + */ + "tabTitleRegister": string; + "_list": { + /** + * 登録された絵文字はありません。 + */ + "emojisNothing": string; + /** + * 選択行を削除対象にする + */ + "markAsDeleteTargetRows": string; + /** + * 選択範囲の行を削除対象にする + */ + "markAsDeleteTargetRanges": string; + /** + * 変更された絵文字はありません。 + */ + "alertUpdateEmojisNothingDescription": string; + /** + * 削除対象の絵文字はありません。 + */ + "alertDeleteEmojisNothingDescription": string; + /** + * 確認 + */ + "confirmUpdateEmojisTitle": string; + /** + * {count}個の絵文字を更新します。実行しますか? + */ + "confirmUpdateEmojisDescription": ParameterizedString<"count">; + /** + * 確認 + */ + "confirmDeleteEmojisTitle": string; + /** + * チェックがつけられた{count}個の絵文字を削除します。実行しますか? + */ + "confirmDeleteEmojisDescription": ParameterizedString<"count">; + /** + * 絵文字に設定されたロールで検索 + */ + "dialogSelectRoleTitle": string; + }; + "_register": { + /** + * アップロード設定 + */ + "uploadSettingTitle": string; + /** + * この画面で絵文字アップロードを行う際の動作を設定できます。 + */ + "uploadSettingDescription": string; + /** + * ディレクトリ名を"category"に入力する + */ + "directoryToCategoryLabel": string; + /** + * ディレクトリをドラッグ・ドロップした時に、ディレクトリ名を"category"に入力します。 + */ + "directoryToCategoryCaption": string; + /** + * いずれかの方法で登録する絵文字を選択してください。 + */ + "emojiInputAreaCaption": string; + /** + * この枠に画像ファイルまたはディレクトリをドラッグ&ドロップ + */ + "emojiInputAreaList1": string; + /** + * このリンクをクリックしてPCから選択する + */ + "emojiInputAreaList2": string; + /** + * このリンクをクリックしてドライブから選択する + */ + "emojiInputAreaList3": string; + /** + * 確認 + */ + "confirmRegisterEmojisTitle": string; + /** + * リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?(負荷を避けるため、一度の操作で登録可能な絵文字は{count}件までです) + */ + "confirmRegisterEmojisDescription": ParameterizedString<"count">; + /** + * 確認 + */ + "confirmClearEmojisTitle": string; + /** + * 編集内容を破棄し、リストに表示されている絵文字をクリアします。よろしいですか? + */ + "confirmClearEmojisDescription": string; + /** + * 確認 + */ + "confirmUploadEmojisTitle": string; + /** + * ドラッグ&ドロップされた{count}個のファイルをドライブにアップロードします。実行しますか? + */ + "confirmUploadEmojisDescription": ParameterizedString<"count">; + }; + }; + }; "_embedCodeGen": { /** * 埋め込みコードをカスタマイズ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 2a8fd94522..638f2a69c3 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -5,6 +5,7 @@ introMisskey: "ようこそ!Misskeyは、オープンソースの分散型マ poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォームMisskeyのサーバーのひとつです。" monthAndDay: "{month}月 {day}日" search: "検索" +reset: "リセット" notifications: "通知" username: "ユーザー名" password: "パスワード" @@ -2808,6 +2809,69 @@ _contextMenu: appWithShift: "Shiftキーでアプリケーション" native: "ブラウザのUI" +_gridComponent: + _error: + requiredValue: "この値は必須項目です" + columnTypeNotSupport: "正規表現によるバリデーションはtype:textのカラムのみサポートします。" + patternNotMatch: "この値は{pattern}のパターンに一致しません" + notUnique: "この値は一意である必要があります" + +_roleSelectDialog: + notSelected: "選択されていません" + +_customEmojisManager: + _gridCommon: + copySelectionRows: "選択行をコピー" + copySelectionRanges: "選択範囲をコピー" + deleteSelectionRows: "選択行を削除" + deleteSelectionRanges: "選択範囲の行を削除" + searchSettings: "検索設定" + searchSettingCaption: "検索条件を詳細に設定します。" + sortOrder: "並び順" + registrationLogs: "登録ログ" + registrationLogsCaption: "絵文字更新・削除時のログが表示されます。更新・削除操作を行ったり、ページを遷移・リロードすると消えます。" + alertEmojisRegisterFailedTitle: "エラー" + alertEmojisRegisterFailedDescription: "絵文字の更新・削除に失敗しました。詳細は登録ログをご確認ください。" + _logs: + showSuccessLogSwitch: "成功ログを表示" + failureLogNothing: "失敗ログはありません。" + logNothing: "ログはありません。" + _remote: + importSelectionRows: "選択行をインポート" + importSelectionRangesRows: "選択範囲の行をインポート" + importEmojisButton: "チェックされた絵文字をインポート" + confirmImportEmojisTitle: "絵文字のインポート" + confirmImportEmojisDescription: "リモートから受信した{count}個の絵文字のインポートを行います。絵文字のライセンスに十分な注意を払ってください。実行しますか?" + _local: + tabTitleList: "登録済み絵文字一覧" + tabTitleRegister: "絵文字の登録" + _list: + emojisNothing: "登録された絵文字はありません。" + markAsDeleteTargetRows: "選択行を削除対象にする" + markAsDeleteTargetRanges: "選択範囲の行を削除対象にする" + alertUpdateEmojisNothingDescription: "変更された絵文字はありません。" + alertDeleteEmojisNothingDescription: "削除対象の絵文字はありません。" + confirmUpdateEmojisTitle: "確認" + confirmUpdateEmojisDescription: "{count}個の絵文字を更新します。実行しますか?" + confirmDeleteEmojisTitle: "確認" + confirmDeleteEmojisDescription: "チェックがつけられた{count}個の絵文字を削除します。実行しますか?" + dialogSelectRoleTitle: "絵文字に設定されたロールで検索" + _register: + uploadSettingTitle: "アップロード設定" + uploadSettingDescription: "この画面で絵文字アップロードを行う際の動作を設定できます。" + directoryToCategoryLabel: "ディレクトリ名を\"category\"に入力する" + directoryToCategoryCaption: "ディレクトリをドラッグ・ドロップした時に、ディレクトリ名を\"category\"に入力します。" + emojiInputAreaCaption: "いずれかの方法で登録する絵文字を選択してください。" + emojiInputAreaList1: "この枠に画像ファイルまたはディレクトリをドラッグ&ドロップ" + emojiInputAreaList2: "このリンクをクリックしてPCから選択する" + emojiInputAreaList3: "このリンクをクリックしてドライブから選択する" + confirmRegisterEmojisTitle: "確認" + confirmRegisterEmojisDescription: "リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?(負荷を避けるため、一度の操作で登録可能な絵文字は{count}件までです)" + confirmClearEmojisTitle: "確認" + confirmClearEmojisDescription: "編集内容を破棄し、リストに表示されている絵文字をクリアします。よろしいですか?" + confirmUploadEmojisTitle: "確認" + confirmUploadEmojisDescription: "ドラッグ&ドロップされた{count}個のファイルをドライブにアップロードします。実行しますか?" + _embedCodeGen: title: "埋め込みコードをカスタマイズ" header: "ヘッダーを表示" diff --git a/packages/backend/migration/1709126576000-optimize-emoji-index.js b/packages/backend/migration/1709126576000-optimize-emoji-index.js new file mode 100644 index 0000000000..e4184895d0 --- /dev/null +++ b/packages/backend/migration/1709126576000-optimize-emoji-index.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class OptimizeEmojiIndex1709126576000 { + name = 'OptimizeEmojiIndex1709126576000' + + async up(queryRunner) { + await queryRunner.query(`CREATE INDEX "IDX_EMOJI_ROLE_IDS" ON "emoji" using gin ("roleIdsThatCanBeUsedThisEmojiAsReaction")`) + await queryRunner.query(`CREATE INDEX "IDX_EMOJI_CATEGORY" ON "emoji" ("category")`) + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "IDX_EMOJI_CATEGORY"`) + await queryRunner.query(`DROP INDEX "IDX_EMOJI_ROLE_IDS"`) + } +} diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index e3a61861f4..1ca0397206 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -26,6 +26,18 @@ export const DB_MAX_NOTE_TEXT_LENGTH = 8192; export const DB_MAX_IMAGE_COMMENT_LENGTH = 512; //#endregion +export const FILE_TYPE_IMAGE = [ + 'image/png', + 'image/gif', + 'image/jpeg', + 'image/webp', + 'image/avif', + 'image/apng', + 'image/bmp', + 'image/tiff', + 'image/x-icon', +]; + // ブラウザで直接表示することを許可するファイルの種類のリスト // ここに含まれないものは application/octet-stream としてレスポンスされる // SVGはXSSを生むので許可しない diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 4566113449..da71a5de6f 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -4,24 +4,59 @@ */ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import { In, IsNull } from 'typeorm'; import * as Redis from 'ioredis'; -import { DI } from '@/di-symbols.js'; -import { IdService } from '@/core/IdService.js'; +import { In, IsNull } from 'typeorm'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiEmoji } from '@/models/Emoji.js'; -import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { query } from '@/misc/prelude/url.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js'; +import type { MiEmoji } from '@/models/Emoji.js'; import type { Serialized } from '@/types.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/; +export const fetchEmojisHostTypes = [ + 'local', + 'remote', + 'all', +] as const; +export type FetchEmojisHostTypes = typeof fetchEmojisHostTypes[number]; +export const fetchEmojisSortKeys = [ + '+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', +] as const; +export type FetchEmojisSortKeys = typeof fetchEmojisSortKeys[number]; + @Injectable() export class CustomEmojiService implements OnApplicationShutdown { private emojisCache: MemoryKVCache; @@ -30,10 +65,8 @@ export class CustomEmojiService implements OnApplicationShutdown { constructor( @Inject(DI.redis) private redisClient: Redis.Redis, - @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, - private utilityService: UtilityService, private idService: IdService, private emojiEntityService: EmojiEntityService, @@ -58,7 +91,9 @@ export class CustomEmojiService implements OnApplicationShutdown { @bindThis public async add(data: { - driveFile: MiDriveFile; + originalUrl: string; + publicUrl: string; + fileType: string; name: string; category: string | null; aliases: string[]; @@ -75,9 +110,9 @@ export class CustomEmojiService implements OnApplicationShutdown { category: data.category, host: data.host, aliases: data.aliases, - originalUrl: data.driveFile.url, - publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url, - type: data.driveFile.webpublicType ?? data.driveFile.type, + originalUrl: data.originalUrl, + publicUrl: data.publicUrl, + type: data.fileType, license: data.license, isSensitive: data.isSensitive, localOnly: data.localOnly, @@ -105,8 +140,10 @@ export class CustomEmojiService implements OnApplicationShutdown { @bindThis public async update(data: ( { id: MiEmoji['id'], name?: string; } | { name: string; id?: MiEmoji['id'], } - ) & { - driveFile?: MiDriveFile; + ) & { + originalUrl?: string; + publicUrl?: string; + fileType?: string; category?: string | null; aliases?: string[]; license?: string | null; @@ -139,9 +176,9 @@ export class CustomEmojiService implements OnApplicationShutdown { license: data.license, isSensitive: data.isSensitive, localOnly: data.localOnly, - originalUrl: data.driveFile != null ? data.driveFile.url : undefined, - publicUrl: data.driveFile != null ? (data.driveFile.webpublicUrl ?? data.driveFile.url) : undefined, - type: data.driveFile != null ? (data.driveFile.webpublicType ?? data.driveFile.type) : undefined, + originalUrl: data.originalUrl, + publicUrl: data.publicUrl, + type: data.fileType, roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined, }); @@ -308,7 +345,7 @@ export class CustomEmojiService implements OnApplicationShutdown { @bindThis private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null { - // クエリに使うホスト + // クエリに使うホスト let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ) : src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない) : this.utilityService.isSelfHost(src) ? null // 自ホスト指定 @@ -414,6 +451,151 @@ export class CustomEmojiService implements OnApplicationShutdown { return this.emojisRepository.findOneBy({ name, host: IsNull() }); } + @bindThis + public async fetchEmojis( + params?: { + query?: { + updatedAtFrom?: string; + updatedAtTo?: string; + name?: string; + host?: string; + uri?: string; + publicUrl?: string; + type?: string; + aliases?: string; + category?: string; + license?: string; + isSensitive?: boolean; + localOnly?: boolean; + hostType?: FetchEmojisHostTypes; + roleIds?: string[]; + }, + sinceId?: string; + untilId?: string; + }, + opts?: { + limit?: number; + page?: number; + sortKeys?: FetchEmojisSortKeys[] + }, + ) { + function multipleWordsToQuery(words: string) { + return words.split(/\s/).filter(x => x.length > 0).map(x => `%${sqlLikeEscape(x)}%`); + } + + const builder = this.emojisRepository.createQueryBuilder('emoji'); + if (params?.query) { + const q = params.query; + if (q.updatedAtFrom) { + // noIndexScan + builder.andWhere('CAST(emoji.updatedAt AS DATE) >= :updateAtFrom', { updateAtFrom: q.updatedAtFrom }); + } + if (q.updatedAtTo) { + // noIndexScan + builder.andWhere('CAST(emoji.updatedAt AS DATE) <= :updateAtTo', { updateAtTo: q.updatedAtTo }); + } + if (q.name) { + builder.andWhere('emoji.name ~~ ANY(ARRAY[:...name])', { name: multipleWordsToQuery(q.name) }); + } + + switch (true) { + case q.hostType === 'local': { + builder.andWhere('emoji.host IS NULL'); + break; + } + case q.hostType === 'remote': { + if (q.host) { + // noIndexScan + builder.andWhere('emoji.host ~~ ANY(ARRAY[:...host])', { host: multipleWordsToQuery(q.host) }); + } else { + builder.andWhere('emoji.host IS NOT NULL'); + } + break; + } + } + + if (q.uri) { + // noIndexScan + builder.andWhere('emoji.uri ~~ ANY(ARRAY[:...uri])', { uri: multipleWordsToQuery(q.uri) }); + } + if (q.publicUrl) { + // noIndexScan + builder.andWhere('emoji.publicUrl ~~ ANY(ARRAY[:...publicUrl])', { publicUrl: multipleWordsToQuery(q.publicUrl) }); + } + if (q.type) { + // noIndexScan + builder.andWhere('emoji.type ~~ ANY(ARRAY[:...type])', { type: multipleWordsToQuery(q.type) }); + } + if (q.aliases) { + // noIndexScan + const subQueryBuilder = builder.subQuery() + .select('COUNT(0)', 'count') + .from( + sq2 => sq2 + .select('unnest(subEmoji.aliases)', 'alias') + .addSelect('subEmoji.id', 'id') + .from('emoji', 'subEmoji'), + 'aliasTable', + ) + .where('"emoji"."id" = "aliasTable"."id"') + .andWhere('"aliasTable"."alias" ~~ ANY(ARRAY[:...aliases])', { aliases: multipleWordsToQuery(q.aliases) }); + + builder.andWhere(`(${subQueryBuilder.getQuery()}) > 0`); + } + if (q.category) { + builder.andWhere('emoji.category ~~ ANY(ARRAY[:...category])', { category: multipleWordsToQuery(q.category) }); + } + if (q.license) { + // noIndexScan + builder.andWhere('emoji.license ~~ ANY(ARRAY[:...license])', { license: multipleWordsToQuery(q.license) }); + } + if (q.isSensitive != null) { + // noIndexScan + builder.andWhere('emoji.isSensitive = :isSensitive', { isSensitive: q.isSensitive }); + } + if (q.localOnly != null) { + // noIndexScan + builder.andWhere('emoji.localOnly = :localOnly', { localOnly: q.localOnly }); + } + if (q.roleIds && q.roleIds.length > 0) { + builder.andWhere('emoji.roleIdsThatCanBeUsedThisEmojiAsReaction && ARRAY[:...roleIds]::VARCHAR[]', { roleIds: q.roleIds }); + } + } + + if (params?.sinceId) { + builder.andWhere('emoji.id > :sinceId', { sinceId: params.sinceId }); + } + if (params?.untilId) { + builder.andWhere('emoji.id < :untilId', { untilId: params.untilId }); + } + + if (opts?.sortKeys && opts.sortKeys.length > 0) { + for (const sortKey of opts.sortKeys) { + const direction = sortKey.startsWith('-') ? 'DESC' : 'ASC'; + const key = sortKey.replace(/^[+-]/, ''); + builder.addOrderBy(`emoji.${key}`, direction); + } + } else { + builder.addOrderBy('emoji.id', 'DESC'); + } + + const limit = opts?.limit ?? 10; + if (opts?.page) { + builder.skip((opts.page - 1) * limit); + } + + builder.take(limit); + + const [emojis, count] = await builder.getManyAndCount(); + + return { + emojis, + count: (count > limit ? emojis.length : count), + allCount: count, + allPages: Math.ceil(count / limit), + }; + } + @bindThis public dispose(): void { this.emojisCache.dispose(); diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index 841bd731c0..490d3f2511 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -4,10 +4,10 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { EmojisRepository } from '@/models/_.js'; +import type { EmojisRepository, MiRole, RolesRepository } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; import type { MiEmoji } from '@/models/Emoji.js'; import { bindThis } from '@/decorators.js'; @@ -16,6 +16,8 @@ export class EmojiEntityService { constructor( @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, ) { } @@ -68,8 +70,90 @@ export class EmojiEntityService { @bindThis public packDetailedMany( emojis: any[], - ) { + ): Promise[]> { return Promise.all(emojis.map(x => this.packDetailed(x))); } + + @bindThis + public async packDetailedAdmin( + src: MiEmoji['id'] | MiEmoji, + hint?: { + roles?: Map + }, + ): Promise> { + const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src }); + + const roles = Array.of(); + if (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0) { + if (hint?.roles) { + const hintRoles = hint.roles; + roles.push( + ...emoji.roleIdsThatCanBeUsedThisEmojiAsReaction + .filter(x => hintRoles.has(x)) + .map(x => hintRoles.get(x)!), + ); + } else { + roles.push( + ...await this.rolesRepository.findBy({ id: In(emoji.roleIdsThatCanBeUsedThisEmojiAsReaction) }), + ); + } + + roles.sort((a, b) => { + if (a.displayOrder !== b.displayOrder) { + return b.displayOrder - a.displayOrder; + } + + return a.id.localeCompare(b.id); + }); + } + + return { + id: emoji.id, + updatedAt: emoji.updatedAt?.toISOString() ?? null, + name: emoji.name, + host: emoji.host, + uri: emoji.uri, + type: emoji.type, + aliases: emoji.aliases, + category: emoji.category, + publicUrl: emoji.publicUrl, + originalUrl: emoji.originalUrl, + license: emoji.license, + localOnly: emoji.localOnly, + isSensitive: emoji.isSensitive, + roleIdsThatCanBeUsedThisEmojiAsReaction: roles.map(it => ({ id: it.id, name: it.name })), + }; + } + + @bindThis + public async packDetailedAdminMany( + emojis: MiEmoji['id'][] | MiEmoji[], + hint?: { + roles?: Map + }, + ): Promise[]> { + // IDのみの要素をピックアップし、DBからレコードを取り出して他の値を補完する + const emojiEntities = emojis.filter(x => typeof x === 'object') as MiEmoji[]; + const emojiIdOnlyList = emojis.filter(x => typeof x === 'string') as string[]; + if (emojiIdOnlyList.length > 0) { + emojiEntities.push(...await this.emojisRepository.findBy({ id: In(emojiIdOnlyList) })); + } + + // 特定ロール専用の絵文字である場合、そのロール情報をあらかじめまとめて取得しておく(pack側で都度取得も出来るが負荷が高いので) + let hintRoles: Map; + if (hint?.roles) { + hintRoles = hint.roles; + } else { + const roles = Array.of(); + const roleIds = [...new Set(emojiEntities.flatMap(x => x.roleIdsThatCanBeUsedThisEmojiAsReaction))]; + if (roleIds.length > 0) { + roles.push(...await this.rolesRepository.findBy({ id: In(roleIds) })); + } + + hintRoles = new Map(roles.map(x => [x.id, x])); + } + + return Promise.all(emojis.map(x => this.packDetailedAdmin(x, { roles: hintRoles }))); + } } diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 040e36228c..f612591eda 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -33,7 +33,11 @@ import { packedClipSchema } from '@/models/json-schema/clip.js'; import { packedFederationInstanceSchema } from '@/models/json-schema/federation-instance.js'; import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js'; -import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; +import { + packedEmojiDetailedAdminSchema, + packedEmojiDetailedSchema, + packedEmojiSimpleSchema, +} from '@/models/json-schema/emoji.js'; import { packedFlashSchema } from '@/models/json-schema/flash.js'; import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; import { packedSigninSchema } from '@/models/json-schema/signin.js'; @@ -95,6 +99,7 @@ export const refs = { GalleryPost: packedGalleryPostSchema, EmojiSimple: packedEmojiSimpleSchema, EmojiDetailed: packedEmojiDetailedSchema, + EmojiDetailedAdmin: packedEmojiDetailedAdminSchema, Flash: packedFlashSchema, Signin: packedSigninSchema, RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema, diff --git a/packages/backend/src/models/json-schema/emoji.ts b/packages/backend/src/models/json-schema/emoji.ts index 62686ad5ae..3cd263fa37 100644 --- a/packages/backend/src/models/json-schema/emoji.ts +++ b/packages/backend/src/models/json-schema/emoji.ts @@ -104,3 +104,86 @@ export const packedEmojiDetailedSchema = { }, }, } as const; + +export const packedEmojiDetailedAdminSchema = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'id', + optional: false, nullable: false, + }, + updatedAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: true, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + host: { + type: 'string', + optional: false, nullable: true, + description: 'The local host is represented with `null`.', + }, + publicUrl: { + type: 'string', + optional: false, nullable: false, + }, + originalUrl: { + type: 'string', + optional: false, nullable: false, + }, + uri: { + type: 'string', + optional: false, nullable: true, + }, + type: { + type: 'string', + optional: false, nullable: true, + }, + aliases: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + format: 'id', + optional: false, nullable: false, + }, + }, + category: { + type: 'string', + optional: false, nullable: true, + }, + license: { + type: 'string', + optional: false, nullable: true, + }, + localOnly: { + type: 'boolean', + optional: false, nullable: false, + }, + isSensitive: { + type: 'boolean', + optional: false, nullable: false, + }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + format: 'misskey:id', + optional: false, nullable: false, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, + }, + }, +} as const; diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index 9e1b8fee70..725e1c8ba2 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -87,6 +87,7 @@ export class ImportCustomEmojisProcessorService { await this.emojisRepository.delete({ name: emojiInfo.name, }); + try { const driveFile = await this.driveService.addFile({ user: null, @@ -95,11 +96,13 @@ export class ImportCustomEmojisProcessorService { force: true, }); await this.customEmojiService.add({ + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + fileType: driveFile.webpublicType ?? driveFile.type, name: emojiInfo.name, category: emojiInfo.category, host: null, aliases: emojiInfo.aliases, - driveFile, license: emojiInfo.license, isSensitive: emojiInfo.isSensitive, localOnly: emojiInfo.localOnly, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index c2462d8b3d..87c9841fd0 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -50,6 +50,7 @@ import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-al import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; +import * as ep___v2_admin_emoji_list from './endpoints/v2/admin/emoji/list.js'; import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js'; @@ -440,6 +441,7 @@ const $admin_emoji_setAliasesBulk: Provider = { provide: 'ep:admin/emoji/set-ali const $admin_emoji_setCategoryBulk: Provider = { provide: 'ep:admin/emoji/set-category-bulk', useClass: ep___admin_emoji_setCategoryBulk.default }; const $admin_emoji_setLicenseBulk: Provider = { provide: 'ep:admin/emoji/set-license-bulk', useClass: ep___admin_emoji_setLicenseBulk.default }; const $admin_emoji_update: Provider = { provide: 'ep:admin/emoji/update', useClass: ep___admin_emoji_update.default }; +const $admin_emoji_v2_list: Provider = { provide: 'ep:v2/admin/emoji/list', useClass: ep___v2_admin_emoji_list.default }; const $admin_federation_deleteAllFiles: Provider = { provide: 'ep:admin/federation/delete-all-files', useClass: ep___admin_federation_deleteAllFiles.default }; const $admin_federation_refreshRemoteInstanceMetadata: Provider = { provide: 'ep:admin/federation/refresh-remote-instance-metadata', useClass: ep___admin_federation_refreshRemoteInstanceMetadata.default }; const $admin_federation_removeAllFollowing: Provider = { provide: 'ep:admin/federation/remove-all-following', useClass: ep___admin_federation_removeAllFollowing.default }; @@ -834,6 +836,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_emoji_setCategoryBulk, $admin_emoji_setLicenseBulk, $admin_emoji_update, + $admin_emoji_v2_list, $admin_federation_deleteAllFiles, $admin_federation_refreshRemoteInstanceMetadata, $admin_federation_removeAllFollowing, @@ -1222,6 +1225,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_emoji_setCategoryBulk, $admin_emoji_setLicenseBulk, $admin_emoji_update, + $admin_emoji_v2_list, $admin_federation_deleteAllFiles, $admin_federation_refreshRemoteInstanceMetadata, $admin_federation_removeAllFollowing, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 86728ef381..4d0c45cc91 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -55,6 +55,7 @@ import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-al import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; +import * as ep___v2_admin_emoji_list from './endpoints/v2/admin/emoji/list.js'; import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; @@ -444,6 +445,7 @@ const eps = [ ['admin/emoji/set-category-bulk', ep___admin_emoji_setCategoryBulk], ['admin/emoji/set-license-bulk', ep___admin_emoji_setLicenseBulk], ['admin/emoji/update', ep___admin_emoji_update], + ['v2/admin/emoji/list', ep___v2_admin_emoji_list], ['admin/federation/delete-all-files', ep___admin_federation_deleteAllFiles], ['admin/federation/refresh-remote-instance-metadata', ep___admin_federation_refreshRemoteInstanceMetadata], ['admin/federation/remove-all-following', ep___admin_federation_removeAllFollowing], diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts index 796f273330..53256565f6 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -9,6 +9,7 @@ import type { DriveFilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { FILE_TYPE_IMAGE } from '@/const.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -24,6 +25,11 @@ export const meta = { code: 'NO_SUCH_FILE', id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf', }, + unsupportedFileType: { + message: 'Unsupported file type.', + code: 'UNSUPPORTED_FILE_TYPE', + id: 'f7599d96-8750-af68-1633-9575d625c1a7', + }, duplicateName: { message: 'Duplicate name.', code: 'DUPLICATE_NAME', @@ -47,15 +53,21 @@ export const paramDef = { nullable: true, description: 'Use `null` to reset the category.', }, - aliases: { type: 'array', items: { - type: 'string', - } }, + aliases: { + type: 'array', + items: { + type: 'string', + }, + }, license: { type: 'string', nullable: true }, isSensitive: { type: 'boolean' }, localOnly: { type: 'boolean' }, - roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { - type: 'string', - } }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { + type: 'array', + items: { + type: 'string', + }, + }, }, required: ['name', 'fileId'], } as const; @@ -67,9 +79,7 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private customEmojiService: CustomEmojiService, - private emojiEntityService: EmojiEntityService, ) { super(meta, paramDef, async (ps, me) => { @@ -77,9 +87,12 @@ export default class extends Endpoint { // eslint- if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); if (isDuplicate) throw new ApiError(meta.errors.duplicateName); + if (!FILE_TYPE_IMAGE.includes(driveFile.type)) throw new ApiError(meta.errors.unsupportedFileType); const emoji = await this.customEmojiService.add({ - driveFile, + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + fileType: driveFile.webpublicType ?? driveFile.type, name: ps.name, category: ps.category ?? null, aliases: ps.aliases ?? [], diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts index 975f892df9..87b58ff6f6 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -86,7 +86,9 @@ export default class extends Endpoint { // eslint- if (isDuplicate) throw new ApiError(meta.errors.duplicateName); const addedEmoji = await this.customEmojiService.add({ - driveFile, + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + fileType: driveFile.webpublicType ?? driveFile.type, name: emoji.name, category: emoji.category, aliases: emoji.aliases, diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index 212cba5c5d..e3aaa051c1 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -79,13 +79,15 @@ export default class extends Endpoint { // eslint- } // JSON schemeのanyOfの型変換がうまくいっていないらしい - const required = { id: ps.id, name: ps.name } as + const required = { id: ps.id, name: ps.name } as | { id: MiEmoji['id']; name?: string } | { id?: MiEmoji['id']; name: string }; const error = await this.customEmojiService.update({ ...required, - driveFile, + originalUrl: driveFile != null ? driveFile.url : undefined, + publicUrl: driveFile != null ? (driveFile.webpublicUrl ?? driveFile.url) : undefined, + fileType: driveFile != null ? (driveFile.webpublicType ?? driveFile.type) : undefined, category: ps.category, aliases: ps.aliases, license: ps.license, diff --git a/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts new file mode 100644 index 0000000000..9426318e34 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { CustomEmojiService, fetchEmojisHostTypes, fetchEmojisSortKeys } from '@/core/CustomEmojiService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canManageCustomEmojis', + kind: 'read:admin:emoji', + + res: { + type: 'object', + properties: { + emojis: { + type: 'array', + items: { + type: 'object', + ref: 'EmojiDetailedAdmin', + }, + }, + count: { type: 'integer' }, + allCount: { type: 'integer' }, + allPages: { type: 'integer' }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + query: { + type: 'object', + nullable: true, + properties: { + updatedAtFrom: { type: 'string' }, + updatedAtTo: { type: 'string' }, + name: { type: 'string' }, + host: { type: 'string' }, + uri: { type: 'string' }, + publicUrl: { type: 'string' }, + originalUrl: { type: 'string' }, + type: { type: 'string' }, + aliases: { type: 'string' }, + category: { type: 'string' }, + license: { type: 'string' }, + isSensitive: { type: 'boolean' }, + localOnly: { type: 'boolean' }, + hostType: { + type: 'string', + enum: fetchEmojisHostTypes, + default: 'all', + }, + roleIds: { + type: 'array', + items: { type: 'string', format: 'misskey:id' }, + }, + }, + }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + page: { type: 'integer' }, + sortKeys: { + type: 'array', + default: ['-id'], + items: { + type: 'string', + enum: fetchEmojisSortKeys, + }, + }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private customEmojiService: CustomEmojiService, + private emojiEntityService: EmojiEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const q = ps.query; + const result = await this.customEmojiService.fetchEmojis( + { + query: { + updatedAtFrom: q?.updatedAtFrom, + updatedAtTo: q?.updatedAtTo, + name: q?.name, + host: q?.host, + uri: q?.uri, + publicUrl: q?.publicUrl, + type: q?.type, + aliases: q?.aliases, + category: q?.category, + license: q?.license, + isSensitive: q?.isSensitive, + localOnly: q?.localOnly, + hostType: q?.hostType, + roleIds: q?.roleIds, + }, + sinceId: ps.sinceId, + untilId: ps.untilId, + }, + { + limit: ps.limit, + page: ps.page, + sortKeys: ps.sortKeys, + }, + ); + + return { + emojis: await this.emojiEntityService.packDetailedAdminMany(result.emojis), + count: result.count, + allCount: result.allCount, + allPages: result.allPages, + }; + }); + } +} diff --git a/packages/backend/test/unit/CustomEmojiService.ts b/packages/backend/test/unit/CustomEmojiService.ts new file mode 100644 index 0000000000..10b687c6a0 --- /dev/null +++ b/packages/backend/test/unit/CustomEmojiService.ts @@ -0,0 +1,817 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, beforeAll, describe, test } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { IdService } from '@/core/IdService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { EmojisRepository } from '@/models/_.js'; +import { MiEmoji } from '@/models/Emoji.js'; + +describe('CustomEmojiService', () => { + let app: TestingModule; + let service: CustomEmojiService; + + let emojisRepository: EmojisRepository; + let idService: IdService; + + beforeAll(async () => { + app = await Test + .createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + CustomEmojiService, + UtilityService, + IdService, + EmojiEntityService, + ModerationLogService, + GlobalEventService, + ], + }) + .compile(); + app.enableShutdownHooks(); + + service = app.get(CustomEmojiService); + emojisRepository = app.get(DI.emojisRepository); + idService = app.get(IdService); + }); + + describe('fetchEmojis', () => { + async function insert(data: Partial[]) { + for (const d of data) { + const id = idService.gen(); + await emojisRepository.insert({ + id: id, + updatedAt: new Date(), + ...d, + }); + } + } + + function call(params: Parameters['0']) { + return service.fetchEmojis( + params, + { + // テスト向けに + sortKeys: ['+id'], + }, + ); + } + + function defaultData(suffix: string, override?: Partial): Partial { + return { + name: `emoji${suffix}`, + host: null, + category: 'default', + originalUrl: `https://example.com/emoji${suffix}.png`, + publicUrl: `https://example.com/emoji${suffix}.png`, + type: 'image/png', + aliases: [`emoji${suffix}`], + license: 'CC0', + isSensitive: false, + localOnly: false, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], + ...override, + }; + } + + afterEach(async () => { + await emojisRepository.delete({}); + }); + + describe('単独', () => { + test('updatedAtFrom', async () => { + await insert([ + defaultData('001', { updatedAt: new Date('2021-01-01T00:00:00.000Z') }), + defaultData('002', { updatedAt: new Date('2021-01-02T00:00:00.000Z') }), + defaultData('003', { updatedAt: new Date('2021-01-03T00:00:00.000Z') }), + ]); + + const actual = await call({ + query: { + updatedAtFrom: '2021-01-02T00:00:00.000Z', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji002'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('updatedAtTo', async () => { + await insert([ + defaultData('001', { updatedAt: new Date('2021-01-01T00:00:00.000Z') }), + defaultData('002', { updatedAt: new Date('2021-01-02T00:00:00.000Z') }), + defaultData('003', { updatedAt: new Date('2021-01-03T00:00:00.000Z') }), + ]); + + const actual = await call({ + query: { + updatedAtTo: '2021-01-02T00:00:00.000Z', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + }); + + describe('name', () => { + test('single', async () => { + await insert([ + defaultData('001'), + defaultData('002'), + ]); + + const actual = await call({ + query: { + name: 'emoji001', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji001'); + }); + + test('multi', async () => { + await insert([ + defaultData('001'), + defaultData('002'), + ]); + + const actual = await call({ + query: { + name: 'emoji001 emoji002', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001'), + defaultData('002'), + defaultData('003', { name: 'em003' }), + ]); + + const actual = await call({ + query: { + name: 'oji', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + }); + + test('escape', async () => { + await insert([ + defaultData('001'), + ]); + + const actual = await call({ + query: { + name: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('host', () => { + test('single', async () => { + await insert([ + defaultData('001', { host: 'example.com' }), + defaultData('002', { host: 'example.com' }), + defaultData('003', { host: '1.example.com' }), + defaultData('004', { host: '2.example.com' }), + ]); + + const actual = await call({ + query: { + host: 'example.com', + hostType: 'remote', + }, + }); + + expect(actual.allCount).toBe(4); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { host: 'example.com' }), + defaultData('002', { host: 'example.com' }), + defaultData('003', { host: '1.example.com' }), + defaultData('004', { host: '2.example.com' }), + ]); + + const actual = await call({ + query: { + host: '1.example.com 2.example.com', + hostType: 'remote', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji003'); + expect(actual.emojis[1].name).toBe('emoji004'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { host: 'example.com' }), + defaultData('002', { host: 'example.com' }), + defaultData('003', { host: '1.example.com' }), + defaultData('004', { host: '2.example.com' }), + ]); + + const actual = await call({ + query: { + host: 'example', + hostType: 'remote', + }, + }); + + expect(actual.allCount).toBe(4); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { host: 'example.com' }), + ]); + + const actual = await call({ + query: { + host: '%', + hostType: 'remote', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('uri', () => { + test('single', async () => { + await insert([ + defaultData('001', { uri: 'uri001' }), + defaultData('002', { uri: 'uri002' }), + defaultData('003', { uri: 'uri003' }), + ]); + + const actual = await call({ + query: { + uri: 'uri002', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { uri: 'uri001' }), + defaultData('002', { uri: 'uri002' }), + defaultData('003', { uri: 'uri003' }), + ]); + + const actual = await call({ + query: { + uri: 'uri001 uri003', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { uri: 'uri001' }), + defaultData('002', { uri: 'uri002' }), + defaultData('003', { uri: 'uri003' }), + ]); + + const actual = await call({ + query: { + uri: 'ri', + }, + }); + + expect(actual.allCount).toBe(3); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { uri: 'uri001' }), + ]); + + const actual = await call({ + query: { + uri: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('publicUrl', () => { + test('single', async () => { + await insert([ + defaultData('001', { publicUrl: 'publicUrl001' }), + defaultData('002', { publicUrl: 'publicUrl002' }), + defaultData('003', { publicUrl: 'publicUrl003' }), + ]); + + const actual = await call({ + query: { + publicUrl: 'publicUrl002', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { publicUrl: 'publicUrl001' }), + defaultData('002', { publicUrl: 'publicUrl002' }), + defaultData('003', { publicUrl: 'publicUrl003' }), + ]); + + const actual = await call({ + query: { + publicUrl: 'publicUrl001 publicUrl003', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { publicUrl: 'publicUrl001' }), + defaultData('002', { publicUrl: 'publicUrl002' }), + defaultData('003', { publicUrl: 'publicUrl003' }), + ]); + + const actual = await call({ + query: { + publicUrl: 'Url', + }, + }); + + expect(actual.allCount).toBe(3); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { publicUrl: 'publicUrl001' }), + ]); + + const actual = await call({ + query: { + publicUrl: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('type', () => { + test('single', async () => { + await insert([ + defaultData('001', { type: 'type001' }), + defaultData('002', { type: 'type002' }), + defaultData('003', { type: 'type003' }), + ]); + + const actual = await call({ + query: { + type: 'type002', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { type: 'type001' }), + defaultData('002', { type: 'type002' }), + defaultData('003', { type: 'type003' }), + ]); + + const actual = await call({ + query: { + type: 'type001 type003', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { type: 'type001' }), + defaultData('002', { type: 'type002' }), + defaultData('003', { type: 'type003' }), + ]); + + const actual = await call({ + query: { + type: 'pe', + }, + }); + + expect(actual.allCount).toBe(3); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { type: 'type001' }), + ]); + + const actual = await call({ + query: { + type: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('aliases', () => { + test('single', async () => { + await insert([ + defaultData('001', { aliases: ['alias001', 'alias002'] }), + defaultData('002', { aliases: ['alias002'] }), + defaultData('003', { aliases: ['alias003'] }), + ]); + + const actual = await call({ + query: { + aliases: 'alias002', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { aliases: ['alias001', 'alias002'] }), + defaultData('002', { aliases: ['alias002', 'alias004'] }), + defaultData('003', { aliases: ['alias003'] }), + defaultData('004', { aliases: ['alias004'] }), + ]); + + const actual = await call({ + query: { + aliases: 'alias001 alias004', + }, + }); + + expect(actual.allCount).toBe(3); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + expect(actual.emojis[2].name).toBe('emoji004'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { aliases: ['alias001', 'alias002'] }), + defaultData('002', { aliases: ['alias002', 'alias004'] }), + defaultData('003', { aliases: ['alias003'] }), + defaultData('004', { aliases: ['alias004'] }), + ]); + + const actual = await call({ + query: { + aliases: 'ias', + }, + }); + + expect(actual.allCount).toBe(4); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { aliases: ['alias001', 'alias002'] }), + ]); + + const actual = await call({ + query: { + aliases: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('category', () => { + test('single', async () => { + await insert([ + defaultData('001', { category: 'category001' }), + defaultData('002', { category: 'category002' }), + defaultData('003', { category: 'category003' }), + ]); + + const actual = await call({ + query: { + category: 'category002', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { category: 'category001' }), + defaultData('002', { category: 'category002' }), + defaultData('003', { category: 'category003' }), + ]); + + const actual = await call({ + query: { + category: 'category001 category003', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { category: 'category001' }), + defaultData('002', { category: 'category002' }), + defaultData('003', { category: 'category003' }), + ]); + + const actual = await call({ + query: { + category: 'egory', + }, + }); + + expect(actual.allCount).toBe(3); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { category: 'category001' }), + ]); + + const actual = await call({ + query: { + category: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('license', () => { + test('single', async () => { + await insert([ + defaultData('001', { license: 'license001' }), + defaultData('002', { license: 'license002' }), + defaultData('003', { license: 'license003' }), + ]); + + const actual = await call({ + query: { + license: 'license002', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { license: 'license001' }), + defaultData('002', { license: 'license002' }), + defaultData('003', { license: 'license003' }), + ]); + + const actual = await call({ + query: { + license: 'license001 license003', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { license: 'license001' }), + defaultData('002', { license: 'license002' }), + defaultData('003', { license: 'license003' }), + ]); + + const actual = await call({ + query: { + license: 'cense', + }, + }); + + expect(actual.allCount).toBe(3); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { license: 'license001' }), + ]); + + const actual = await call({ + query: { + license: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('isSensitive', () => { + test('true', async () => { + await insert([ + defaultData('001', { isSensitive: true }), + defaultData('002', { isSensitive: false }), + defaultData('003', { isSensitive: true }), + ]); + + const actual = await call({ + query: { + isSensitive: true, + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('false', async () => { + await insert([ + defaultData('001', { isSensitive: true }), + defaultData('002', { isSensitive: false }), + defaultData('003', { isSensitive: true }), + ]); + + const actual = await call({ + query: { + isSensitive: false, + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('null', async () => { + await insert([ + defaultData('001', { isSensitive: true }), + defaultData('002', { isSensitive: false }), + defaultData('003', { isSensitive: true }), + ]); + + const actual = await call({ + query: {}, + }); + + expect(actual.allCount).toBe(3); + }); + }); + + describe('localOnly', () => { + test('true', async () => { + await insert([ + defaultData('001', { localOnly: true }), + defaultData('002', { localOnly: false }), + defaultData('003', { localOnly: true }), + ]); + + const actual = await call({ + query: { + localOnly: true, + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('false', async () => { + await insert([ + defaultData('001', { localOnly: true }), + defaultData('002', { localOnly: false }), + defaultData('003', { localOnly: true }), + ]); + + const actual = await call({ + query: { + localOnly: false, + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('null', async () => { + await insert([ + defaultData('001', { localOnly: true }), + defaultData('002', { localOnly: false }), + defaultData('003', { localOnly: true }), + ]); + + const actual = await call({ + query: {}, + }); + + expect(actual.allCount).toBe(3); + }); + }); + + describe('roleId', () => { + test('single', async () => { + await insert([ + defaultData('001', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role001'] }), + defaultData('002', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role002'] }), + defaultData('003', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role003'] }), + ]); + + const actual = await call({ + query: { + roleIds: ['role002'], + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role001'] }), + defaultData('002', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role002', 'role003'] }), + defaultData('003', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role003'] }), + defaultData('004', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role004'] }), + ]); + + const actual = await call({ + query: { + roleIds: ['role001', 'role003'], + }, + }); + + expect(actual.allCount).toBe(3); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + expect(actual.emojis[2].name).toBe('emoji003'); + }); + }); + }); + }); +}); diff --git a/packages/frontend/.storybook/fake-utils.ts b/packages/frontend/.storybook/fake-utils.ts new file mode 100644 index 0000000000..c777cbbe72 --- /dev/null +++ b/packages/frontend/.storybook/fake-utils.ts @@ -0,0 +1,154 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import seedrandom from 'seedrandom'; + +/** + * AIで生成した無作為なファーストネーム + */ +export const firstNameDict = [ + 'Ethan', 'Olivia', 'Jackson', 'Emma', 'Liam', 'Ava', 'Aiden', 'Sophia', 'Mason', 'Isabella', + 'Noah', 'Mia', 'Lucas', 'Harper', 'Caleb', 'Abigail', 'Samuel', 'Emily', 'Logan', + 'Madison', 'Benjamin', 'Chloe', 'Elijah', 'Grace', 'Alexander', 'Scarlett', 'William', 'Zoey', 'James', 'Lily', +] + +/** + * AIで生成した無作為なラストネーム + */ +export const lastNameDict = [ + 'Anderson', 'Johnson', 'Thompson', 'Davis', 'Rodriguez', 'Smith', 'Patel', 'Williams', 'Lee', 'Brown', + 'Garcia', 'Jackson', 'Martinez', 'Taylor', 'Harris', 'Nguyen', 'Miller', 'Jones', 'Wilson', + 'White', 'Thomas', 'Garcia', 'Martinez', 'Robinson', 'Turner', 'Lewis', 'Hall', 'King', 'Baker', 'Cooper', +] + +/** + * AIで生成した無作為な国名 + */ +export const countryDict = [ + 'Japan', 'Canada', 'Brazil', 'Australia', 'Italy', 'SouthAfrica', 'Mexico', 'Sweden', 'Russia', 'India', + 'Germany', 'Argentina', 'South Korea', 'France', 'Nigeria', 'Turkey', 'Spain', 'Egypt', 'Thailand', + 'Vietnam', 'Kenya', 'Saudi Arabia', 'Netherlands', 'Colombia', 'Poland', 'Chile', 'Malaysia', 'Ukraine', 'New Zealand', 'Peru', +] + +export function text(length: number = 10, seed?: string): string { + let result = ""; + + // シード値を使う場合、同じ数値が羅列されるが、ランダム文字列という意味では満たせていると思うのでこのまま使っておく + const rand = seed ? seedrandom(seed)() : Math.random(); + while (result.length < length) { + result += rand.toString(36).substring(2); + } + + return result.substring(0, length); +} + +export function integer(min: number = 0, max: number = 9999, seed?: string): number { + const rand = seed ? seedrandom(seed)() : Math.random(); + return Math.floor(rand * (max - min)) + min; +} + +export function date(params?: { + yearMin?: number, + yearMax?: number, + monthMin?: number, + monthMax?: number, + dayMin?: number, + dayMax?: number, + hourMin?: number, + hourMax?: number, + minuteMin?: number, + minuteMax?: number, + secondMin?: number, + secondMax?: number, + millisecondMin?: number, + millisecondMax?: number, +}, seed?: string): Date { + const year = integer(params?.yearMin ?? 1970, params?.yearMax ?? (new Date()).getFullYear(), seed); + const month = integer(params?.monthMin ?? 1, params?.monthMax ?? 12, seed); + let day = integer(params?.dayMin ?? 1, params?.dayMax ?? 31, seed); + if (month === 2) { + day = Math.min(day, 28); + } else if ([4, 6, 9, 11].includes(month)) { + day = Math.min(day, 30); + } else { + day = Math.min(day, 31); + } + + const hour = integer(params?.hourMin ?? 0, params?.hourMax ?? 23, seed); + const minute = integer(params?.minuteMin ?? 0, params?.minuteMax ?? 59, seed); + const second = integer(params?.secondMin ?? 0, params?.secondMax ?? 59, seed); + const millisecond = integer(params?.millisecondMin ?? 0, params?.millisecondMax ?? 999, seed); + + return new Date(year, month - 1, day, hour, minute, second, millisecond); +} + +export function boolean(seed?: string): boolean { + const rand = seed ? seedrandom(seed)() : Math.random(); + return rand < 0.5; +} + +export function choose(array: T[], seed?: string): T { + const rand = seed ? seedrandom(seed)() : Math.random(); + return array[Math.floor(rand * array.length)]; +} + +export function firstName(seed?: string): string { + return choose(firstNameDict, seed); +} + +export function lastName(seed?: string): string { + return choose(lastNameDict, seed); +} + +export function country(seed?: string): string { + return choose(countryDict, seed); +} + +const TIME2000 = 946684800000; +export function fakeId(seed?: string): string { + let time = new Date().getTime(); + + time = time - TIME2000; + if (time < 0) time = 0; + + const timeStr = time.toString(36).padStart(8, '0'); + const noiseStr = text(2, seed); + + return timeStr + noiseStr; +} + +export function imageDataUrl(options?: { + size?: { + width?: number, + height?: number, + }, + color?: { + red?: number, + green?: number, + blue?: number, + alpha?: number, + } +}, seed?: string): string { + const canvas = document.createElement('canvas'); + canvas.width = options?.size?.width ?? 100; + canvas.height = options?.size?.height ?? 100; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get 2d context'); + } + + ctx.beginPath() + + const red = options?.color?.red ?? integer(0, 255, seed); + const green = options?.color?.green ?? integer(0, 255, seed); + const blue = options?.color?.blue ?? integer(0, 255, seed); + const alpha = options?.color?.alpha ?? 1; + ctx.arc(canvas.width / 2, canvas.height / 2, canvas.width / 2, 0, Math.PI * 2, true); + ctx.fillStyle = `rgba(${red}, ${green}, ${blue}, ${alpha})`; + ctx.fill(); + + return canvas.toDataURL('image/png', 1.0); +} diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index fc3b0334e4..0a5ac15aa5 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -5,6 +5,7 @@ import { AISCRIPT_VERSION } from '@syuilo/aiscript'; import type { entities } from 'misskey-js' +import { date, imageDataUrl, text } from "./fake-utils.js"; export function abuseUserReport() { return { @@ -301,3 +302,93 @@ export function inviteCode(isUsed = false, hasExpiration = false, isExpired = fa used: isUsed, } } + +export function role(params: { + id?: string, + name?: string, + color?: string | null, + iconUrl?: string | null, + description?: string, + isModerator?: boolean, + isAdministrator?: boolean, + displayOrder?: number, + createdAt?: string, + updatedAt?: string, + target?: 'manual' | 'conditional', + isPublic?: boolean, + isExplorable?: boolean, + asBadge?: boolean, + canEditMembersByModerator?: boolean, + usersCount?: number, +}, seed?: string): entities.Role { + const prefix = params.displayOrder ? params.displayOrder.toString().padStart(3, '0') + '-' : ''; + const genId = text(36, seed); + const createdAt = params.createdAt ?? date({}, seed).toISOString(); + const updatedAt = params.updatedAt ?? date({}, seed).toISOString(); + + return { + id: params.id ?? genId, + name: params.name ?? `${prefix}TestRole-${genId}`, + color: params.color ?? '#445566', + iconUrl: params.iconUrl ?? null, + description: params.description ?? '', + isModerator: params.isModerator ?? false, + isAdministrator: params.isAdministrator ?? false, + displayOrder: params.displayOrder ?? 0, + createdAt: createdAt, + updatedAt: updatedAt, + target: params.target ?? 'manual', + isPublic: params.isPublic ?? true, + isExplorable: params.isExplorable ?? true, + asBadge: params.asBadge ?? true, + canEditMembersByModerator: params.canEditMembersByModerator ?? false, + usersCount: params.usersCount ?? 10, + condFormula: { + id: '', + type: 'or', + values: [] + }, + policies: {}, + } +} + +export function emoji(params?: { + id?: string, + name?: string, + host?: string, + uri?: string, + publicUrl?: string, + originalUrl?: string, + type?: string, + aliases?: string[], + category?: string, + license?: string, + isSensitive?: boolean, + localOnly?: boolean, + roleIdsThatCanBeUsedThisEmojiAsReaction?: {id:string, name:string}[], + updatedAt?: string, +}, seed?: string): entities.EmojiDetailedAdmin { + const _seed = seed ?? (params?.id ?? "DEFAULT_SEED"); + const id = params?.id ?? text(32, _seed); + const name = params?.name ?? text(8, _seed); + const updatedAt = params?.updatedAt ?? date({}, _seed).toISOString(); + + const image = imageDataUrl({}, _seed) + + return { + id: id, + name: name, + host: params?.host ?? null, + uri: params?.uri ?? null, + publicUrl: params?.publicUrl ?? image, + originalUrl: params?.originalUrl ?? image, + type: params?.type ?? 'image/png', + aliases: params?.aliases ?? [`alias1-${name}`, `alias2-${name}`], + category: params?.category ?? null, + license: params?.license ?? null, + isSensitive: params?.isSensitive ?? false, + localOnly: params?.localOnly ?? false, + roleIdsThatCanBeUsedThisEmojiAsReaction: params?.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [], + updatedAt: updatedAt, + } +} diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index f2bdc631d2..8830523810 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -416,6 +416,10 @@ function toStories(component: string): Promise { glob('src/components/MkUserSetupDialog.*.vue'), glob('src/components/MkInstanceCardMini.vue'), glob('src/components/MkInviteCode.vue'), + glob('src/components/MkTagItem.vue'), + glob('src/components/MkRoleSelectDialog.vue'), + glob('src/components/grid/MkGrid.vue'), + glob('src/pages/admin/custom-emojis-manager2.vue'), glob('src/pages/admin/overview.ap-requests.vue'), glob('src/pages/user/home.vue'), glob('src/pages/search.vue'), diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 7bdc06a8b4..384c0c0b34 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only >
- +
@@ -64,10 +64,14 @@ const props = withDefaults(defineProps<{ defaultOpen?: boolean; maxHeight?: number | null; withSpacer?: boolean; + spacerMin?: number; + spacerMax?: number; }>(), { defaultOpen: false, maxHeight: null, withSpacer: true, + spacerMin: 14, + spacerMax: 22, }); const rootEl = shallowRef(); diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index c766a33823..a446dad0ab 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -288,20 +288,23 @@ const align = () => { const onOpened = () => { emit('opened'); - // NOTE: Chromatic テストの際に undefined になる場合がある - if (content.value == null) return; - - // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する - const el = content.value.children[0]; - el.addEventListener('mousedown', ev => { - contentClicking = true; - window.addEventListener('mouseup', ev => { - // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ - window.setTimeout(() => { - contentClicking = false; - }, 100); - }, { passive: true, once: true }); - }, { passive: true }); + // contentの子要素にアクセスするためレンダリングの完了を待つ必要がある(nextTickが必要) + nextTick(() => { + // NOTE: Chromatic テストの際に undefined になる場合がある + if (content.value == null) return; + + // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する + const el = content.value.children[0]; + el.addEventListener('mousedown', ev => { + contentClicking = true; + window.addEventListener('mouseup', ev => { + // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ + window.setTimeout(() => { + contentClicking = false; + }, 100); + }, { passive: true, once: true }); + }, { passive: true }); + }); }; const onClosed = () => { diff --git a/packages/frontend/src/components/MkPagingButtons.vue b/packages/frontend/src/components/MkPagingButtons.vue new file mode 100644 index 0000000000..fe59efd83a --- /dev/null +++ b/packages/frontend/src/components/MkPagingButtons.vue @@ -0,0 +1,124 @@ + + + + + + + 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 993532bc1fe0f67d84e16a99ee916f7fff9b0935 Mon Sep 17 00:00:00 2001 From: Kinetix Date: Tue, 28 Jan 2025 15:57:45 -0800 Subject: Adding robots.txt override via admin control panel This is a requested low priority feature in #418 - I created the changes to follow similarly to how the Instance Description is handled. --- locales/index.d.ts | 8 ++++++++ packages/backend/migration/1738098171990-robotsTxt.js | 16 ++++++++++++++++ packages/backend/src/core/entities/MetaEntityService.ts | 1 + packages/backend/src/models/Meta.ts | 5 +++++ packages/backend/src/models/json-schema/meta.ts | 4 ++++ packages/backend/src/server/api/endpoints/admin/meta.ts | 5 +++++ .../src/server/api/endpoints/admin/update-meta.ts | 5 +++++ packages/backend/src/server/web/ClientServerService.ts | 9 ++++++++- packages/frontend/src/pages/admin/settings.vue | 7 +++++++ sharkey-locales/en-US.yml | 3 +++ 10 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 packages/backend/migration/1738098171990-robotsTxt.js (limited to 'packages/backend/src/server/api/endpoints/admin') diff --git a/locales/index.d.ts b/locales/index.d.ts index 3a3b94b89d..70eba52ea0 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -11626,6 +11626,14 @@ export interface Locale extends ILocale { * Scheduled Notes */ "scheduledNotes": string; + /** + * Custom robots.txt + */ + "robotsTxt": string; + /** + * Adding entries here will override the default robots.txt packaged with Sharkey. Maximum 2048 characters. + */ + "robotsTxtDescription": string; } declare const locales: { [lang: string]: Locale; diff --git a/packages/backend/migration/1738098171990-robotsTxt.js b/packages/backend/migration/1738098171990-robotsTxt.js new file mode 100644 index 0000000000..947f21cc46 --- /dev/null +++ b/packages/backend/migration/1738098171990-robotsTxt.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RobotsTxt1738098171990 { + name = 'RobotsTxt1738098171990' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "robotsTxt" character varying(2048)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "robotsTxt"`); + } +} diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 7d7b4cbd81..857e8f5a7b 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -95,6 +95,7 @@ export class MetaEntityService { mcaptchaInstanceUrl: instance.mcaptchaInstanceUrl, enableRecaptcha: instance.enableRecaptcha, enableAchievements: instance.enableAchievements, + robotsTxt: instance.robotsTxt, recaptchaSiteKey: instance.recaptchaSiteKey, enableTurnstile: instance.enableTurnstile, turnstileSiteKey: instance.turnstileSiteKey, diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 3fc3f273dd..a224117676 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -599,6 +599,11 @@ export class MiMeta { }) public enableAchievements: boolean; + @Column('varchar', { + length: 2048, nullable: true, + }) + public robotsTxt: string | null; + @Column('jsonb', { default: { }, }) diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 5179e5d51c..29fdb4f6be 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -139,6 +139,10 @@ export const packedMetaLiteSchema = { type: 'boolean', optional: false, nullable: true, }, + robotsTxt: { + type: 'string', + optional: false, nullable: true, + }, enableTestcaptcha: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 6495e3b7da..436dcf27cb 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -391,6 +391,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + robotsTxt: { + type: 'string', + optional: false, nullable: true, + }, enableIdenticonGeneration: { type: 'boolean', optional: false, nullable: false, @@ -708,6 +712,7 @@ export default class extends Endpoint { // eslint- enableStatsForFederatedInstances: instance.enableStatsForFederatedInstances, enableServerMachineStats: instance.enableServerMachineStats, enableAchievements: instance.enableAchievements, + robotsTxt: instance.robotsTxt, enableIdenticonGeneration: instance.enableIdenticonGeneration, bannedEmailDomains: instance.bannedEmailDomains, policies: { ...DEFAULT_POLICIES, ...instance.policies }, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 72f428d85f..b3733d3d39 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -149,6 +149,7 @@ export const paramDef = { enableStatsForFederatedInstances: { type: 'boolean' }, enableServerMachineStats: { type: 'boolean' }, enableAchievements: { type: 'boolean' }, + robotsTxt: { type: 'string', nullable: true }, enableIdenticonGeneration: { type: 'boolean' }, serverRules: { type: 'array', items: { type: 'string' } }, bannedEmailDomains: { type: 'array', items: { type: 'string' } }, @@ -636,6 +637,10 @@ export default class extends Endpoint { // eslint- set.enableAchievements = ps.enableAchievements; } + if (ps.robotsTxt !== undefined) { + set.robotsTxt = ps.robotsTxt; + } + if (ps.enableIdenticonGeneration !== undefined) { set.enableIdenticonGeneration = ps.enableIdenticonGeneration; } diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index e59314bf55..e93900b358 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -488,7 +488,14 @@ export class ClientServerService { }); fastify.get('/robots.txt', async (request, reply) => { - return await reply.sendFile('/robots.txt', staticAssets); + if (this.meta.robotsTxt) { + let content = ''; + content += this.meta.robotsTxt; + reply.header('Content-Type', 'text/plain'); + return await reply.send(content); + } else { + return await reply.sendFile('/robots.txt', staticAssets); + } }); // OpenSearch XML diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 68f211de5c..cd05b43be8 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -159,6 +159,11 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + +
@@ -369,10 +374,12 @@ const serviceWorkerForm = useForm({ const otherForm = useForm({ enableAchievements: meta.enableAchievements, enableBotTrending: meta.enableBotTrending, + robotsTxt: meta.robotsTxt, }, async (state) => { await os.apiWithDialog('admin/update-meta', { enableAchievements: state.enableAchievements, enableBotTrending: state.enableBotTrending, + robotsTxt: state.robotsTxt, }); fetchInstance(true); }); diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index 6b3c099411..e0430b10ea 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -434,3 +434,6 @@ scheduledNotes: "Scheduled Notes" _permissions: "read:notes-schedule": "View your list of scheduled notes" "write:notes-schedule": "Compose or delete scheduled notes" + +robotsTxt: "Custom robots.txt" +robotsTxtDescription: "Adding entries here will override the default robots.txt packaged with Sharkey. Maximum 2048 characters." -- cgit v1.2.3-freya From 4afe01909e1bb7def7221495f0d8f2c6ca1dabcc Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 3 Feb 2025 18:36:11 -0500 Subject: add FriendlyCaptcha to new captcha admin endpoints --- packages/backend/src/server/api/endpoints/admin/captcha/current.ts | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'packages/backend/src/server/api/endpoints/admin') diff --git a/packages/backend/src/server/api/endpoints/admin/captcha/current.ts b/packages/backend/src/server/api/endpoints/admin/captcha/current.ts index 63ec740348..41192c1926 100644 --- a/packages/backend/src/server/api/endpoints/admin/captcha/current.ts +++ b/packages/backend/src/server/api/endpoints/admin/captcha/current.ts @@ -52,6 +52,13 @@ export const meta = { secretKey: { type: 'string', nullable: true }, }, }, + fc: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + }, + }, }, }, } as const; -- cgit v1.2.3-freya From 5ff6814c74106d94a534f36902cdbfeeed513fe7 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 09:57:16 -0500 Subject: remove unused imports from accounts/create.ts --- packages/backend/src/server/api/endpoints/admin/accounts/create.ts | 1 - 1 file changed, 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints/admin') diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts index 53b1c4c4ec..5843457676 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -6,7 +6,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository } from '@/models/_.js'; -import { MiAccessToken, MiUser } from '@/models/_.js'; import { SignupService } from '@/core/SignupService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { InstanceActorService } from '@/core/InstanceActorService.js'; -- cgit v1.2.3-freya From c889948f95ad9706403ae07072e5ce76002c73b5 Mon Sep 17 00:00:00 2001 From: Marie Date: Fri, 7 Feb 2025 06:00:49 +0100 Subject: feat: Add generation of keys to admin page --- locales/index.d.ts | 14 ++++++ packages/backend/src/server/api/EndpointsModule.ts | 4 ++ packages/backend/src/server/api/endpoints.ts | 2 + .../server/api/endpoints/admin/gen-vapid-keys.ts | 33 ++++++++++++++ packages/frontend/src/pages/admin/settings.vue | 19 ++++++++ packages/misskey-js/src/autogen/apiClientJSDoc.ts | 11 +++++ packages/misskey-js/src/autogen/endpoint.ts | 1 + packages/misskey-js/src/autogen/types.ts | 53 ++++++++++++++++++++++ sharkey-locales/en-US.yml | 5 ++ 9 files changed, 142 insertions(+) create mode 100644 packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts (limited to 'packages/backend/src/server/api/endpoints/admin') diff --git a/locales/index.d.ts b/locales/index.d.ts index 4a46883e9f..b2612d641f 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -11668,6 +11668,20 @@ export interface Locale extends ILocale { */ "parentDefault": string; }; + /** + * Generate Keys + */ + "genKeys": string; + "_genKeysDialog": { + /** + * Are you sure that you want to generate new keys? + */ + "text": string; + /** + * Generate new keys + */ + "title": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index e319d6e0a4..3b72160597 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -101,6 +101,7 @@ import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js'; import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js'; import * as ep___admin_systemWebhook_test from './endpoints/admin/system-webhook/test.js'; +import * as ep___admin_genVapidKeys from './endpoints/admin/gen-vapid-keys.js'; import * as ep___announcements from './endpoints/announcements.js'; import * as ep___announcements_show from './endpoints/announcements/show.js'; import * as ep___antennas_create from './endpoints/antennas/create.js'; @@ -508,6 +509,7 @@ const $admin_systemWebhook_list: Provider = { provide: 'ep:admin/system-webhook/ const $admin_systemWebhook_show: Provider = { provide: 'ep:admin/system-webhook/show', useClass: ep___admin_systemWebhook_show.default }; const $admin_systemWebhook_update: Provider = { provide: 'ep:admin/system-webhook/update', useClass: ep___admin_systemWebhook_update.default }; const $admin_systemWebhook_test: Provider = { provide: 'ep:admin/system-webhook/test', useClass: ep___admin_systemWebhook_test.default }; +const $admin_genVapidKeys: Provider = { provide: 'ep:admin/gen-vapid-keys', useClass: ep___admin_genVapidKeys.default }; const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default }; const $announcements_show: Provider = { provide: 'ep:announcements/show', useClass: ep___announcements_show.default }; const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default }; @@ -919,6 +921,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_systemWebhook_show, $admin_systemWebhook_update, $admin_systemWebhook_test, + $admin_genVapidKeys, $announcements, $announcements_show, $antennas_create, @@ -1324,6 +1327,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_systemWebhook_show, $admin_systemWebhook_update, $admin_systemWebhook_test, + $admin_genVapidKeys, $announcements, $announcements_show, $antennas_create, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index b4f36234f0..b6998ba581 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -108,6 +108,7 @@ import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js'; import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js'; import * as ep___admin_systemWebhook_test from './endpoints/admin/system-webhook/test.js'; +import * as ep___admin_genVapidKeys from './endpoints/admin/gen-vapid-keys.js'; import * as ep___announcements from './endpoints/announcements.js'; import * as ep___announcements_show from './endpoints/announcements/show.js'; import * as ep___antennas_create from './endpoints/antennas/create.js'; @@ -513,6 +514,7 @@ const eps = [ ['admin/system-webhook/show', ep___admin_systemWebhook_show], ['admin/system-webhook/update', ep___admin_systemWebhook_update], ['admin/system-webhook/test', ep___admin_systemWebhook_test], + ['admin/gen-vapid-keys', ep___admin_genVapidKeys], ['announcements', ep___announcements], ['announcements/show', ep___announcements_show], ['antennas/create', ep___antennas_create], diff --git a/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts b/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts new file mode 100644 index 0000000000..5695866265 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: marie and sharkey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import webpush from 'web-push'; +const { generateVAPIDKeys } = webpush; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:meta', +} as const; + +export const paramDef = {} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const keys = await generateVAPIDKeys(); + + return { public: keys.publicKey, private: keys.privateKey }; + }); + } +} diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index cd05b43be8..ed8b75f9e8 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -138,6 +138,8 @@ SPDX-License-Identifier: AGPL-3.0-only + + {{ i18n.ts.genKeys }}
@@ -434,6 +436,23 @@ function chooseProxyAccount() { }); } +async function genKeys() { + if (serviceWorkerForm.savedState.swPrivateKey) { + os.confirm({ type: 'warning', title: i18n.ts._genKeysDialog.title, text: i18n.ts._genKeysDialog.text }).then(result => { + if (result.canceled) return; + os.apiWithDialog('admin/gen-vapid-keys', {}).then(res => { + serviceWorkerForm.state.swPublicKey = res.public; + serviceWorkerForm.state.swPrivateKey = res.private; + }); + }); + } else { + os.apiWithDialog('admin/gen-vapid-keys', {}).then(res => { + serviceWorkerForm.state.swPublicKey = res.public; + serviceWorkerForm.state.swPrivateKey = res.private; + }); + } +} + const headerTabs = computed(() => []); definePageMetadata(() => ({ diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index ccb513b7f9..34dbafe686 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -1060,6 +1060,17 @@ declare module '../api.js' { 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 66e7126460..2ba62ea3cd 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -700,6 +700,7 @@ export type Endpoints = { 'admin/system-webhook/show': { req: AdminSystemWebhookShowRequest; res: AdminSystemWebhookShowResponse }; 'admin/system-webhook/update': { req: AdminSystemWebhookUpdateRequest; res: AdminSystemWebhookUpdateResponse }; 'admin/system-webhook/test': { req: AdminSystemWebhookTestRequest; res: EmptyResponse }; + 'admin/gen-vapid-keys': { req: EmptyRequest; res: EmptyResponse }; 'announcements': { req: AnnouncementsRequest; res: AnnouncementsResponse }; 'announcements/show': { req: AnnouncementsShowRequest; res: AnnouncementsShowResponse }; 'antennas/create': { req: AntennasCreateRequest; res: AntennasCreateResponse }; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index c7268ade6a..db709b2889 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -879,6 +879,15 @@ export type paths = { */ post: operations['admin___system-webhook___test']; }; + '/admin/gen-vapid-keys': { + /** + * admin/gen-vapid-keys + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:meta* + */ + post: operations['admin___gen-vapid-keys']; + }; '/announcements': { /** * announcements @@ -11203,6 +11212,50 @@ export type operations = { }; }; }; + /** + * admin/gen-vapid-keys + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:meta* + */ + 'admin___gen-vapid-keys': { + 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']; + }; + }; + }; + }; /** * announcements * @description No description provided. diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index cd3a44407a..068d13faab 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -447,3 +447,8 @@ _defaultCWPriority: parent: "Use Parent (use the inherited CW, ignoring the default CW)" defaultParent: "Use Default, then Parent (use the default CW, and append the inherited CW)" parentDefault: "Use Parent, then Default (use the inherited CW, and append the default CW)" + +genKeys: "Generate Keys" +_genKeysDialog: + text: "Are you sure that you want to generate new keys?" + title: "Generate new keys" -- cgit v1.2.3-freya From 2bf8648ebc547f6ca335392b7fe20899f1b53862 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 28 Jan 2025 01:41:10 -0500 Subject: refresh cache when marking a user as NSFW --- packages/backend/src/server/api/endpoints/admin/nsfw-user.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) (limited to 'packages/backend/src/server/api/endpoints/admin') diff --git a/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts b/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts index d3fa4251dd..f64ba7f48a 100644 --- a/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; export const meta = { tags: ['admin'], @@ -28,10 +29,12 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( @Inject(DI.usersRepository) - private usersRepository: UsersRepository, + private readonly usersRepository: UsersRepository, @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, + private readonly userProfilesRepository: UserProfilesRepository, + + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy({ id: ps.userId }); @@ -43,6 +46,8 @@ export default class extends Endpoint { // eslint- await this.userProfilesRepository.update(user.id, { alwaysMarkNsfw: true, }); + + await this.cacheService.userProfileCache.refresh(ps.userId); }); } } -- cgit v1.2.3-freya From ea89348b62706c4f6fbeaf603fc73d1b9874e7d0 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 28 Jan 2025 01:47:03 -0500 Subject: add user-level "force content warning" moderation feature --- locales/index.d.ts | 8 + .../1738043621143-add_user_mandatoryCW.js | 11 + packages/backend/src/core/NoteCreateService.ts | 11 + packages/backend/src/core/NoteEditService.ts | 10 + packages/backend/src/models/User.ts | 9 + .../src/server/api/endpoints/admin/cw-user.ts | 53 ++ .../src/server/api/endpoints/admin/show-user.ts | 5 + packages/frontend/src/pages/admin-user.vue | 16 + packages/misskey-js/src/autogen/apiClientJSDoc.ts | 814 ++++++++++----------- packages/misskey-js/src/consts.ts | 1 + sharkey-locales/en-US.yml | 3 + 11 files changed, 534 insertions(+), 407 deletions(-) create mode 100644 packages/backend/migration/1738043621143-add_user_mandatoryCW.js create mode 100644 packages/backend/src/server/api/endpoints/admin/cw-user.ts (limited to 'packages/backend/src/server/api/endpoints/admin') diff --git a/locales/index.d.ts b/locales/index.d.ts index 9624b48b42..65e8096403 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -12089,6 +12089,14 @@ export interface Locale extends ILocale { * ID */ "id": string; + /** + * Force content warning + */ + "mandatoryCW": string; + /** + * Applies a content warning to all posts created by this user. If the post already has a CW, then this is appended to the end. + */ + "mandatoryCWDescription": string; } declare const locales: { [lang: string]: Locale; diff --git a/packages/backend/migration/1738043621143-add_user_mandatoryCW.js b/packages/backend/migration/1738043621143-add_user_mandatoryCW.js new file mode 100644 index 0000000000..dd05076dd2 --- /dev/null +++ b/packages/backend/migration/1738043621143-add_user_mandatoryCW.js @@ -0,0 +1,11 @@ +export class AddUserMandatoryCW1738043621143 { + name = 'AddUserCW1738043621143' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "mandatoryCW" text`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "mandatoryCW"`); + } +} diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index f24c665659..ecf711e011 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -234,6 +234,7 @@ export class NoteCreateService implements OnApplicationShutdown { host: MiUser['host']; isBot: MiUser['isBot']; noindex: MiUser['noindex']; + mandatoryCW: MiUser['mandatoryCW']; }, data: Option, silent = false): Promise { // チャンネル外にリプライしたら対象のスコープに合わせる // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) @@ -368,6 +369,15 @@ export class NoteCreateService implements OnApplicationShutdown { data.cw = null; } + // Apply mandatory CW, if applicable + if (user.mandatoryCW) { + if (data.cw) { + data.cw += `, ${user.mandatoryCW}`; + } else { + data.cw = user.mandatoryCW; + } + } + let tags = data.apHashtags; let emojis = data.apEmojis; let mentionedUsers = data.apMentions; @@ -441,6 +451,7 @@ export class NoteCreateService implements OnApplicationShutdown { host: MiUser['host']; isBot: MiUser['isBot']; noindex: MiUser['noindex']; + mandatoryCW: MiUser['mandatoryCW']; }, data: Option): Promise { return this.create(user, data, true); } diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 18912181d7..1f947aaffb 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -230,6 +230,7 @@ export class NoteEditService implements OnApplicationShutdown { host: MiUser['host']; isBot: MiUser['isBot']; noindex: MiUser['noindex']; + mandatoryCW: MiUser['mandatoryCW']; }, editid: MiNote['id'], data: Option, silent = false): Promise { if (!editid) { throw new Error('fail'); @@ -396,6 +397,15 @@ export class NoteEditService implements OnApplicationShutdown { data.cw = null; } + // Apply mandatory CW, if applicable + if (user.mandatoryCW) { + if (data.cw) { + data.cw += `, ${user.mandatoryCW}`; + } else { + data.cw = user.mandatoryCW; + } + } + let tags = data.apHashtags; let emojis = data.apEmojis; let mentionedUsers = data.apMentions; diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 3a825d36a7..8a3ad1003d 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -339,6 +339,15 @@ export class MiUser { }) public enableRss: boolean; + /** + * Specifies a Content Warning that should be forcibly applied to all notes by this user. + * If null (default), then no Content Warning is applied. + */ + @Column('text', { + nullable: true, + }) + public mandatoryCW: string | null; + constructor(data: Partial) { if (data == null) return; diff --git a/packages/backend/src/server/api/endpoints/admin/cw-user.ts b/packages/backend/src/server/api/endpoints/admin/cw-user.ts new file mode 100644 index 0000000000..d48ca565a4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/cw-user.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UsersRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:cw-user', +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + cw: { type: 'string', nullable: true }, + }, + required: ['userId', 'cw'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.usersRepository) + private readonly usersRepository: UsersRepository, + + private readonly globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async ps => { + const result = await this.usersRepository.update(ps.userId, { + // Collapse empty strings to null + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + mandatoryCW: ps.cw || null, + }); + + if (result.affected && result.affected < 1) { + throw new Error('No such user'); + } + + // Synchronize caches and other processes + this.globalEventService.publishInternalEvent('localUserUpdated', { id: ps.userId }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index 669bffe2dc..0f0b0f8e7a 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -144,6 +144,10 @@ export const meta = { type: 'string', optional: false, nullable: false, }, + mandatoryCW: { + type: 'string', + optional: false, nullable: true, + }, signins: { type: 'array', optional: false, nullable: false, @@ -260,6 +264,7 @@ export default class extends Endpoint { // eslint- isHibernated: user.isHibernated, lastActiveDate: user.lastActiveDate ? user.lastActiveDate.toISOString() : null, moderationNote: profile.moderationNote ?? '', + mandatoryCW: user.mandatoryCW, signins, policies: await this.roleService.getUserPolicies(user.id), roles: await this.roleEntityService.packMany(roles, me), diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index 11a34d34ef..e21db84334 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -83,6 +83,11 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.suspend }} {{ i18n.ts.markAsNSFW }} + + + + +
{{ i18n.ts.resetPassword }}
@@ -222,6 +227,7 @@ import { i18n } from '@/i18n.js'; import { iAmAdmin, $i, iAmModerator } from '@/account.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; import MkPagination from '@/components/MkPagination.vue'; +import MkInput from '@/components/MkInput.vue'; const props = withDefaults(defineProps<{ userId: string; @@ -243,6 +249,7 @@ const approved = ref(false); const suspended = ref(false); const markedAsNSFW = ref(false); const moderationNote = ref(''); +const mandatoryCW = ref(null); const isSystem = computed(() => info.value?.isSystem ?? false); const filesPagination = { endpoint: 'admin/drive/files' as const, @@ -281,6 +288,15 @@ function createFetcher() { markedAsNSFW.value = info.value.alwaysMarkNsfw; suspended.value = info.value.isSuspended; moderationNote.value = info.value.moderationNote; + mandatoryCW.value = info.value.mandatoryCW; + + // These watch statements work because they're lazy-initialized. + // The watched values are already set, so they don't trigger any "change" just from refreshing the user. + + watch(mandatoryCW, async () => { + await os.apiWithDialog('admin/cw-user', { userId: props.userId, cw: mandatoryCW.value }); + refreshUser(); + }); watch(moderationNote, async () => { await misskeyApi('admin/update-user-note', { userId: user.value.id, text: moderationNote.value }); diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index f4120b3afc..5e47ad15ad 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -5,7 +5,7 @@ declare module '../api.js' { export interface APIClient { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* / **Permission**: *write:admin:abuse-report:notification-recipient* */ @@ -17,7 +17,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* / **Permission**: *write:admin:abuse-report:notification-recipient* */ @@ -29,7 +29,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* / **Permission**: *read:admin:abuse-report:notification-recipient* */ @@ -41,7 +41,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* / **Permission**: *read:admin:abuse-report:notification-recipient* */ @@ -53,7 +53,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* / **Permission**: *write:admin:abuse-report:notification-recipient* */ @@ -65,7 +65,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:abuse-user-reports* */ request( @@ -76,7 +76,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -87,7 +87,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:account* */ request( @@ -98,7 +98,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:account* */ request( @@ -109,7 +109,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:ad* */ request( @@ -120,7 +120,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:ad* */ request( @@ -131,7 +131,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:ad* */ request( @@ -142,7 +142,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:ad* */ request( @@ -153,7 +153,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:announcements* */ request( @@ -164,7 +164,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:announcements* */ request( @@ -175,7 +175,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:announcements* */ request( @@ -186,7 +186,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:announcements* */ request( @@ -197,7 +197,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:approve-user* */ request( @@ -208,7 +208,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:avatar-decorations* */ request( @@ -219,7 +219,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:avatar-decorations* */ request( @@ -230,7 +230,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:avatar-decorations* */ request( @@ -241,7 +241,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:avatar-decorations* */ request( @@ -252,7 +252,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:meta* */ request( @@ -263,7 +263,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:meta* */ request( @@ -274,7 +274,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:decline-user* */ request( @@ -285,7 +285,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:delete-account* */ request( @@ -296,7 +296,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:delete-all-files-of-a-user* */ request( @@ -307,7 +307,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:drive* */ request( @@ -318,7 +318,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:drive* */ request( @@ -329,7 +329,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:drive* */ request( @@ -340,7 +340,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:drive* */ request( @@ -351,7 +351,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:emoji* */ request( @@ -362,7 +362,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:emoji* */ request( @@ -373,7 +373,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:emoji* */ request( @@ -384,7 +384,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:emoji* */ request( @@ -395,7 +395,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:emoji* */ request( @@ -406,7 +406,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -418,7 +418,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:emoji* */ request( @@ -429,7 +429,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:emoji* */ request( @@ -440,7 +440,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:emoji* */ request( @@ -451,7 +451,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:emoji* */ request( @@ -462,7 +462,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:emoji* */ request( @@ -473,7 +473,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:emoji* */ request( @@ -484,7 +484,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:emoji* */ request( @@ -495,7 +495,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:federation* */ request( @@ -506,7 +506,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:federation* */ request( @@ -517,7 +517,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:federation* */ request( @@ -528,7 +528,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:federation* */ request( @@ -539,7 +539,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:resolve-abuse-user-report* */ request( @@ -550,7 +550,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:meta* */ request( @@ -561,7 +561,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:index-stats* */ request( @@ -572,7 +572,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:table-stats* */ request( @@ -583,7 +583,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:user-ips* */ request( @@ -594,7 +594,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:invite-codes* */ request( @@ -605,7 +605,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:invite-codes* */ request( @@ -616,7 +616,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:meta* */ request( @@ -627,7 +627,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:nsfw-user* */ request( @@ -638,7 +638,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:promo* */ request( @@ -649,7 +649,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:queue* */ request( @@ -660,7 +660,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:queue* */ request( @@ -671,7 +671,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:queue* */ request( @@ -682,7 +682,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:queue* */ request( @@ -693,7 +693,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:emoji* */ request( @@ -704,7 +704,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:relays* */ request( @@ -715,7 +715,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:relays* */ request( @@ -726,7 +726,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:relays* */ request( @@ -737,7 +737,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:reset-password* */ request( @@ -748,7 +748,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:resolve-abuse-user-report* */ request( @@ -759,7 +759,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:roles* */ request( @@ -770,7 +770,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:roles* */ request( @@ -781,7 +781,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:roles* */ request( @@ -792,7 +792,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:roles* */ request( @@ -803,7 +803,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:roles* */ request( @@ -814,7 +814,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:roles* */ request( @@ -825,7 +825,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:roles* */ request( @@ -836,7 +836,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:roles* */ request( @@ -847,7 +847,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* / **Permission**: *read:admin:roles* */ request( @@ -858,7 +858,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:send-email* */ request( @@ -869,7 +869,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:server-info* */ request( @@ -880,7 +880,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:show-moderation-log* */ request( @@ -891,7 +891,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:show-user* */ request( @@ -902,7 +902,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:show-user* */ request( @@ -913,7 +913,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:silence-user* */ request( @@ -924,7 +924,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:suspend-user* */ request( @@ -935,7 +935,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* */ @@ -947,7 +947,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* */ @@ -959,7 +959,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* */ @@ -971,7 +971,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* */ @@ -983,7 +983,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* / **Permission**: *read:admin:system-webhook* */ @@ -995,7 +995,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* */ @@ -1007,7 +1007,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:unnsfw-user* */ request( @@ -1018,7 +1018,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:unset-user-avatar* */ request( @@ -1029,7 +1029,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:unset-user-banner* */ request( @@ -1040,7 +1040,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:unsilence-user* */ request( @@ -1051,7 +1051,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:unsuspend-user* */ request( @@ -1062,7 +1062,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:resolve-abuse-user-report* */ request( @@ -1073,7 +1073,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:meta* */ request( @@ -1084,7 +1084,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:admin:user-note* */ request( @@ -1095,7 +1095,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1106,7 +1106,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1117,7 +1117,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -1128,7 +1128,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -1139,7 +1139,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -1150,7 +1150,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -1161,7 +1161,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -1172,7 +1172,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -1183,7 +1183,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:federation* */ request( @@ -1194,7 +1194,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -1205,7 +1205,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1216,7 +1216,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1227,7 +1227,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -1239,7 +1239,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1250,7 +1250,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1261,7 +1261,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1272,7 +1272,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:blocks* */ request( @@ -1283,7 +1283,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:blocks* */ request( @@ -1294,7 +1294,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:blocks* */ request( @@ -1305,7 +1305,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1316,7 +1316,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -1327,7 +1327,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:channels* */ request( @@ -1338,7 +1338,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:channels* */ request( @@ -1349,7 +1349,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1360,7 +1360,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:channels* */ request( @@ -1371,7 +1371,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:channels* */ request( @@ -1382,7 +1382,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:channels* */ request( @@ -1393,7 +1393,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:channels* */ request( @@ -1404,7 +1404,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1415,7 +1415,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1426,7 +1426,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1437,7 +1437,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:channels* */ request( @@ -1448,7 +1448,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:channels* */ request( @@ -1459,7 +1459,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:channels* */ request( @@ -1470,7 +1470,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1481,7 +1481,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1492,7 +1492,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1503,7 +1503,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1514,7 +1514,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1525,7 +1525,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1536,7 +1536,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1547,7 +1547,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1558,7 +1558,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1569,7 +1569,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1580,7 +1580,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1591,7 +1591,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1602,7 +1602,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -1613,7 +1613,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -1624,7 +1624,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -1635,7 +1635,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:clip-favorite* */ request( @@ -1646,7 +1646,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -1657,7 +1657,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:clip-favorite* */ request( @@ -1668,7 +1668,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* / **Permission**: *read:account* */ request( @@ -1679,7 +1679,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -1690,7 +1690,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* / **Permission**: *read:account* */ request( @@ -1701,7 +1701,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:clip-favorite* */ request( @@ -1712,7 +1712,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -1723,7 +1723,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:drive* */ request( @@ -1734,7 +1734,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:drive* */ request( @@ -1745,7 +1745,7 @@ declare module '../api.js' { /** * Find the notes to which the given file is attached. - * + * * **Credential required**: *Yes* / **Permission**: *read:drive* */ request( @@ -1756,7 +1756,7 @@ declare module '../api.js' { /** * Check if a given file exists. - * + * * **Credential required**: *Yes* / **Permission**: *read:drive* */ request( @@ -1767,7 +1767,7 @@ declare module '../api.js' { /** * Upload a new drive file. - * + * * **Credential required**: *Yes* / **Permission**: *write:drive* */ request( @@ -1778,7 +1778,7 @@ declare module '../api.js' { /** * Delete an existing drive file. - * + * * **Credential required**: *Yes* / **Permission**: *write:drive* */ request( @@ -1789,7 +1789,7 @@ declare module '../api.js' { /** * Search for a drive file by the given parameters. - * + * * **Credential required**: *Yes* / **Permission**: *read:drive* */ request( @@ -1800,7 +1800,7 @@ declare module '../api.js' { /** * Search for a drive file by a hash of the contents. - * + * * **Credential required**: *Yes* / **Permission**: *read:drive* */ request( @@ -1811,7 +1811,7 @@ declare module '../api.js' { /** * Show the properties of a drive file. - * + * * **Credential required**: *Yes* / **Permission**: *read:drive* */ request( @@ -1822,7 +1822,7 @@ declare module '../api.js' { /** * Update the properties of a drive file. - * + * * **Credential required**: *Yes* / **Permission**: *write:drive* */ request( @@ -1833,7 +1833,7 @@ declare module '../api.js' { /** * Request the server to download a new drive file from the specified URL. - * + * * **Credential required**: *Yes* / **Permission**: *write:drive* */ request( @@ -1844,7 +1844,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:drive* */ request( @@ -1855,7 +1855,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:drive* */ request( @@ -1866,7 +1866,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:drive* */ request( @@ -1877,7 +1877,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:drive* */ request( @@ -1888,7 +1888,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:drive* */ request( @@ -1899,7 +1899,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:drive* */ request( @@ -1910,7 +1910,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:drive* */ request( @@ -1921,7 +1921,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1932,7 +1932,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1943,7 +1943,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1954,7 +1954,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1965,7 +1965,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -1976,7 +1976,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -1988,7 +1988,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -1999,7 +1999,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -2010,7 +2010,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2021,7 +2021,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2032,7 +2032,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2043,7 +2043,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2054,7 +2054,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2065,7 +2065,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2077,7 +2077,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2088,7 +2088,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:flash* */ request( @@ -2099,7 +2099,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:flash* */ request( @@ -2110,7 +2110,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2121,7 +2121,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:flash-likes* */ request( @@ -2132,7 +2132,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:flash* */ request( @@ -2143,7 +2143,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:flash-likes* */ request( @@ -2154,7 +2154,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2165,7 +2165,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:flash-likes* */ request( @@ -2176,7 +2176,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:flash* */ request( @@ -2187,7 +2187,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:following* */ request( @@ -2198,7 +2198,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:following* */ request( @@ -2209,7 +2209,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:following* */ request( @@ -2220,7 +2220,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:following* */ request( @@ -2231,7 +2231,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:following* */ request( @@ -2242,7 +2242,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:following* */ request( @@ -2253,7 +2253,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:following* */ request( @@ -2264,7 +2264,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:following* */ request( @@ -2275,7 +2275,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:following* */ request( @@ -2286,7 +2286,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:following* */ request( @@ -2297,7 +2297,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2308,7 +2308,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2319,7 +2319,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2330,7 +2330,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:gallery* */ request( @@ -2341,7 +2341,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:gallery* */ request( @@ -2352,7 +2352,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:gallery-likes* */ request( @@ -2363,7 +2363,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2374,7 +2374,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:gallery-likes* */ request( @@ -2385,7 +2385,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:gallery* */ request( @@ -2396,7 +2396,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2407,7 +2407,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2418,7 +2418,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2429,7 +2429,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2440,7 +2440,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2451,7 +2451,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2462,7 +2462,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -2473,7 +2473,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -2484,7 +2484,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2496,7 +2496,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2508,7 +2508,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2520,7 +2520,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2532,7 +2532,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2544,7 +2544,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2556,7 +2556,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2568,7 +2568,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2580,7 +2580,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2592,7 +2592,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2604,7 +2604,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2616,7 +2616,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -2627,7 +2627,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2639,7 +2639,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2651,7 +2651,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2663,7 +2663,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2675,7 +2675,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2687,7 +2687,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2699,7 +2699,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2711,7 +2711,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2723,7 +2723,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2735,7 +2735,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2747,7 +2747,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:favorites* */ request( @@ -2758,7 +2758,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:gallery-likes* */ request( @@ -2769,7 +2769,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:gallery* */ request( @@ -2780,7 +2780,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2792,7 +2792,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2804,7 +2804,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2816,7 +2816,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2828,7 +2828,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2840,7 +2840,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2852,7 +2852,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2864,7 +2864,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:notifications* */ request( @@ -2875,7 +2875,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:notifications* */ request( @@ -2886,7 +2886,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:page-likes* */ request( @@ -2897,7 +2897,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:pages* */ request( @@ -2908,7 +2908,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -2919,7 +2919,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -2930,7 +2930,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -2941,7 +2941,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -2953,7 +2953,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -2964,7 +2964,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -2975,7 +2975,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -2986,7 +2986,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -2997,7 +2997,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -3008,7 +3008,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -3019,7 +3019,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -3030,7 +3030,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -3042,7 +3042,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -3053,7 +3053,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -3065,7 +3065,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -3077,7 +3077,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -3088,7 +3088,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -3099,7 +3099,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -3111,7 +3111,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -3122,7 +3122,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -3133,7 +3133,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -3144,7 +3144,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -3155,7 +3155,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* / **Permission**: *read:account* */ @@ -3167,7 +3167,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -3178,7 +3178,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:invite-codes* */ request( @@ -3189,7 +3189,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:invite-codes* */ request( @@ -3200,7 +3200,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:invite-codes* */ request( @@ -3211,7 +3211,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:invite-codes* */ request( @@ -3222,7 +3222,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3233,7 +3233,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -3245,7 +3245,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:mutes* */ request( @@ -3256,7 +3256,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:mutes* */ request( @@ -3267,7 +3267,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:mutes* */ request( @@ -3278,7 +3278,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -3289,7 +3289,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3300,7 +3300,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3311,7 +3311,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3322,7 +3322,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3333,7 +3333,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3344,7 +3344,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:notes* */ request( @@ -3355,7 +3355,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:notes* */ request( @@ -3366,7 +3366,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:notes* */ request( @@ -3377,7 +3377,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:favorites* */ request( @@ -3388,7 +3388,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:favorites* */ request( @@ -3399,7 +3399,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3410,7 +3410,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -3421,7 +3421,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3432,7 +3432,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -3443,7 +3443,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:reactions* */ request( @@ -3454,7 +3454,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3465,7 +3465,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -3476,7 +3476,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -3487,7 +3487,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:federation* */ request( @@ -3498,7 +3498,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:votes* */ request( @@ -3509,7 +3509,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3520,7 +3520,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:reactions* */ request( @@ -3531,7 +3531,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:reactions* */ request( @@ -3542,7 +3542,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3553,7 +3553,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3564,7 +3564,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:notes-schedule* */ request( @@ -3575,7 +3575,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:notes-schedule* */ request( @@ -3586,7 +3586,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:notes-schedule* */ request( @@ -3597,7 +3597,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3608,7 +3608,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3619,7 +3619,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3630,7 +3630,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -3641,7 +3641,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -3652,7 +3652,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -3663,7 +3663,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -3674,7 +3674,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -3685,7 +3685,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:notes* */ request( @@ -3696,7 +3696,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -3707,7 +3707,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3718,7 +3718,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:notifications* */ request( @@ -3729,7 +3729,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:notifications* */ request( @@ -3740,7 +3740,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:notifications* */ request( @@ -3751,7 +3751,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:notifications* */ request( @@ -3762,7 +3762,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -3774,7 +3774,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:pages* */ request( @@ -3785,7 +3785,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:pages* */ request( @@ -3796,7 +3796,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3807,7 +3807,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:page-likes* */ request( @@ -3818,7 +3818,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3829,7 +3829,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:page-likes* */ request( @@ -3840,7 +3840,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:pages* */ request( @@ -3851,7 +3851,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3862,7 +3862,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3873,7 +3873,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -3884,7 +3884,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:mutes* */ request( @@ -3895,7 +3895,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:mutes* */ request( @@ -3906,7 +3906,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:mutes* */ request( @@ -3917,7 +3917,7 @@ declare module '../api.js' { /** * Request a users password to be reset. - * + * * **Credential required**: *No* */ request( @@ -3928,7 +3928,7 @@ declare module '../api.js' { /** * Only available when running with NODE_ENV=testing. Reset the database and flush Redis. - * + * * **Credential required**: *No* */ request( @@ -3939,7 +3939,7 @@ declare module '../api.js' { /** * Complete the password reset that was previously requested. - * + * * **Credential required**: *No* */ request( @@ -3950,7 +3950,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3961,7 +3961,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -3972,7 +3972,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -3983,7 +3983,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -3994,7 +3994,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -4005,7 +4005,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -4016,7 +4016,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -4027,7 +4027,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -4038,7 +4038,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -4049,7 +4049,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -4060,7 +4060,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -4071,7 +4071,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -4082,7 +4082,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -4093,7 +4093,7 @@ declare module '../api.js' { /** * Get Sharkey Sponsors or Instance Sponsors - * + * * **Credential required**: *No* */ request( @@ -4104,7 +4104,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -4115,7 +4115,7 @@ declare module '../api.js' { /** * Register to receive push notifications. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -4127,7 +4127,7 @@ declare module '../api.js' { /** * Check push notification registration exists. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -4139,7 +4139,7 @@ declare module '../api.js' { /** * Unregister from receiving push notifications. - * + * * **Credential required**: *No* */ request( @@ -4150,7 +4150,7 @@ declare module '../api.js' { /** * Update push notification registration. - * + * * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. * **Credential required**: *Yes* */ @@ -4162,7 +4162,7 @@ declare module '../api.js' { /** * Endpoint for testing input validation. - * + * * **Credential required**: *No* */ request( @@ -4173,7 +4173,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -4184,7 +4184,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -4195,7 +4195,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -4206,7 +4206,7 @@ declare module '../api.js' { /** * Show all clips this user owns. - * + * * **Credential required**: *No* */ request( @@ -4217,7 +4217,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -4228,7 +4228,7 @@ declare module '../api.js' { /** * Show all flashs this user created. - * + * * **Credential required**: *No* */ request( @@ -4239,7 +4239,7 @@ declare module '../api.js' { /** * Show everyone that follows this user. - * + * * **Credential required**: *No* */ request( @@ -4250,7 +4250,7 @@ declare module '../api.js' { /** * Show everyone that this user is following. - * + * * **Credential required**: *No* */ request( @@ -4261,7 +4261,7 @@ declare module '../api.js' { /** * Show all gallery posts by the given user. - * + * * **Credential required**: *No* */ request( @@ -4272,7 +4272,7 @@ declare module '../api.js' { /** * Get a list of other users that the specified user frequently replies to. - * + * * **Credential required**: *No* */ request( @@ -4283,7 +4283,7 @@ declare module '../api.js' { /** * Create a new list of users. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -4294,7 +4294,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -4305,7 +4305,7 @@ declare module '../api.js' { /** * Delete an existing list of users. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -4316,7 +4316,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -4327,7 +4327,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* / **Permission**: *read:account* */ request( @@ -4338,7 +4338,7 @@ declare module '../api.js' { /** * Show all lists that the authenticated user has created. - * + * * **Credential required**: *No* / **Permission**: *read:account* */ request( @@ -4349,7 +4349,7 @@ declare module '../api.js' { /** * Remove a user from a list. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -4360,7 +4360,7 @@ declare module '../api.js' { /** * Add a user to an existing list. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -4371,7 +4371,7 @@ declare module '../api.js' { /** * Show the properties of a list. - * + * * **Credential required**: *No* / **Permission**: *read:account* */ request( @@ -4382,7 +4382,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -4393,7 +4393,7 @@ declare module '../api.js' { /** * Update the properties of a list. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -4404,7 +4404,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -4415,7 +4415,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *No* */ request( @@ -4426,7 +4426,7 @@ declare module '../api.js' { /** * Show all pages this user created. - * + * * **Credential required**: *No* */ request( @@ -4437,7 +4437,7 @@ declare module '../api.js' { /** * Show all reactions this user made. - * + * * **Credential required**: *No* */ request( @@ -4448,7 +4448,7 @@ declare module '../api.js' { /** * Show users that the authenticated user might be interested to follow. - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -4459,7 +4459,7 @@ declare module '../api.js' { /** * Show the different kinds of relations between the authenticated user and the specified user(s). - * + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request( @@ -4470,7 +4470,7 @@ declare module '../api.js' { /** * File a report. - * + * * **Credential required**: *Yes* / **Permission**: *write:report-abuse* */ request( @@ -4481,7 +4481,7 @@ declare module '../api.js' { /** * Search for users. - * + * * **Credential required**: *No* */ request( @@ -4492,7 +4492,7 @@ declare module '../api.js' { /** * Search for a user by username and/or host. - * + * * **Credential required**: *No* */ request( @@ -4503,7 +4503,7 @@ declare module '../api.js' { /** * Show the properties of a user. - * + * * **Credential required**: *No* */ request( @@ -4514,7 +4514,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *write:account* */ request( @@ -4525,7 +4525,7 @@ declare module '../api.js' { /** * No description provided. - * + * * **Credential required**: *Yes* / **Permission**: *read:admin:emoji* */ request( diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index 0faf3dddc4..1d4950ceea 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -83,6 +83,7 @@ export const permissions = [ 'write:admin:decline-user', 'write:admin:nsfw-user', 'write:admin:unnsfw-user', + 'write:admin:cw-user', 'write:admin:silence-user', 'write:admin:unsilence-user', 'write:admin:unset-user-avatar', diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index 86eafc8a33..b95a912a5f 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -471,3 +471,6 @@ _noteSearch: flash: "Flash" id: "ID" + +mandatoryCW: "Force content warning" +mandatoryCWDescription: "Applies a content warning to all posts created by this user. If the post already has a CW, then this is appended to the end." -- cgit v1.2.3-freya From 568d82a9746d3d67a756b13fc007beb057dcc011 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 28 Jan 2025 11:39:41 -0500 Subject: record ModLog entry when setting a user's content warning --- locales/index.d.ts | 4 ++++ .../src/server/api/endpoints/admin/cw-user.ts | 26 +++++++++++++++++----- packages/backend/src/types.ts | 8 +++++++ .../frontend/src/pages/admin/modlog.ModLog.vue | 8 +++++++ packages/misskey-js/etc/misskey-js.api.md | 5 ++++- packages/misskey-js/src/consts.ts | 8 +++++++ packages/misskey-js/src/entities.ts | 4 ++-- sharkey-locales/en-US.yml | 1 + 8 files changed, 55 insertions(+), 9 deletions(-) (limited to 'packages/backend/src/server/api/endpoints/admin') diff --git a/locales/index.d.ts b/locales/index.d.ts index 39feda5075..5b7df00f93 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -10218,6 +10218,10 @@ export interface Locale extends ILocale { * Declined */ "decline": string; + /** + * Set content warning for user + */ + "setMandatoryCW": string; /** * Set remote instance as NSFW */ diff --git a/packages/backend/src/server/api/endpoints/admin/cw-user.ts b/packages/backend/src/server/api/endpoints/admin/cw-user.ts index d48ca565a4..bdcfa6a0d9 100644 --- a/packages/backend/src/server/api/endpoints/admin/cw-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/cw-user.ts @@ -9,6 +9,7 @@ import type { UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { CacheService } from '@/core/CacheService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -34,18 +35,31 @@ export default class extends Endpoint { // eslint- private readonly usersRepository: UsersRepository, private readonly globalEventService: GlobalEventService, + private readonly cacheService: CacheService, + private readonly moderationLogService: ModerationLogService, ) { - super(meta, paramDef, async ps => { - const result = await this.usersRepository.update(ps.userId, { + super(meta, paramDef, async (ps, me) => { + const user = await this.cacheService.findUserById(ps.userId); + + // Skip if there's nothing to do + if (user.mandatoryCW === ps.cw) return; + + // Log event first. + // This ensures that we don't "lose" the log if an error occurs + await this.moderationLogService.log(me, 'setMandatoryCW', { + newCW: ps.cw, + oldCW: user.mandatoryCW, + userId: user.id, + userUsername: user.username, + userHost: user.host, + }); + + await this.usersRepository.update(ps.userId, { // Collapse empty strings to null // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing mandatoryCW: ps.cw || null, }); - if (result.affected && result.affected < 1) { - throw new Error('No such user'); - } - // Synchronize caches and other processes this.globalEventService.publishInternalEvent('localUserUpdated', { id: ps.userId }); }); diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 067481d9da..b359fa5a39 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -100,6 +100,7 @@ export const moderationLogTypes = [ 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'resetPassword', + 'setMandatoryCW', 'setRemoteInstanceNSFW', 'unsetRemoteInstanceNSFW', 'suspendRemoteInstance', @@ -261,6 +262,13 @@ export type ModerationLogPayloads = { userUsername: string; userHost: string | null; }; + setMandatoryCW: { + newCW: string | null; + oldCW: string | null; + userId: string; + userUsername: string; + userHost: string | null; + }; setRemoteInstanceNSFW: { id: string; host: string; diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index 2e5c820054..741de875bc 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.logYellow]: [ 'markSensitiveDriveFile', 'resetPassword', + 'setMandatoryCW', 'suspendRemoteInstance', 'setRemoteInstanceNSFW', 'unsetRemoteInstanceNSFW', @@ -55,6 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only : @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} : @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} : @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} + : @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} : @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} {{ log.info.roleName }} : @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} {{ log.info.roleName }} : {{ log.info.role.name }} @@ -123,6 +125,12 @@ SPDX-License-Identifier: AGPL-3.0-only + diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index ee8bfe322c..25921d14c8 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2609,6 +2609,9 @@ type ModerationLog = { } | { type: 'deleteUserAnnouncement'; info: ModerationLogPayloads['deleteUserAnnouncement']; +} | { + type: 'setMandatoryCW'; + info: ModerationLogPayloads['setMandatoryCW']; } | { type: 'setRemoteInstanceNSFW'; info: ModerationLogPayloads['setRemoteInstanceNSFW']; @@ -2708,7 +2711,7 @@ type ModerationLog = { }); // @public (undocumented) -export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "approve", "decline", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "setRemoteInstanceNSFW", "unsetRemoteInstanceNSFW", "suspendRemoteInstance", "unsuspendRemoteInstance", "rejectRemoteInstanceReports", "acceptRemoteInstanceReports", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "forwardAbuseReport", "updateAbuseReportNote", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner", "createSystemWebhook", "updateSystemWebhook", "deleteSystemWebhook", "createAbuseReportNotificationRecipient", "updateAbuseReportNotificationRecipient", "deleteAbuseReportNotificationRecipient", "deleteAccount", "deletePage", "deleteFlash", "deleteGalleryPost"]; +export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "approve", "decline", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "setMandatoryCW", "setRemoteInstanceNSFW", "unsetRemoteInstanceNSFW", "suspendRemoteInstance", "unsuspendRemoteInstance", "rejectRemoteInstanceReports", "acceptRemoteInstanceReports", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "forwardAbuseReport", "updateAbuseReportNote", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner", "createSystemWebhook", "updateSystemWebhook", "deleteSystemWebhook", "createAbuseReportNotificationRecipient", "updateAbuseReportNotificationRecipient", "deleteAbuseReportNotificationRecipient", "deleteAccount", "deletePage", "deleteFlash", "deleteGalleryPost"]; // @public (undocumented) type MuteCreateRequest = operations['mute___create']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index fcb19be303..96da0a8fad 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -147,6 +147,7 @@ export const moderationLogTypes = [ 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'resetPassword', + 'setMandatoryCW', 'setRemoteInstanceNSFW', 'unsetRemoteInstanceNSFW', 'suspendRemoteInstance', @@ -335,6 +336,13 @@ export type ModerationLogPayloads = { userUsername: string; userHost: string | null; }; + setMandatoryCW: { + newCW: string | null; + oldCW: string | null; + userId: string; + userUsername: string; + userHost: string | null; + }; setRemoteInstanceNSFW: { id: string; host: string; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index b92c2537a1..3e88eae275 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -118,8 +118,8 @@ export type ModerationLog = { type: 'deleteUserAnnouncement'; info: ModerationLogPayloads['deleteUserAnnouncement']; } | { - type: 'resetPassword'; - info: ModerationLogPayloads['resetPassword']; + type: 'setMandatoryCW'; + info: ModerationLogPayloads['setMandatoryCW']; } | { type: 'setRemoteInstanceNSFW'; info: ModerationLogPayloads['setRemoteInstanceNSFW']; diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index a12d84e2f5..84dd527694 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -304,6 +304,7 @@ _abuseReport: _moderationLogTypes: approve: "Approved" decline: "Declined" + setMandatoryCW: "Set content warning for user" setRemoteInstanceNSFW: "Set remote instance as NSFW" unsetRemoteInstanceNSFW: "Set remote instance as NSFW" rejectRemoteInstanceReports: "Rejected reports from remote instance" -- cgit v1.2.3-freya From c5933f369e89c2380881e656f18608e22b4c0585 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Wed, 12 Feb 2025 14:42:24 -0500 Subject: move `mandatoryCW` from admin-user to PackedUserLite (public field) --- packages/backend/src/core/entities/UserEntityService.ts | 1 + packages/backend/src/models/json-schema/user.ts | 4 ++++ packages/backend/src/server/api/endpoints/admin/show-user.ts | 5 ----- packages/frontend/src/pages/admin-user.vue | 2 +- packages/misskey-js/src/autogen/types.ts | 1 + 5 files changed, 7 insertions(+), 6 deletions(-) (limited to 'packages/backend/src/server/api/endpoints/admin') diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index ef0b5213c8..4fbbbdd379 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -592,6 +592,7 @@ export class UserEntityService implements OnModuleInit { isCat: user.isCat, noindex: user.noindex, enableRss: user.enableRss, + mandatoryCW: user.mandatoryCW, isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), speakAsCat: user.speakAsCat ?? false, approved: user.approved, diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 93b031e9c5..1c2ba538c1 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -134,6 +134,10 @@ export const packedUserLiteSchema = { type: 'boolean', nullable: false, optional: false, }, + mandatoryCW: { + type: 'string', + nullable: true, optional: false, + }, isBot: { type: 'boolean', nullable: false, optional: true, diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index 0f0b0f8e7a..669bffe2dc 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -144,10 +144,6 @@ export const meta = { type: 'string', optional: false, nullable: false, }, - mandatoryCW: { - type: 'string', - optional: false, nullable: true, - }, signins: { type: 'array', optional: false, nullable: false, @@ -264,7 +260,6 @@ export default class extends Endpoint { // eslint- isHibernated: user.isHibernated, lastActiveDate: user.lastActiveDate ? user.lastActiveDate.toISOString() : null, moderationNote: profile.moderationNote ?? '', - mandatoryCW: user.mandatoryCW, signins, policies: await this.roleService.getUserPolicies(user.id), roles: await this.roleEntityService.packMany(roles, me), diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index 744c4d9682..229f581672 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -288,7 +288,7 @@ function createFetcher() { markedAsNSFW.value = info.value.alwaysMarkNsfw; suspended.value = info.value.isSuspended; moderationNote.value = info.value.moderationNote; - mandatoryCW.value = info.value.mandatoryCW; + mandatoryCW.value = user.value.mandatoryCW; }); } diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 9bac7a812c..7b3f4c0d83 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -3968,6 +3968,7 @@ export type components = { isSystem?: boolean; noindex: boolean; enableRss: boolean; + mandatoryCW: string | null; isBot?: boolean; isCat?: boolean; speakAsCat?: boolean; -- cgit v1.2.3-freya From 292d3b92295d194856cb73c66ac097180f70deb8 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 15 Feb 2025 23:08:02 -0500 Subject: add "reject quotes" toggle at user and instance level + improve, cleanup, and de-duplicate quote resolution + add warning message when quote cannot be loaded + add "process error" framework to display warnings when a note cannot be correctly loaded from another instance --- locales/index.d.ts | 34 +++++ .../1739671352784-add_note_processErrors.js | 11 ++ .../1739671777344-add_user_rejectQuotes.js | 11 ++ .../1739671847942-add_instance_rejectQuotes.js | 11 ++ packages/backend/src/core/NoteCreateService.ts | 30 +++++ packages/backend/src/core/NoteEditService.ts | 5 + .../src/core/activitypub/models/ApNoteService.ts | 143 ++++++++++----------- .../src/core/entities/InstanceEntityService.ts | 1 + .../backend/src/core/entities/NoteEntityService.ts | 1 + .../backend/src/core/entities/UserEntityService.ts | 1 + packages/backend/src/models/Instance.ts | 9 ++ packages/backend/src/models/Note.ts | 11 ++ packages/backend/src/models/User.ts | 9 ++ .../src/models/json-schema/federation-instance.ts | 5 + packages/backend/src/models/json-schema/note.ts | 8 ++ packages/backend/src/models/json-schema/user.ts | 4 + .../endpoints/admin/federation/update-instance.ts | 10 ++ .../server/api/endpoints/admin/reject-quotes.ts | 63 +++++++++ .../src/server/api/endpoints/notes/create.ts | 8 ++ .../backend/src/server/api/endpoints/notes/edit.ts | 8 ++ packages/backend/src/types.ts | 22 ++++ packages/frontend/src/components/MkNote.vue | 2 +- .../frontend/src/components/MkNoteDetailed.vue | 2 +- packages/frontend/src/components/MkNoteSub.vue | 2 +- packages/frontend/src/components/MkPostForm.vue | 2 +- packages/frontend/src/components/SkErrorList.vue | 43 +++++++ packages/frontend/src/components/SkNote.vue | 2 +- .../frontend/src/components/SkNoteDetailed.vue | 2 +- packages/frontend/src/components/SkNoteSub.vue | 2 +- packages/frontend/src/pages/admin-user.vue | 19 +++ .../frontend/src/pages/admin/modlog.ModLog.vue | 12 ++ packages/frontend/src/pages/instance-info.vue | 12 ++ packages/frontend/src/pages/note.vue | 8 +- packages/misskey-js/src/consts.ts | 19 +++ packages/misskey-js/src/entities.ts | 12 ++ sharkey-locales/en-US.yml | 10 ++ 36 files changed, 466 insertions(+), 88 deletions(-) create mode 100644 packages/backend/migration/1739671352784-add_note_processErrors.js create mode 100644 packages/backend/migration/1739671777344-add_user_rejectQuotes.js create mode 100644 packages/backend/migration/1739671847942-add_instance_rejectQuotes.js create mode 100644 packages/backend/src/server/api/endpoints/admin/reject-quotes.ts create mode 100644 packages/frontend/src/components/SkErrorList.vue (limited to 'packages/backend/src/server/api/endpoints/admin') diff --git a/locales/index.d.ts b/locales/index.d.ts index bf49869bf8..cc7884b8c1 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -8566,6 +8566,10 @@ export interface Locale extends ILocale { * Un-silence users */ "write:admin:unsilence-user": string; + /** + * Allow/Reject quote posts from a user + */ + "write:admin:reject-quotes": string; /** * View your list of scheduled notes */ @@ -10242,6 +10246,14 @@ export interface Locale extends ILocale { * Accepted reports from remote instance */ "acceptRemoteInstanceReports": string; + /** + * Rejected quotes from user + */ + "rejectQuotesUser": string; + /** + * Allowed quotes from user + */ + "allowQuotesUser": string; }; "_fileViewer": { /** @@ -11240,6 +11252,22 @@ export interface Locale extends ILocale { * Reject reports from this instance */ "rejectReports": string; + /** + * Reject quote posts from this instance + */ + "rejectQuotesInstance": string; + /** + * Reject quote posts from this user + */ + "rejectQuotesUser": string; + /** + * Are you sure you wish to reject quote posts? + */ + "rejectQuotesConfirm": string; + /** + * Are you sure you wish to allow quote posts? + */ + "allowQuotesConfirm": string; /** * This host is blocked implicitly because a base domain is blocked. To unblock this host, first unblock the base domain(s). */ @@ -12109,6 +12137,12 @@ export interface Locale extends ILocale { * Applies a content warning to all posts created by this user. If the post already has a CW, then this is appended to the end. */ "mandatoryCWDescription": string; + "_processErrors": { + /** + * Unable to process quote. This post may be missing context. + */ + "quoteUnavailable": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/packages/backend/migration/1739671352784-add_note_processErrors.js b/packages/backend/migration/1739671352784-add_note_processErrors.js new file mode 100644 index 0000000000..0be10125e1 --- /dev/null +++ b/packages/backend/migration/1739671352784-add_note_processErrors.js @@ -0,0 +1,11 @@ +export class AddNoteProcessErrors1739671352784 { + name = 'AddNoteProcessErrors1739671352784' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "processErrors" text array`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "processErrors"`); + } +} diff --git a/packages/backend/migration/1739671777344-add_user_rejectQuotes.js b/packages/backend/migration/1739671777344-add_user_rejectQuotes.js new file mode 100644 index 0000000000..29ed90c8ff --- /dev/null +++ b/packages/backend/migration/1739671777344-add_user_rejectQuotes.js @@ -0,0 +1,11 @@ +export class AddUserRejectQuotes1739671777344 { + name = 'AddUserRejectQuotes1739671777344' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "rejectQuotes" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "rejectQuotes"`); + } +} diff --git a/packages/backend/migration/1739671847942-add_instance_rejectQuotes.js b/packages/backend/migration/1739671847942-add_instance_rejectQuotes.js new file mode 100644 index 0000000000..89774eb991 --- /dev/null +++ b/packages/backend/migration/1739671847942-add_instance_rejectQuotes.js @@ -0,0 +1,11 @@ +export class AddInstanceRejectQuotes1739671847942 { + name = 'AddInstanceRejectQuotes1739671847942' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "instance" ADD "rejectQuotes" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "rejectQuotes"`); + } +} diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 8291db9b42..df31cb4247 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -144,6 +144,7 @@ type Option = { uri?: string | null; url?: string | null; app?: MiApp | null; + processErrors?: string[] | null; }; export type PureRenoteOption = Option & { renote: MiNote } & ({ text?: null } | { cw?: null } | { reply?: null } | { poll?: null } | { files?: null | [] }); @@ -309,6 +310,9 @@ export class NoteCreateService implements OnApplicationShutdown { } } + // Check quote permissions + await this.checkQuotePermissions(data, user); + // Check blocking if (this.isRenote(data) && !this.isQuote(data)) { if (data.renote.userHost === null) { @@ -482,6 +486,7 @@ export class NoteCreateService implements OnApplicationShutdown { renoteUserId: data.renote ? data.renote.userId : null, renoteUserHost: data.renote ? data.renote.userHost : null, userHost: user.host, + processErrors: data.processErrors, }); // should really not happen, but better safe than sorry @@ -1147,4 +1152,29 @@ export class NoteCreateService implements OnApplicationShutdown { public async onApplicationShutdown(signal?: string | undefined): Promise { await this.dispose(); } + + @bindThis + public async checkQuotePermissions(data: Option, user: MiUser): Promise { + // Not a quote + if (!this.isRenote(data) || !this.isQuote(data)) return; + + // User cannot quote + if (user.rejectQuotes) { + if (user.host == null) { + throw new IdentifiableError('1c0ea108-d1e3-4e8e-aa3f-4d2487626153', 'QUOTE_DISABLED_FOR_USER'); + } else { + (data as Option).renote = null; + (data.processErrors ??= []).push('quoteUnavailable'); + } + } + + // Instance cannot quote + if (user.host) { + const instance = await this.federatedInstanceService.fetch(user.host); + if (instance?.rejectQuotes) { + (data as Option).renote = null; + (data.processErrors ??= []).push('quoteUnavailable'); + } + } + } } diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 24a99156d2..7851af86b7 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -140,6 +140,7 @@ type Option = { app?: MiApp | null; updatedAt?: Date | null; editcount?: boolean | null; + processErrors?: string[] | null; }; @Injectable() @@ -337,6 +338,9 @@ export class NoteEditService implements OnApplicationShutdown { } } + // Check quote permissions + await this.noteCreateService.checkQuotePermissions(data, user); + // Check blocking if (this.isRenote(data) && !this.isQuote(data)) { if (data.renote.userHost === null) { @@ -529,6 +533,7 @@ export class NoteEditService implements OnApplicationShutdown { if (data.uri != null) note.uri = data.uri; if (data.url != null) note.url = data.url; + if (data.processErrors !== undefined) note.processErrors = data.processErrors; if (mentionedUsers.length > 0) { note.mentions = mentionedUsers.map(u => u.id); diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 2995b1e764..8470285e93 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -296,44 +296,8 @@ export class ApNoteService { : null; // 引用 - let quote: MiNote | undefined | null = null; - - if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) { - const tryResolveNote = async (uri: unknown): Promise< - | { status: 'ok'; res: MiNote } - | { status: 'permerror' | 'temperror' } - > => { - if (typeof uri !== 'string' || !/^https?:/.test(uri)) { - this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: URI is invalid`); - return { status: 'permerror' }; - } - try { - const res = await this.resolveNote(uri, { resolver }); - if (res == null) { - this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: resolution error`); - return { status: 'permerror' }; - } - return { status: 'ok', res }; - } catch (e) { - const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e); - this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: ${error}`); - - return { - status: (e instanceof StatusError && !e.isRetryable) ? 'permerror' : 'temperror', - }; - } - }; - - const uris = unique([note._misskey_quote, note.quoteUrl, note.quoteUri].filter(x => x != null)); - const results = await Promise.all(uris.map(tryResolveNote)); - - quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0); - if (!quote) { - if (results.some(x => x.status === 'temperror')) { - throw new Error(`temporary error resolving quote for ${entryUri}`); - } - } - } + const quote = await this.getQuote(note, entryUri, resolver); + const processErrors = quote === null ? ['quoteUnavailable'] : null; // vote if (reply && reply.hasPoll) { @@ -369,7 +333,8 @@ export class ApNoteService { createdAt: note.published ? new Date(note.published) : null, files, reply, - renote: quote, + renote: quote ?? null, + processErrors, name: note.name, cw, text, @@ -538,44 +503,8 @@ export class ApNoteService { : null; // 引用 - let quote: MiNote | undefined | null = null; - - if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) { - const tryResolveNote = async (uri: unknown): Promise< - | { status: 'ok'; res: MiNote } - | { status: 'permerror' | 'temperror' } - > => { - if (typeof uri !== 'string' || !/^https?:/.test(uri)) { - this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: URI is invalid`); - return { status: 'permerror' }; - } - try { - const res = await this.resolveNote(uri, { resolver }); - if (res == null) { - this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: resolution error`); - return { status: 'permerror' }; - } - return { status: 'ok', res }; - } catch (e) { - const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e); - this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: ${error}`); - - return { - status: (e instanceof StatusError && !e.isRetryable) ? 'permerror' : 'temperror', - }; - } - }; - - const uris = unique([note._misskey_quote, note.quoteUrl, note.quoteUri].filter(x => x != null)); - const results = await Promise.all(uris.map(tryResolveNote)); - - quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0); - if (!quote) { - if (results.some(x => x.status === 'temperror')) { - throw new Error(`temporary error resolving quote for ${entryUri}`); - } - } - } + const quote = await this.getQuote(note, entryUri, resolver); + const processErrors = quote === null ? ['quoteUnavailable'] : null; // vote if (reply && reply.hasPoll) { @@ -611,7 +540,8 @@ export class ApNoteService { createdAt: note.published ? new Date(note.published) : null, files, reply, - renote: quote, + renote: quote ?? null, + processErrors, name: note.name, cw, text, @@ -734,6 +664,63 @@ export class ApNoteService { }); })); } + + /** + * Fetches the note's quoted post. + * On success - returns the note. + * On skip (no quote) - returns undefined. + * On permanent error - returns null. + * On temporary error - throws an exception. + */ + private async getQuote(note: IPost, entryUri: string, resolver: Resolver): Promise { + const quoteUris = new Set(); + if (note._misskey_quote) quoteUris.add(note._misskey_quote); + if (note.quoteUrl) quoteUris.add(note.quoteUrl); + if (note.quoteUri) quoteUris.add(note.quoteUri); + + // No quote, return undefined + if (quoteUris.size < 1) return undefined; + + /** + * Attempts to resolve a quote by URI. + * Returns the note if successful, true if there's a retryable error, and false if there's a permanent error. + */ + const resolveQuote = async (uri: unknown): Promise => { + if (typeof(uri) !== 'string' || !/^https?:/.test(uri)) { + this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": URI is invalid`); + return false; + } + + try { + const quote = await this.resolveNote(uri, { resolver }); + + if (quote == null) { + this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": request error`); + return false; + } + + return quote; + } catch (e) { + const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e); + this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": ${error}`); + + return (e instanceof StatusError && e.isRetryable); + } + }; + + const results = await Promise.all(Array.from(quoteUris).map(u => resolveQuote(u))); + + // Success - return the quote + const quote = results.find(r => typeof(r) === 'object'); + if (quote) return quote; + + // Temporary / retryable error - throw error + const tempError = results.find(r => r === true); + if (tempError) throw new Error(`temporary error resolving quote for "${entryUri}"`); + + // Permanent error - return null + return null; + } } function getBestIcon(note: IObject): IObject | null { diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index 63e5923255..fcc9bed3bd 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -60,6 +60,7 @@ export class InstanceEntityService { latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null, isNSFW: instance.isNSFW, rejectReports: instance.rejectReports, + rejectQuotes: instance.rejectQuotes, moderationNote: iAmModerator ? instance.moderationNote : null, }; } diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index dca73567cc..1c51aba09b 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -490,6 +490,7 @@ export class NoteEntityService implements OnModuleInit { ...(opts.detail ? { clippedCount: note.clippedCount, + processErrors: note.processErrors, reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, { detail: false, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 4fbbbdd379..5d539ea264 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -674,6 +674,7 @@ export class UserEntityService implements OnModuleInit { securityKeys: profile!.twoFactorEnabled ? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1) : false, + rejectQuotes: user.rejectQuotes, } : {}), ...(isDetailed && isMe ? { diff --git a/packages/backend/src/models/Instance.ts b/packages/backend/src/models/Instance.ts index ba93190c57..c64ebb1b3b 100644 --- a/packages/backend/src/models/Instance.ts +++ b/packages/backend/src/models/Instance.ts @@ -164,6 +164,15 @@ export class MiInstance { }) public rejectReports: boolean; + /** + * If true, quote posts from this instance will be downgraded to normal posts. + * The quote will be stripped and a process error will be generated. + */ + @Column('boolean', { + default: false, + }) + public rejectQuotes: boolean; + @Column('varchar', { length: 16384, default: '', }) diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 8b5265e8fe..2dabb75d83 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -203,6 +203,17 @@ export class MiNote { @JoinColumn() public channel: MiChannel | null; + /** + * List of non-fatal errors encountered while processing (creating or updating) this note. + * Entries can be a translation key (which will be queried from the "_processErrors" section) or a raw string. + * Errors will be displayed to the user when viewing the note. + */ + @Column('text', { + array: true, + nullable: true, + }) + public processErrors: string[] | null; + //#region Denormalized fields @Index() @Column('varchar', { diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 8a3ad1003d..5d87c7fa12 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -348,6 +348,15 @@ export class MiUser { }) public mandatoryCW: string | null; + /** + * If true, quote posts from this user will be downgraded to normal posts. + * The quote will be stripped and a process error will be generated. + */ + @Column('boolean', { + default: false, + }) + public rejectQuotes: boolean; + constructor(data: Partial) { if (data == null) return; diff --git a/packages/backend/src/models/json-schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts index 7960e748e9..57d4466ffa 100644 --- a/packages/backend/src/models/json-schema/federation-instance.ts +++ b/packages/backend/src/models/json-schema/federation-instance.ts @@ -126,6 +126,11 @@ export const packedFederationInstanceSchema = { optional: false, nullable: false, }, + rejectQuotes: { + type: 'boolean', + optional: false, + nullable: false, + }, moderationNote: { type: 'string', optional: true, nullable: true, diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index 432c096e48..51d23fe5e7 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -256,6 +256,14 @@ export const packedNoteSchema = { type: 'number', optional: true, nullable: false, }, + processErrors: { + type: 'array', + optional: true, nullable: true, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, myReaction: { type: 'string', diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 1c2ba538c1..3d0bf44c2e 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -445,6 +445,10 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'boolean', nullable: false, optional: true, }, + rejectQuotes: { + type: 'boolean', + nullable: false, optional: true, + }, //#region relations isFollowing: { type: 'boolean', diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts index daf19c4435..24d0b8527c 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts @@ -27,6 +27,7 @@ export const paramDef = { isNSFW: { type: 'boolean' }, rejectReports: { type: 'boolean' }, moderationNote: { type: 'string' }, + rejectQuotes: { type: 'boolean' }, }, required: ['host'], } as const; @@ -59,6 +60,7 @@ export default class extends Endpoint { // eslint- suspensionState, isNSFW: ps.isNSFW, rejectReports: ps.rejectReports, + rejectQuotes: ps.rejectQuotes, moderationNote: ps.moderationNote, }); @@ -92,6 +94,14 @@ export default class extends Endpoint { // eslint- }); } + if (ps.rejectQuotes != null && instance.rejectQuotes !== ps.rejectQuotes) { + const message = ps.rejectReports ? 'rejectQuotesInstance' : 'acceptQuotesInstance'; + this.moderationLogService.log(me, message, { + id: instance.id, + host: instance.host, + }); + } + if (ps.moderationNote != null && instance.moderationNote !== ps.moderationNote) { this.moderationLogService.log(me, 'updateRemoteInstanceNote', { id: instance.id, diff --git a/packages/backend/src/server/api/endpoints/admin/reject-quotes.ts b/packages/backend/src/server/api/endpoints/admin/reject-quotes.ts new file mode 100644 index 0000000000..78f94ceeff --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/reject-quotes.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UsersRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:reject-quotes', +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + rejectQuotes: { type: 'boolean', nullable: false }, + }, + required: ['userId', 'rejectQuotes'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.usersRepository) + private readonly usersRepository: UsersRepository, + + private readonly globalEventService: GlobalEventService, + private readonly cacheService: CacheService, + private readonly moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.cacheService.findUserById(ps.userId); + + // Skip if there's nothing to do + if (user.rejectQuotes === ps.rejectQuotes) return; + + // Log event first. + // This ensures that we don't "lose" the log if an error occurs + await this.moderationLogService.log(me, ps.rejectQuotes ? 'rejectQuotesUser' : 'acceptQuotesUser', { + userId: user.id, + userUsername: user.username, + userHost: user.host, + }); + + await this.usersRepository.update(ps.userId, { + rejectQuotes: ps.rejectQuotes, + }); + + // Synchronize caches and other processes + this.globalEventService.publishInternalEvent('localUserUpdated', { id: ps.userId }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index d1cf0123dc..b0f32bfda8 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -143,6 +143,12 @@ export const meta = { code: 'CONTAINS_TOO_MANY_MENTIONS', id: '4de0363a-3046-481b-9b0f-feff3e211025', }, + + quoteDisabledForUser: { + message: 'You do not have permission to create quote posts.', + code: 'QUOTE_DISABLED_FOR_USER', + id: '1c0ea108-d1e3-4e8e-aa3f-4d2487626153', + }, }, } as const; @@ -415,6 +421,8 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.containsProhibitedWords); } else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') { throw new ApiError(meta.errors.containsTooManyMentions); + } else if (e.id === '1c0ea108-d1e3-4e8e-aa3f-4d2487626153') { + throw new ApiError(meta.errors.quoteDisabledForUser); } } throw e; diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts index dc94c78e75..cc2293c5d6 100644 --- a/packages/backend/src/server/api/endpoints/notes/edit.ts +++ b/packages/backend/src/server/api/endpoints/notes/edit.ts @@ -176,6 +176,12 @@ export const meta = { id: '33510210-8452-094c-6227-4a6c05d99f02', }, + quoteDisabledForUser: { + message: 'You do not have permission to create quote posts.', + code: 'QUOTE_DISABLED_FOR_USER', + id: '1c0ea108-d1e3-4e8e-aa3f-4d2487626153', + }, + containsProhibitedWords: { message: 'Cannot post because it contains prohibited words.', code: 'CONTAINS_PROHIBITED_WORDS', @@ -469,6 +475,8 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.containsProhibitedWords); } else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') { throw new ApiError(meta.errors.containsTooManyMentions); + } else if (e.id === '1c0ea108-d1e3-4e8e-aa3f-4d2487626153') { + throw new ApiError(meta.errors.quoteDisabledForUser); } } throw e; diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index b359fa5a39..b5d982e3a5 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -132,6 +132,10 @@ export const moderationLogTypes = [ 'deletePage', 'deleteFlash', 'deleteGalleryPost', + 'acceptQuotesUser', + 'rejectQuotesUser', + 'acceptQuotesInstance', + 'rejectQuotesInstance', ] as const; export type ModerationLogPayloads = { @@ -417,6 +421,24 @@ export type ModerationLogPayloads = { postUserUsername: string; post: any; }; + acceptQuotesUser: { + userId: string, + userUsername: string, + userHost: string | null, + }; + rejectQuotesUser: { + userId: string, + userUsername: string, + userHost: string | null, + }; + acceptQuotesInstance: { + id: string; + host: string; + }; + rejectQuotesInstance: { + id: string; + host: string; + }; }; export type Serialized = { diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 0bac6a67b9..4b174d7336 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -142,7 +142,7 @@ SPDX-License-Identifier: AGPL-3.0-only