From f123be38b93339f405468c8ed1aaa39d340b7791 Mon Sep 17 00:00:00 2001 From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Thu, 19 Dec 2024 16:05:33 +0900 Subject: enhance(frontend): 照会の際にエラーを表示するように (#15147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance: 照会の失敗理由を表示するように * Update Changelog * fix * fix test * lookupErrors-> remoteLookupErrors --- .../backend/src/server/api/endpoints/ap/show.ts | 65 +++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 24d5a7b0f1..5c2e82da88 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -19,6 +19,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { ApiError } from '../../error.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; export const meta = { tags: ['federation'], @@ -32,6 +33,31 @@ export const meta = { }, errors: { + federationNotAllowed: { + message: 'Federation for this host is not allowed.', + code: 'FEDERATION_NOT_ALLOWED', + id: '974b799e-1a29-4889-b706-18d4dd93e266', + }, + uriInvalid: { + message: 'URI is invalid.', + code: 'URI_INVALID', + id: '1a5eab56-e47b-48c2-8d5e-217b897d70db', + }, + requestFailed: { + message: 'Request failed.', + code: 'REQUEST_FAILED', + id: '81b539cf-4f57-4b29-bc98-032c33c0792e', + }, + responseInvalid: { + message: 'Response from remote server is invalid.', + code: 'RESPONSE_INVALID', + id: '70193c39-54f3-4813-82f0-70a680f7495b', + }, + responseInvalidIdHostNotMatch: { + message: 'Requested URI and response URI host does not match.', + code: 'RESPONSE_INVALID_ID_HOST_NOT_MATCH', + id: 'a2c9c61a-cb72-43ab-a964-3ca5fddb410a', + }, noSuchObject: { message: 'No such object.', code: 'NO_SUCH_OBJECT', @@ -110,7 +136,9 @@ export default class extends Endpoint { // eslint- */ @bindThis private async fetchAny(uri: string, me: MiLocalUser | null | undefined): Promise | null> { - if (!this.utilityService.isFederationAllowedUri(uri)) return null; + if (!this.utilityService.isFederationAllowedUri(uri)) { + throw new ApiError(meta.errors.federationNotAllowed); + } let local = await this.mergePack(me, ...await Promise.all([ this.apDbResolverService.getUserFromApId(uri), @@ -125,7 +153,40 @@ export default class extends Endpoint { // eslint- // リモートから一旦オブジェクトフェッチ const resolver = this.apResolverService.createResolver(); - const object = await resolver.resolve(uri) as any; + const object = await resolver.resolve(uri).catch((err) => { + if (err instanceof IdentifiableError) { + switch (err.id) { + // resolve + case 'b94fd5b1-0e3b-4678-9df2-dad4cd515ab2': + throw new ApiError(meta.errors.uriInvalid); + case '0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5': + case 'd592da9f-822f-4d91-83d7-4ceefabcf3d2': + throw new ApiError(meta.errors.requestFailed); + case '09d79f9e-64f1-4316-9cfa-e75c4d091574': + throw new ApiError(meta.errors.federationNotAllowed); + case '72180409-793c-4973-868e-5a118eb5519b': + case 'ad2dc287-75c1-44c4-839d-3d2e64576675': + throw new ApiError(meta.errors.responseInvalid); + case 'fd93c2fa-69a8-440f-880b-bf178e0ec877': + throw new ApiError(meta.errors.responseInvalidIdHostNotMatch); + + // resolveLocal + case '02b40cd0-fa92-4b0c-acc9-fb2ada952ab8': + throw new ApiError(meta.errors.uriInvalid); + case 'a9d946e5-d276-47f8-95fb-f04230289bb0': + case '06ae3170-1796-4d93-a697-2611ea6d83b6': + throw new ApiError(meta.errors.noSuchObject); + case '7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0': + throw new ApiError(meta.errors.responseInvalid); + } + } + + throw new ApiError(meta.errors.requestFailed); + }); + + if (object.id == null) { + throw new ApiError(meta.errors.responseInvalid); + } // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する // これはDBに存在する可能性があるため再度DB検索 -- cgit v1.2.3-freya From 020882edcf2d79bf403c97b860008bf306fe5f90 Mon Sep 17 00:00:00 2001 From: 4ster1sk <146138447+4ster1sk@users.noreply.github.com> Date: Sat, 4 Jan 2025 15:06:19 +0900 Subject: fix(backend): アプリ作成方式で作成したトークンの権限を表示するように (#15177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/server/api/endpoints/i/apps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/i/apps.ts b/packages/backend/src/server/api/endpoints/i/apps.ts index 91c8597b1b..055b5cc061 100644 --- a/packages/backend/src/server/api/endpoints/i/apps.ts +++ b/packages/backend/src/server/api/endpoints/i/apps.ts @@ -87,7 +87,7 @@ export default class extends Endpoint { // eslint- name: token.name ?? token.app?.name, createdAt: this.idService.parse(token.id).date.toISOString(), lastUsedAt: token.lastUsedAt?.toISOString(), - permission: token.permission, + permission: token.app ? token.app.permission : token.permission, }))); }); } -- cgit v1.2.3-freya 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') 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') 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 da9e05582d1ce4c79a91b05abf73c18f2f2d5760 Mon Sep 17 00:00:00 2001 From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Tue, 14 Jan 2025 20:30:49 +0900 Subject: fix(backend): `pages/update`のnameの重複チェックはnameプロパティがある時のみ行うように (#15104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(backend): pagesのnameの重複チェックはnameプロパティがある時のみ行うように * Update Changelog --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- CHANGELOG.md | 1 + .../backend/src/server/api/endpoints/pages/update.ts | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/CHANGELOG.md b/CHANGELOG.md index 287d390453..362636e205 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ - Fix: URLとURIが異なるエンティティの照会に失敗する問題を修正( #15039 ) - Fix: ActivityPubリクエストかどうかの判定が正しくない問題を修正 (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/869) +- Fix: `/api/pages/update`にて`name`を指定せずにリクエストするとエラーが発生する問題を修正 - Fix: AIセンシティブ判定が arm64 環境で動作しない問題を修正 ## 2024.11.0 diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts index f11bbbcb1a..e52d9c32df 100644 --- a/packages/backend/src/server/api/endpoints/pages/update.ts +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -102,15 +102,17 @@ export default class extends Endpoint { // eslint- } } - await this.pagesRepository.findBy({ - id: Not(ps.pageId), - userId: me.id, - name: ps.name, - }).then(result => { - if (result.length > 0) { - throw new ApiError(meta.errors.nameAlreadyExists); - } - }); + if (ps.name != null) { + await this.pagesRepository.findBy({ + id: Not(ps.pageId), + userId: me.id, + name: ps.name, + }).then(result => { + if (result.length > 0) { + throw new ApiError(meta.errors.nameAlreadyExists); + } + }); + } await this.pagesRepository.update(page.id, { updatedAt: new Date(), -- cgit v1.2.3-freya From 145c6cf2b5285518e65db8a7098d1b570023fabb Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Tue, 14 Jan 2025 21:37:39 +0900 Subject: fix: node.js の punycode モジュールが使用されている場所がある問題 (#15248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: punycode.js が使用されていない場所がある問題 * fix: use punycode/punycode.js on backend * fix: use punycode/punycode.es6.js on backend * fix: d.ts missing declare keyword * chore: don't use punycode.js on backend * update pnpm-lock.yaml * chore: remove punycode.d.ts * chore: use punycode.js instead of punycode npm package * chore: bump psl to 1.15.0 * chore: bump nsfwjs to 4.2.0 4.2.1 is not usable because of https://github.com/infinitered/nsfwjs/issues/904 * chore: prevent loading node-fetch from tensorflow * chore: DOMWindow['document'] => Document IDK why DOMWindow['document'] fails, but might be related to tsc internal complexity limit * fix: disable --trace-deprecation --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- packages/backend/package.json | 6 +- packages/backend/src/core/AiService.ts | 4 +- .../src/core/FetchInstanceMetadataService.ts | 12 +- packages/backend/src/core/FileInfoService.ts | 4 +- packages/backend/src/core/UtilityService.ts | 7 +- .../backend/src/server/api/endpoints/i/update.ts | 2 +- packages/frontend-embed/package.json | 4 +- packages/frontend-embed/src/components/EmAcct.vue | 2 +- .../frontend-embed/src/components/EmMention.vue | 2 +- packages/frontend-embed/src/components/EmUrl.vue | 2 +- packages/frontend/package.json | 4 +- packages/frontend/src/components/MkMention.vue | 2 +- packages/frontend/src/components/MkPostForm.vue | 2 +- .../frontend/src/components/MkSignin.input.vue | 2 +- .../src/components/MkSignupDialog.form.vue | 2 +- packages/frontend/src/components/global/MkAcct.vue | 2 +- packages/frontend/src/components/global/MkUrl.vue | 2 +- packages/frontend/src/pages/theme-editor.vue | 2 +- packages/frontend/src/scripts/autocomplete.ts | 2 +- packages/frontend/src/scripts/get-user-menu.ts | 2 +- packages/shared/eslint.config.js | 7 + pnpm-lock.yaml | 350 ++++----------------- 22 files changed, 104 insertions(+), 320 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/package.json b/packages/backend/package.json index f56a737eea..757912755a 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -134,8 +134,8 @@ "json5": "2.2.3", "jsonld": "8.3.2", "jsrsasign": "11.1.0", - "meilisearch": "0.45.0", "juice": "11.0.0", + "meilisearch": "0.45.0", "mfm-js": "0.24.0", "microformats-parser": "2.0.2", "mime-types": "2.1.35", @@ -146,7 +146,7 @@ "nested-property": "4.0.0", "node-fetch": "3.3.2", "nodemailer": "6.9.16", - "nsfwjs": "2.4.2", + "nsfwjs": "4.2.0", "oauth": "0.10.0", "oauth2orize": "1.12.0", "oauth2orize-pkce": "0.1.2", @@ -158,7 +158,6 @@ "probe-image-size": "7.2.3", "promise-limit": "2.7.0", "pug": "3.0.3", - "punycode": "2.3.1", "qrcode": "1.5.4", "random-seed": "0.3.0", "ratelimiter": "3.4.1", @@ -215,7 +214,6 @@ "@types/oauth2orize-pkce": "0.1.2", "@types/pg": "8.11.10", "@types/pug": "2.0.10", - "@types/punycode": "2.1.4", "@types/qrcode": "1.5.5", "@types/random-seed": "0.3.5", "@types/ratelimiter": "3.4.6", diff --git a/packages/backend/src/core/AiService.ts b/packages/backend/src/core/AiService.ts index 33ddabb5e0..248a9b8979 100644 --- a/packages/backend/src/core/AiService.ts +++ b/packages/backend/src/core/AiService.ts @@ -10,6 +10,7 @@ import { Injectable } from '@nestjs/common'; import * as nsfw from 'nsfwjs'; import si from 'systeminformation'; import { Mutex } from 'async-mutex'; +import fetch from 'node-fetch'; import { bindThis } from '@/decorators.js'; const _filename = fileURLToPath(import.meta.url); @@ -28,7 +29,7 @@ export class AiService { } @bindThis - public async detectSensitive(path: string): Promise { + public async detectSensitive(path: string): Promise { try { if (isSupportedCpu === undefined) { isSupportedCpu = await this.computeIsSupportedCpu(); @@ -40,6 +41,7 @@ export class AiService { } const tf = await import('@tensorflow/tfjs-node'); + tf.env().global.fetch = fetch; if (this.model == null) { await this.modelLoadMutex.runExclusive(async () => { diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index 987999bce7..ce3af7c774 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -181,7 +181,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchDom(instance: MiInstance): Promise { + private async fetchDom(instance: MiInstance): Promise { this.logger.info(`Fetching HTML of ${instance.host} ...`); const url = 'https://' + instance.host; @@ -206,7 +206,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchFaviconUrl(instance: MiInstance, doc: DOMWindow['document'] | null): Promise { + private async fetchFaviconUrl(instance: MiInstance, doc: Document | null): Promise { const url = 'https://' + instance.host; if (doc) { @@ -232,7 +232,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchIconUrl(instance: MiInstance, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { + private async fetchIconUrl(instance: MiInstance, doc: Document | null, manifest: Record | null): Promise { if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) { const url = 'https://' + instance.host; return (new URL(manifest.icons[0].src, url)).href; @@ -261,7 +261,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getThemeColor(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { + private async getThemeColor(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color; if (themeColor) { @@ -273,7 +273,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getSiteName(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { + private async getSiteName(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { if (info && info.metadata) { if (typeof info.metadata.nodeName === 'string') { return info.metadata.nodeName; @@ -298,7 +298,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getDescription(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { + private async getDescription(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { if (info && info.metadata) { if (typeof info.metadata.nodeDescription === 'string') { return info.metadata.nodeDescription; diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index 6bd6cb8d9b..fc68eb4836 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -13,7 +13,6 @@ import * as fileType from 'file-type'; import FFmpeg from 'fluent-ffmpeg'; import isSvg from 'is-svg'; import probeImageSize from 'probe-image-size'; -import { type predictionType } from 'nsfwjs'; import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; import * as blurhash from 'blurhash'; import { createTempDir } from '@/misc/create-temp.js'; @@ -21,6 +20,7 @@ import { AiService } from '@/core/AiService.js'; import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import type { PredictionType } from 'nsfwjs'; export type FileInfo = { size: number; @@ -170,7 +170,7 @@ export class FileInfoService { let sensitive = false; let porn = false; - function judgePrediction(result: readonly predictionType[]): [sensitive: boolean, porn: boolean] { + function judgePrediction(result: readonly PredictionType[]): [sensitive: boolean, porn: boolean] { let sensitive = false; let porn = false; diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index 9a2ba72ed3..fcb750d3bf 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -3,8 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { URL } from 'node:url'; -import { toASCII } from 'punycode'; +import { URL, domainToASCII } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import RE2 from 're2'; import { DI } from '@/di-symbols.js'; @@ -106,13 +105,13 @@ export class UtilityService { @bindThis public toPuny(host: string): string { - return toASCII(host.toLowerCase()); + return domainToASCII(host.toLowerCase()); } @bindThis public toPunyNullable(host: string | null | undefined): string | null { if (host == null) return null; - return toASCII(host.toLowerCase()); + return domainToASCII(host.toLowerCase()); } @bindThis diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index d3eeb75b27..4c72879b73 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -553,7 +553,7 @@ export default class extends Endpoint { // eslint- const html = await this.httpRequestService.getHtml(url); const { window } = new JSDOM(html); - const doc = window.document; + const doc: Document = window.document; const myLink = `${this.config.url}/@${user.username}`; diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index 59b744e43a..3d04c566b6 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -25,7 +25,7 @@ "mfm-js": "0.24.0", "misskey-js": "workspace:*", "frontend-shared": "workspace:*", - "punycode": "2.3.1", + "punycode.js": "2.3.1", "rollup": "4.26.0", "sass": "1.79.4", "shiki": "1.22.2", @@ -44,7 +44,7 @@ "@types/estree": "1.0.6", "@types/micromatch": "4.0.9", "@types/node": "22.9.0", - "@types/punycode": "2.1.4", + "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/tinycolor2": "1.4.6", "@types/uuid": "10.0.0", "@types/ws": "8.5.13", diff --git a/packages/frontend-embed/src/components/EmAcct.vue b/packages/frontend-embed/src/components/EmAcct.vue index 6856b8272e..ff794d9b6e 100644 --- a/packages/frontend-embed/src/components/EmAcct.vue +++ b/packages/frontend-embed/src/components/EmAcct.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts b/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts new file mode 100644 index 0000000000..411d62edf9 --- /dev/null +++ b/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { http, HttpResponse } from 'msw'; +import { role } from '../../.storybook/fakes.js'; +import { commonHandlers } from '../../.storybook/mocks.js'; +import MkRoleSelectDialog from '@/components/MkRoleSelectDialog.vue'; + +const roles = [ + role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'), + role({ displayOrder: 2 }, '2'), role({ displayOrder: 2 }, '2'), role({ displayOrder: 3 }, '3'), role({ displayOrder: 3 }, '3'), + role({ displayOrder: 4 }, '4'), role({ displayOrder: 5 }, '5'), role({ displayOrder: 6 }, '6'), role({ displayOrder: 7 }, '7'), + role({ displayOrder: 999, name: 'privateRole', isPublic: false }, '999'), +]; + +export const Default = { + render(args) { + return { + components: { + MkRoleSelectDialog, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + initialRoleIds: undefined, + infoMessage: undefined, + title: undefined, + publicOnly: true, + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/admin/roles/list', ({ params }) => { + return HttpResponse.json(roles); + }), + ], + }, + }, + decorators: [() => ({ + template: '
', + })], +} satisfies StoryObj; + +export const InitialIds = { + ...Default, + args: { + ...Default.args, + initialRoleIds: [roles[0].id, roles[1].id, roles[4].id, roles[6].id, roles[8].id, roles[10].id], + }, +} satisfies StoryObj; + +export const InfoMessage = { + ...Default, + args: { + ...Default.args, + infoMessage: 'This is a message.', + }, +} satisfies StoryObj; + +export const Title = { + ...Default, + args: { + ...Default.args, + title: 'Select roles', + }, +} satisfies StoryObj; + +export const Full = { + ...Default, + args: { + ...Default.args, + initialRoleIds: roles.map(it => it.id), + infoMessage: InfoMessage.args.infoMessage, + title: Title.args.title, + }, +} satisfies StoryObj; + +export const FullWithPrivate = { + ...Default, + args: { + ...Default.args, + initialRoleIds: roles.map(it => it.id), + infoMessage: InfoMessage.args.infoMessage, + title: Title.args.title, + publicOnly: false, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue new file mode 100644 index 0000000000..67a7a3f752 --- /dev/null +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -0,0 +1,200 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkSortOrderEditor.define.ts b/packages/frontend/src/components/MkSortOrderEditor.define.ts new file mode 100644 index 0000000000..f023b5d72b --- /dev/null +++ b/packages/frontend/src/components/MkSortOrderEditor.define.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type SortOrderDirection = '+' | '-' + +export type SortOrder = { + key: T; + direction: SortOrderDirection; +} diff --git a/packages/frontend/src/components/MkSortOrderEditor.vue b/packages/frontend/src/components/MkSortOrderEditor.vue new file mode 100644 index 0000000000..da08f12297 --- /dev/null +++ b/packages/frontend/src/components/MkSortOrderEditor.vue @@ -0,0 +1,112 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkTagItem.stories.impl.ts b/packages/frontend/src/components/MkTagItem.stories.impl.ts new file mode 100644 index 0000000000..3f243ff651 --- /dev/null +++ b/packages/frontend/src/components/MkTagItem.stories.impl.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import MkTagItem from './MkTagItem.vue'; + +export const Default = { + render(args) { + return { + components: { + MkTagItem: MkTagItem, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + click: action('click'), + exButtonClick: action('exButtonClick'), + }; + }, + }, + template: '', + }; + }, + args: { + content: 'name', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; + +export const Icon = { + ...Default, + args: { + ...Default.args, + iconClass: 'ti ti-arrow-up', + }, +} satisfies StoryObj; + +export const ExButton = { + ...Default, + args: { + ...Default.args, + exButtonIconClass: 'ti ti-x', + }, +} satisfies StoryObj; + +export const IconExButton = { + ...Default, + args: { + ...Default.args, + iconClass: 'ti ti-arrow-up', + exButtonIconClass: 'ti ti-x', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkTagItem.vue b/packages/frontend/src/components/MkTagItem.vue new file mode 100644 index 0000000000..98f2411392 --- /dev/null +++ b/packages/frontend/src/components/MkTagItem.vue @@ -0,0 +1,76 @@ + + + + + + + diff --git a/packages/frontend/src/components/grid/MkCellTooltip.vue b/packages/frontend/src/components/grid/MkCellTooltip.vue new file mode 100644 index 0000000000..fd289c6cd9 --- /dev/null +++ b/packages/frontend/src/components/grid/MkCellTooltip.vue @@ -0,0 +1,35 @@ + + + + + + + diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue new file mode 100644 index 0000000000..0ffd42abda --- /dev/null +++ b/packages/frontend/src/components/grid/MkDataCell.vue @@ -0,0 +1,391 @@ + + + + + + + diff --git a/packages/frontend/src/components/grid/MkDataRow.vue b/packages/frontend/src/components/grid/MkDataRow.vue new file mode 100644 index 0000000000..280a14bc4a --- /dev/null +++ b/packages/frontend/src/components/grid/MkDataRow.vue @@ -0,0 +1,72 @@ + + + + + + + diff --git a/packages/frontend/src/components/grid/MkGrid.stories.impl.ts b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts new file mode 100644 index 0000000000..5801012f15 --- /dev/null +++ b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts @@ -0,0 +1,223 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import { ref } from 'vue'; +import { commonHandlers } from '../../../.storybook/mocks.js'; +import { boolean, choose, country, date, firstName, integer, lastName, text } from '../../../.storybook/fake-utils.js'; +import MkGrid from './MkGrid.vue'; +import { GridContext, GridEvent } from '@/components/grid/grid-event.js'; +import { DataSource, GridSetting } from '@/components/grid/grid.js'; +import { GridColumnSetting } from '@/components/grid/column.js'; + +function d(p: { + check?: boolean, + name?: string, + email?: string, + age?: number, + birthday?: string, + gender?: string, + country?: string, + reportCount?: number, + createdAt?: string, +}, seed: string) { + const prefix = text(10, seed); + + return { + check: p.check ?? boolean(seed), + name: p.name ?? `${firstName(seed)} ${lastName(seed)}`, + email: p.email ?? `${prefix}@example.com`, + age: p.age ?? integer(20, 80, seed), + birthday: date({}, seed).toISOString(), + gender: p.gender ?? choose(['male', 'female', 'other', 'unknown'], seed), + country: p.country ?? country(seed), + reportCount: p.reportCount ?? integer(0, 9999, seed), + createdAt: p.createdAt ?? date({}, seed).toISOString(), + }; +} + +const defaultCols: GridColumnSetting[] = [ + { bindTo: 'check', icon: 'ti-check', type: 'boolean', width: 50 }, + { bindTo: 'name', title: 'Name', type: 'text', width: 'auto' }, + { bindTo: 'email', title: 'Email', type: 'text', width: 'auto' }, + { bindTo: 'age', title: 'Age', type: 'number', width: 50 }, + { bindTo: 'birthday', title: 'Birthday', type: 'date', width: 'auto' }, + { bindTo: 'gender', title: 'Gender', type: 'text', width: 80 }, + { bindTo: 'country', title: 'Country', type: 'text', width: 120 }, + { bindTo: 'reportCount', title: 'ReportCount', type: 'number', width: 'auto' }, + { bindTo: 'createdAt', title: 'CreatedAt', type: 'date', width: 'auto' }, +]; + +function createArgs(overrides?: { settings?: Partial, data?: DataSource[] }) { + const refData = ref[]>([]); + for (let i = 0; i < 100; i++) { + refData.value.push(d({}, i.toString())); + } + + return { + settings: { + row: overrides?.settings?.row, + cols: [ + ...defaultCols.filter(col => overrides?.settings?.cols?.every(c => c.bindTo !== col.bindTo) ?? true), + ...overrides?.settings?.cols ?? [], + ], + cells: overrides?.settings?.cells, + }, + data: refData.value, + }; +} + +function createRender(params: { settings: GridSetting, data: DataSource[] }) { + return { + render(args) { + return { + components: { + MkGrid, + }, + setup() { + return { + args, + }; + }, + data() { + return { + data: args.data, + }; + }, + computed: { + props() { + return { + ...args, + }; + }, + events() { + return { + event: (event: GridEvent, context: GridContext) => { + switch (event.type) { + case 'cell-value-change': { + args.data[event.row.index][event.column.setting.bindTo] = event.newValue; + } + } + }, + }; + }, + }, + template: '
', + }; + }, + args: { + ...params, + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + ], + }, + }, + } satisfies StoryObj; +} + +export const Default = createRender(createArgs()); + +export const NoNumber = createRender(createArgs({ + settings: { + row: { + showNumber: false, + }, + }, +})); + +export const NoSelectable = createRender(createArgs({ + settings: { + row: { + selectable: false, + }, + }, +})); + +export const Editable = createRender(createArgs({ + settings: { + cols: defaultCols.map(col => ({ ...col, editable: true })), + }, +})); + +export const AdditionalRowStyle = createRender(createArgs({ + settings: { + cols: defaultCols.map(col => ({ ...col, editable: true })), + row: { + styleRules: [ + { + condition: ({ row }) => AdditionalRowStyle.args.data[row.index].check as boolean, + applyStyle: { + style: { + backgroundColor: 'lightgray', + }, + }, + }, + ], + }, + }, +})); + +export const ContextMenu = createRender(createArgs({ + settings: { + cols: [ + { + bindTo: 'check', icon: 'ti-check', type: 'boolean', width: 50, contextMenuFactory: (col, context) => [ + { + type: 'button', + text: 'Check All', + action: () => { + for (const d of ContextMenu.args.data) { + d.check = true; + } + }, + }, + { + type: 'button', + text: 'Uncheck All', + action: () => { + for (const d of ContextMenu.args.data) { + d.check = false; + } + }, + }, + ], + }, + ], + row: { + contextMenuFactory: (row, context) => [ + { + type: 'button', + text: 'Delete', + action: () => { + const idxes = context.rangedRows.map(r => r.index); + const newData = ContextMenu.args.data.filter((d, i) => !idxes.includes(i)); + + ContextMenu.args.data.splice(0); + ContextMenu.args.data.push(...newData); + }, + }, + ], + }, + cells: { + contextMenuFactory: (col, row, value, context) => [ + { + type: 'button', + text: 'Delete', + action: () => { + for (const cell of context.rangedCells) { + ContextMenu.args.data[cell.row.index][cell.column.setting.bindTo] = undefined; + } + }, + }, + ], + }, + }, +})); diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue new file mode 100644 index 0000000000..60738365fb --- /dev/null +++ b/packages/frontend/src/components/grid/MkGrid.vue @@ -0,0 +1,1342 @@ + + + + + + + + + diff --git a/packages/frontend/src/components/grid/MkHeaderCell.vue b/packages/frontend/src/components/grid/MkHeaderCell.vue new file mode 100644 index 0000000000..605d27c6d6 --- /dev/null +++ b/packages/frontend/src/components/grid/MkHeaderCell.vue @@ -0,0 +1,216 @@ + + + + + + + diff --git a/packages/frontend/src/components/grid/MkHeaderRow.vue b/packages/frontend/src/components/grid/MkHeaderRow.vue new file mode 100644 index 0000000000..8affa08fd5 --- /dev/null +++ b/packages/frontend/src/components/grid/MkHeaderRow.vue @@ -0,0 +1,60 @@ + + + + + + + diff --git a/packages/frontend/src/components/grid/MkNumberCell.vue b/packages/frontend/src/components/grid/MkNumberCell.vue new file mode 100644 index 0000000000..674bba96bc --- /dev/null +++ b/packages/frontend/src/components/grid/MkNumberCell.vue @@ -0,0 +1,61 @@ + + + + + + + diff --git a/packages/frontend/src/components/grid/cell-validators.ts b/packages/frontend/src/components/grid/cell-validators.ts new file mode 100644 index 0000000000..949cab2ec6 --- /dev/null +++ b/packages/frontend/src/components/grid/cell-validators.ts @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridColumn } from '@/components/grid/column.js'; +import { GridRow } from '@/components/grid/row.js'; +import { i18n } from '@/i18n.js'; + +export type ValidatorParams = { + column: GridColumn; + row: GridRow; + value: CellValue; + allCells: GridCell[]; +}; + +export type ValidatorResult = { + valid: boolean; + message?: string; +} + +export type GridCellValidator = { + name?: string; + ignoreViolation?: boolean; + validate: (params: ValidatorParams) => ValidatorResult; +} + +export type ValidateViolation = { + valid: boolean; + params: ValidatorParams; + violations: ValidateViolationItem[]; +} + +export type ValidateViolationItem = { + valid: boolean; + validator: GridCellValidator; + result: ValidatorResult; +} + +export function cellValidation(allCells: GridCell[], cell: GridCell, newValue: CellValue): ValidateViolation { + const { column, row } = cell; + const validators = column.setting.validators ?? []; + + const params: ValidatorParams = { + column, + row, + value: newValue, + allCells, + }; + + const violations: ValidateViolationItem[] = validators.map(validator => { + const result = validator.validate(params); + return { + valid: result.valid, + validator, + result, + }; + }); + + return { + valid: violations.every(v => v.result.valid), + params, + violations, + }; +} + +class ValidatorPreset { + required(): GridCellValidator { + return { + name: 'required', + validate: ({ value }): ValidatorResult => { + return { + valid: value !== null && value !== undefined && value !== '', + message: i18n.ts._gridComponent._error.requiredValue, + }; + }, + }; + } + + regex(pattern: RegExp): GridCellValidator { + return { + name: 'regex', + validate: ({ value }): ValidatorResult => { + return { + valid: (typeof value !== 'string') || pattern.test(value.toString() ?? ''), + message: i18n.tsx._gridComponent._error.patternNotMatch({ pattern: pattern.source }), + }; + }, + }; + } + + unique(): GridCellValidator { + return { + name: 'unique', + validate: ({ column, row, value, allCells }): ValidatorResult => { + const bindTo = column.setting.bindTo; + const isUnique = allCells + .filter(it => it.column.setting.bindTo === bindTo && it.row.index !== row.index) + .every(cell => cell.value !== value); + return { + valid: isUnique, + message: i18n.ts._gridComponent._error.notUnique, + }; + }, + }; + } +} + +export const validators = new ValidatorPreset(); diff --git a/packages/frontend/src/components/grid/cell.ts b/packages/frontend/src/components/grid/cell.ts new file mode 100644 index 0000000000..71b7a3e3f1 --- /dev/null +++ b/packages/frontend/src/components/grid/cell.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ValidateViolation } from '@/components/grid/cell-validators.js'; +import { Size } from '@/components/grid/grid.js'; +import { GridColumn } from '@/components/grid/column.js'; +import { GridRow } from '@/components/grid/row.js'; +import { MenuItem } from '@/types/menu.js'; +import { GridContext } from '@/components/grid/grid-event.js'; + +export type CellValue = string | boolean | number | undefined | null | Array | NonNullable; + +export type CellAddress = { + row: number; + col: number; +} + +export const CELL_ADDRESS_NONE: CellAddress = { + row: -1, + col: -1, +}; + +export type GridCell = { + address: CellAddress; + value: CellValue; + column: GridColumn; + row: GridRow; + selected: boolean; + ranged: boolean; + contentSize: Size; + setting: GridCellSetting; + violation: ValidateViolation; +} + +export type GridCellContextMenuFactory = (col: GridColumn, row: GridRow, value: CellValue, context: GridContext) => MenuItem[]; + +export type GridCellSetting = { + contextMenuFactory?: GridCellContextMenuFactory; +} + +export function createCell( + column: GridColumn, + row: GridRow, + value: CellValue, + setting: GridCellSetting, +): GridCell { + const newValue = (row.using && column.setting.valueTransformer) + ? column.setting.valueTransformer(row, column, value) + : value; + + return { + address: { row: row.index, col: column.index }, + value: newValue, + column, + row, + selected: false, + ranged: false, + contentSize: { width: 0, height: 0 }, + violation: { + valid: true, + params: { + column, + row, + value, + allCells: [], + }, + violations: [], + }, + setting, + }; +} + +export function resetCell(cell: GridCell): void { + cell.selected = false; + cell.ranged = false; + cell.violation = { + valid: true, + params: { + column: cell.column, + row: cell.row, + value: cell.value, + allCells: [], + }, + violations: [], + }; +} diff --git a/packages/frontend/src/components/grid/column.ts b/packages/frontend/src/components/grid/column.ts new file mode 100644 index 0000000000..2f505756fe --- /dev/null +++ b/packages/frontend/src/components/grid/column.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { GridCellValidator } from '@/components/grid/cell-validators.js'; +import { Size, SizeStyle } from '@/components/grid/grid.js'; +import { calcCellWidth } from '@/components/grid/grid-utils.js'; +import { CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridRow } from '@/components/grid/row.js'; +import { MenuItem } from '@/types/menu.js'; +import { GridContext } from '@/components/grid/grid-event.js'; + +export type ColumnType = 'text' | 'number' | 'date' | 'boolean' | 'image' | 'hidden'; + +export type CustomValueEditor = (row: GridRow, col: GridColumn, value: CellValue, cellElement: HTMLElement) => Promise; +export type CellValueTransformer = (row: GridRow, col: GridColumn, value: CellValue) => CellValue; +export type GridColumnContextMenuFactory = (col: GridColumn, context: GridContext) => MenuItem[]; + +export type GridColumnSetting = { + bindTo: string; + title?: string; + icon?: string; + type: ColumnType; + width: SizeStyle; + editable?: boolean; + validators?: GridCellValidator[]; + customValueEditor?: CustomValueEditor; + valueTransformer?: CellValueTransformer; + contextMenuFactory?: GridColumnContextMenuFactory; + events?: { + copy?: (value: CellValue) => string; + paste?: (text: string) => CellValue; + delete?: (cell: GridCell, context: GridContext) => void; + } +}; + +export type GridColumn = { + index: number; + setting: GridColumnSetting; + width: string; + contentSize: Size; +} + +export function createColumn(setting: GridColumnSetting, index: number): GridColumn { + return { + index, + setting, + width: calcCellWidth(setting.width), + contentSize: { width: 0, height: 0 }, + }; +} + diff --git a/packages/frontend/src/components/grid/grid-event.ts b/packages/frontend/src/components/grid/grid-event.ts new file mode 100644 index 0000000000..074b72b956 --- /dev/null +++ b/packages/frontend/src/components/grid/grid-event.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridState } from '@/components/grid/grid.js'; +import { ValidateViolation } from '@/components/grid/cell-validators.js'; +import { GridColumn } from '@/components/grid/column.js'; +import { GridRow } from '@/components/grid/row.js'; + +export type GridContext = { + selectedCell?: GridCell; + rangedCells: GridCell[]; + rangedRows: GridRow[]; + randedBounds: { + leftTop: CellAddress; + rightBottom: CellAddress; + }; + availableBounds: { + leftTop: CellAddress; + rightBottom: CellAddress; + }; + state: GridState; + rows: GridRow[]; + columns: GridColumn[]; +}; + +export type GridEvent = + GridCellValueChangeEvent | + GridCellValidationEvent + ; + +export type GridCellValueChangeEvent = { + type: 'cell-value-change'; + column: GridColumn; + row: GridRow; + oldValue: CellValue; + newValue: CellValue; +}; + +export type GridCellValidationEvent = { + type: 'cell-validation'; + violation?: ValidateViolation; + all: ValidateViolation[]; +}; diff --git a/packages/frontend/src/components/grid/grid-utils.ts b/packages/frontend/src/components/grid/grid-utils.ts new file mode 100644 index 0000000000..a45bc88926 --- /dev/null +++ b/packages/frontend/src/components/grid/grid-utils.ts @@ -0,0 +1,215 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { isRef, Ref } from 'vue'; +import { DataSource, SizeStyle } from '@/components/grid/grid.js'; +import { CELL_ADDRESS_NONE, CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridRow } from '@/components/grid/row.js'; +import { GridContext } from '@/components/grid/grid-event.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { GridColumn, GridColumnSetting } from '@/components/grid/column.js'; + +export function isCellElement(elem: HTMLElement): boolean { + return elem.hasAttribute('data-grid-cell'); +} + +export function isRowElement(elem: HTMLElement): boolean { + return elem.hasAttribute('data-grid-row'); +} + +export function calcCellWidth(widthSetting: SizeStyle): string { + switch (widthSetting) { + case undefined: + case 'auto': { + return 'auto'; + } + default: { + return `${widthSetting}px`; + } + } +} + +function getCellRowByAttribute(elem: HTMLElement): number { + const row = elem.getAttribute('data-grid-cell-row'); + if (row === null) { + throw new Error('data-grid-cell-row attribute not found'); + } + return Number(row); +} + +function getCellColByAttribute(elem: HTMLElement): number { + const col = elem.getAttribute('data-grid-cell-col'); + if (col === null) { + throw new Error('data-grid-cell-col attribute not found'); + } + return Number(col); +} + +export function getCellAddress(elem: HTMLElement, parentNodeCount = 10): CellAddress { + let node = elem; + for (let i = 0; i < parentNodeCount; i++) { + if (!node.parentElement) { + break; + } + + if (isCellElement(node) && isRowElement(node.parentElement)) { + const row = getCellRowByAttribute(node); + const col = getCellColByAttribute(node); + + return { row, col }; + } + + node = node.parentElement; + } + + return CELL_ADDRESS_NONE; +} + +export function getCellElement(elem: HTMLElement, parentNodeCount = 10): HTMLElement | null { + let node = elem; + for (let i = 0; i < parentNodeCount; i++) { + if (isCellElement(node)) { + return node; + } + + if (!node.parentElement) { + break; + } + + node = node.parentElement; + } + + return null; +} + +export function equalCellAddress(a: CellAddress, b: CellAddress): boolean { + return a.row === b.row && a.col === b.col; +} + +/** + * グリッドの選択範囲の内容をタブ区切り形式テキストに変換してクリップボードにコピーする。 + */ +export function copyGridDataToClipboard( + gridItems: Ref | DataSource[], + context: GridContext, +) { + const items = isRef(gridItems) ? gridItems.value : gridItems; + const lines = Array.of(); + const bounds = context.randedBounds; + + for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) { + const rowItems = Array.of(); + for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) { + const { bindTo, events } = context.columns[col].setting; + const value = items[row][bindTo]; + const transformValue = events?.copy + ? events.copy(value) + : typeof value === 'object' || Array.isArray(value) + ? JSON.stringify(value) + : value?.toString() ?? ''; + rowItems.push(transformValue); + } + lines.push(rowItems.join('\t')); + } + + const text = lines.join('\n'); + copyToClipboard(text); + + if (_DEV_) { + console.log(`Copied to clipboard: ${text}`); + } +} + +/** + * クリップボードからタブ区切りテキストとして値を読み取り、グリッドの選択範囲に貼り付けるためのユーティリティ関数。 + * …と言いつつも、使用箇所により反映方法に差があるため更新操作はコールバック関数に任せている。 + */ +export async function pasteToGridFromClipboard( + context: GridContext, + callback: (row: GridRow, col: GridColumn, parsedValue: CellValue) => void, +) { + function parseValue(value: string, setting: GridColumnSetting): CellValue { + if (setting.events?.paste) { + return setting.events.paste(value); + } else { + switch (setting.type) { + case 'number': { + return Number(value); + } + case 'boolean': { + return value === 'true'; + } + default: { + return value; + } + } + } + } + + const clipBoardText = await navigator.clipboard.readText(); + if (_DEV_) { + console.log(`Paste from clipboard: ${clipBoardText}`); + } + + const bounds = context.randedBounds; + const lines = clipBoardText.replace(/\r/g, '') + .split('\n') + .map(it => it.split('\t')); + + if (lines.length === 1 && lines[0].length === 1) { + // 単独文字列の場合は選択範囲全体に同じテキストを貼り付ける + const ranges = context.rangedCells; + for (const cell of ranges) { + if (cell.column.setting.editable) { + callback(cell.row, cell.column, parseValue(lines[0][0], cell.column.setting)); + } + } + } else { + // 表形式文字列の場合は表形式にパースし、選択範囲に合うように貼り付ける + const offsetRow = bounds.leftTop.row; + const offsetCol = bounds.leftTop.col; + const { columns, rows } = context; + for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) { + const rowIdx = row - offsetRow; + if (lines.length <= rowIdx) { + // クリップボードから読んだ二次元配列よりも選択範囲の方が大きい場合、貼り付け操作を打ち切る + break; + } + + const items = lines[rowIdx]; + for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) { + const colIdx = col - offsetCol; + if (items.length <= colIdx) { + // クリップボードから読んだ二次元配列よりも選択範囲の方が大きい場合、貼り付け操作を打ち切る + break; + } + + if (columns[col].setting.editable) { + callback(rows[row], columns[col], parseValue(items[colIdx], columns[col].setting)); + } + } + } + } +} + +/** + * グリッドの選択範囲にあるデータを削除するためのユーティリティ関数。 + * …と言いつつも、使用箇所により反映方法に差があるため更新操作はコールバック関数に任せている。 + */ +export function removeDataFromGrid( + context: GridContext, + callback: (cell: GridCell) => void, +) { + for (const cell of context.rangedCells) { + const { editable, events } = cell.column.setting; + if (editable) { + if (events?.delete) { + events.delete(cell, context); + } else { + callback(cell); + } + } + } +} diff --git a/packages/frontend/src/components/grid/grid.ts b/packages/frontend/src/components/grid/grid.ts new file mode 100644 index 0000000000..0cb3b6f28b --- /dev/null +++ b/packages/frontend/src/components/grid/grid.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { EventEmitter } from 'eventemitter3'; +import { CellValue, GridCellSetting } from '@/components/grid/cell.js'; +import { GridColumnSetting } from '@/components/grid/column.js'; +import { GridRowSetting } from '@/components/grid/row.js'; + +export type GridSetting = { + row?: GridRowSetting; + cols: GridColumnSetting[]; + cells?: GridCellSetting; +}; + +export type DataSource = Record; + +export type GridState = + 'normal' | + 'cellSelecting' | + 'cellEditing' | + 'colResizing' | + 'colSelecting' | + 'rowSelecting' | + 'hidden' + ; + +export type Size = { + width: number; + height: number; +} + +export type SizeStyle = number | 'auto' | undefined; + +export type AdditionalStyle = { + className?: string; + style?: Record; +} + +export class GridEventEmitter extends EventEmitter<{ + 'forceRefreshContentSize': void; +}> { +} diff --git a/packages/frontend/src/components/grid/row.ts b/packages/frontend/src/components/grid/row.ts new file mode 100644 index 0000000000..e0a317c9d3 --- /dev/null +++ b/packages/frontend/src/components/grid/row.ts @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { AdditionalStyle } from '@/components/grid/grid.js'; +import { GridCell } from '@/components/grid/cell.js'; +import { GridColumn } from '@/components/grid/column.js'; +import { MenuItem } from '@/types/menu.js'; +import { GridContext } from '@/components/grid/grid-event.js'; + +export const defaultGridRowSetting: Required = { + showNumber: true, + selectable: true, + minimumDefinitionCount: 100, + styleRules: [], + contextMenuFactory: () => [], + events: {}, +}; + +export type GridRowStyleRuleConditionParams = { + row: GridRow, + targetCols: GridColumn[], + cells: GridCell[] +}; + +export type GridRowStyleRule = { + condition: (params: GridRowStyleRuleConditionParams) => boolean; + applyStyle: AdditionalStyle; +} + +export type GridRowContextMenuFactory = (row: GridRow, context: GridContext) => MenuItem[]; + +export type GridRowSetting = { + showNumber?: boolean; + selectable?: boolean; + minimumDefinitionCount?: number; + styleRules?: GridRowStyleRule[]; + contextMenuFactory?: GridRowContextMenuFactory; + events?: { + delete?: (rows: GridRow[]) => void; + } +} + +export type GridRow = { + index: number; + ranged: boolean; + using: boolean; + setting: GridRowSetting; + additionalStyles: AdditionalStyle[]; +} + +export function createRow(index: number, using: boolean, setting: GridRowSetting): GridRow { + return { + index, + ranged: false, + using: using, + setting, + additionalStyles: [], + }; +} + +export function resetRow(row: GridRow): void { + row.ranged = false; + row.using = false; + row.additionalStyles = []; +} + diff --git a/packages/frontend/src/components/hook/useLoading.ts b/packages/frontend/src/components/hook/useLoading.ts new file mode 100644 index 0000000000..6c6ff6ae0d --- /dev/null +++ b/packages/frontend/src/components/hook/useLoading.ts @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { computed, h, ref } from 'vue'; +import MkLoading from '@/components/global/MkLoading.vue'; + +export const useLoading = (props?: { + static?: boolean; + inline?: boolean; + colored?: boolean; + mini?: boolean; + em?: boolean; +}) => { + const showingCnt = ref(0); + + const show = () => { + showingCnt.value++; + }; + + const close = (force?: boolean) => { + if (force) { + showingCnt.value = 0; + } else { + showingCnt.value = Math.max(0, showingCnt.value - 1); + } + }; + + const scope = (fn: () => T) => { + show(); + + const result = fn(); + if (result instanceof Promise) { + return result.finally(() => close()); + } else { + close(); + return result; + } + }; + + const showing = computed(() => showingCnt.value > 0); + const component = computed(() => showing.value ? h(MkLoading, props) : null); + + return { + show, + close, + scope, + component, + showing, + }; +}; diff --git a/packages/frontend/src/index.html b/packages/frontend/src/index.html index 0be589262f..84ba9dfabc 100644 --- a/packages/frontend/src/index.html +++ b/packages/frontend/src/index.html @@ -20,6 +20,7 @@ worker-src 'self'; script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://*.recaptcha.net https://*.gstatic.com https://challenges.cloudflare.com https://esm.sh; style-src 'self' 'unsafe-inline'; + font-src 'self' data:; img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000; media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000; connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com; diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 589ace0155..18c7464d2e 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -602,6 +602,27 @@ export async function selectDriveFolder(multiple: boolean): Promise { + return new Promise((resolve) => { + popup(defineAsyncComponent(() => import('@/components/MkRoleSelectDialog.vue')), params, { + done: roles => { + resolve({ canceled: false, result: roles }); + }, + close: () => { + resolve({ canceled: true, result: undefined }); + }, + }, 'dispose'); + }); +} + export async function pickEmoji(src: HTMLElement, opts: ComponentProps): Promise { return new Promise(resolve => { const { dispose } = popup(MkEmojiPickerDialog, { diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts b/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts new file mode 100644 index 0000000000..de2b2aca8c --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type RequestLogItem = { + failed: boolean; + url: string; + name: string; + error?: string; +}; + +export const gridSortOrderKeys = [ + 'name', + 'category', + 'aliases', + 'type', + 'license', + 'host', + 'uri', + 'publicUrl', + 'isSensitive', + 'localOnly', + 'updatedAt', +]; +export type GridSortOrderKey = typeof gridSortOrderKeys[number]; + +export function emptyStrToUndefined(value: string | null) { + return value ? value : undefined; +} + +export function emptyStrToNull(value: string) { + return value === '' ? null : value; +} + +export function emptyStrToEmptyArray(value: string) { + return value === '' ? [] : value.split(' ').map(it => it.trim()); +} + +export function roleIdsParser(text: string): { id: string, name: string }[] { + // idとnameのペア配列をJSONで受け取る。それ以外の形式は許容しない + try { + const obj = JSON.parse(text); + if (!Array.isArray(obj)) { + return []; + } + if (!obj.every(it => typeof it === 'object' && 'id' in it && 'name' in it)) { + return []; + } + + return obj.map(it => ({ id: it.id, name: it.name })); + } catch (ex) { + console.warn(ex); + return []; + } +} diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue new file mode 100644 index 0000000000..55f9632ce4 --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue @@ -0,0 +1,757 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue new file mode 100644 index 0000000000..a3de5de569 --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue @@ -0,0 +1,477 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue new file mode 100644 index 0000000000..ea4303f342 --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue @@ -0,0 +1,36 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.logs-folder.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.logs-folder.vue new file mode 100644 index 0000000000..f75f6c0da5 --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.logs-folder.vue @@ -0,0 +1,102 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue new file mode 100644 index 0000000000..9a9d2990ba --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue @@ -0,0 +1,441 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts b/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts new file mode 100644 index 0000000000..f62304277a --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts @@ -0,0 +1,160 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { delay, http, HttpResponse } from 'msw'; +import { StoryObj } from '@storybook/vue3'; +import { entities } from 'misskey-js'; +import { commonHandlers } from '../../../.storybook/mocks.js'; +import { emoji } from '../../../.storybook/fakes.js'; +import { fakeId } from '../../../.storybook/fake-utils.js'; +import custom_emojis_manager2 from './custom-emojis-manager2.vue'; + +function createRender(params: { + emojis: entities.EmojiDetailedAdmin[]; +}) { + const storedEmojis: entities.EmojiDetailedAdmin[] = [...params.emojis]; + const storedDriveFiles: entities.DriveFile[] = []; + + return { + render(args) { + return { + components: { + custom_emojis_manager2, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/v2/admin/emoji/list', async ({ request }) => { + await delay(100); + + const bodyStream = request.body as ReadableStream; + const body = await new Response(bodyStream).json() as entities.V2AdminEmojiListRequest; + + const emojis = storedEmojis; + const limit = body.limit ?? 10; + const page = body.page ?? 1; + const result = emojis.slice((page - 1) * limit, page * limit); + + return HttpResponse.json({ + emojis: result, + count: Math.min(emojis.length, limit), + allCount: emojis.length, + allPages: Math.ceil(emojis.length / limit), + }); + }), + http.post('/api/drive/folders', () => { + return HttpResponse.json([]); + }), + http.post('/api/drive/files', () => { + return HttpResponse.json(storedDriveFiles); + }), + http.post('/api/drive/files/create', async ({ request }) => { + const data = await request.formData(); + const file = data.get('file'); + if (!file || !(file instanceof File)) { + return HttpResponse.json({ error: 'file is required' }, { + status: 400, + }); + } + + // FIXME: ファイルのバイナリに0xEF 0xBF 0xBDが混入してしまい、うまく画像ファイルとして表示できない問題がある + const base64 = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + resolve(reader.result as string); + }; + reader.readAsDataURL(new Blob([file], { type: 'image/webp' })); + }); + + const driveFile: entities.DriveFile = { + id: fakeId(file.name), + createdAt: new Date().toISOString(), + name: file.name, + type: file.type, + md5: '', + size: file.size, + isSensitive: false, + blurhash: null, + properties: {}, + url: base64, + thumbnailUrl: null, + comment: null, + folderId: null, + folder: null, + userId: null, + user: null, + }; + + storedDriveFiles.push(driveFile); + + return HttpResponse.json(driveFile); + }), + http.post('api/admin/emoji/add', async ({ request }) => { + await delay(100); + + const bodyStream = request.body as ReadableStream; + const body = await new Response(bodyStream).json() as entities.AdminEmojiAddRequest; + + const fileId = body.fileId; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const file = storedDriveFiles.find(f => f.id === fileId)!; + + const em = emoji({ + id: fakeId(file.name), + name: body.name, + publicUrl: file.url, + originalUrl: file.url, + type: file.type, + aliases: body.aliases, + category: body.category ?? undefined, + license: body.license ?? undefined, + localOnly: body.localOnly, + isSensitive: body.isSensitive, + }); + storedEmojis.push(em); + + return HttpResponse.json(null); + }), + ], + }, + }, + } satisfies StoryObj; +} + +export const Default = createRender({ + emojis: [], +}); + +export const List10 = createRender({ + emojis: Array.from({ length: 10 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())), +}); + +export const List100 = createRender({ + emojis: Array.from({ length: 100 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())), +}); + +export const List1000 = createRender({ + emojis: Array.from({ length: 1000 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())), +}); diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager2.vue b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue new file mode 100644 index 0000000000..a952a5a3d1 --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index fd15ae1d66..969ca8b9e8 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -121,6 +121,11 @@ const menuDef = computed(() => [{ text: i18n.ts.customEmojis, to: '/admin/emojis', active: currentPage.value?.route.name === 'emojis', + }, { + icon: 'ti ti-icons', + text: i18n.ts.customEmojis + '(beta)', + to: '/admin/emojis2', + active: currentPage.value?.route.name === 'emojis2', }, { icon: 'ti ti-sparkles', text: i18n.ts.avatarDecorations, diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts index e98e0b59b1..732b209a36 100644 --- a/packages/frontend/src/router/definition.ts +++ b/packages/frontend/src/router/definition.ts @@ -382,6 +382,10 @@ const routes: RouteDef[] = [{ path: '/emojis', name: 'emojis', component: page(() => import('@/pages/custom-emojis-manager.vue')), + }, { + path: '/emojis2', + name: 'emojis2', + component: page(() => import('@/pages/admin/custom-emojis-manager2.vue')), }, { path: '/avatar-decorations', name: 'avatarDecorations', diff --git a/packages/frontend/src/scripts/file-drop.ts b/packages/frontend/src/scripts/file-drop.ts new file mode 100644 index 0000000000..c2e863c0dc --- /dev/null +++ b/packages/frontend/src/scripts/file-drop.ts @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type DroppedItem = DroppedFile | DroppedDirectory; + +export type DroppedFile = { + isFile: true; + path: string; + file: File; +}; + +export type DroppedDirectory = { + isFile: false; + path: string; + children: DroppedItem[]; +} + +export async function extractDroppedItems(ev: DragEvent): Promise { + const dropItems = ev.dataTransfer?.items; + if (!dropItems || dropItems.length === 0) { + return []; + } + + const apiTestItem = dropItems[0]; + if ('webkitGetAsEntry' in apiTestItem) { + return readDataTransferItems(dropItems); + } else { + // webkitGetAsEntryに対応していない場合はfilesから取得する(ディレクトリのサポートは出来ない) + const dropFiles = ev.dataTransfer.files; + if (dropFiles.length === 0) { + return []; + } + + const droppedFiles = Array.of(); + for (let i = 0; i < dropFiles.length; i++) { + const file = dropFiles.item(i); + if (file) { + droppedFiles.push({ + isFile: true, + path: file.name, + file, + }); + } + } + + return droppedFiles; + } +} + +/** + * ドラッグ&ドロップされたファイルのリストからディレクトリ構造とファイルへの参照({@link File})を取得する。 + */ +export async function readDataTransferItems(itemList: DataTransferItemList): Promise { + async function readEntry(entry: FileSystemEntry): Promise { + if (entry.isFile) { + return { + isFile: true, + path: entry.fullPath, + file: await readFile(entry as FileSystemFileEntry), + }; + } else { + return { + isFile: false, + path: entry.fullPath, + children: await readDirectory(entry as FileSystemDirectoryEntry), + }; + } + } + + function readFile(fileSystemFileEntry: FileSystemFileEntry): Promise { + return new Promise((resolve, reject) => { + fileSystemFileEntry.file(resolve, reject); + }); + } + + function readDirectory(fileSystemDirectoryEntry: FileSystemDirectoryEntry): Promise { + return new Promise(async (resolve) => { + const allEntries = Array.of(); + const reader = fileSystemDirectoryEntry.createReader(); + while (true) { + const entries = await new Promise((res, rej) => reader.readEntries(res, rej)); + if (entries.length === 0) { + break; + } + allEntries.push(...entries); + } + + resolve(await Promise.all(allEntries.map(readEntry))); + }); + } + + // 扱いにくいので配列に変換 + const items = Array.of(); + for (let i = 0; i < itemList.length; i++) { + items.push(itemList[i]); + } + + return Promise.all( + items + .map(it => it.webkitGetAsEntry()) + .filter(it => it) + .map(it => readEntry(it!)), + ); +} + +/** + * {@link DroppedItem}のリストからディレクトリを再帰的に検索し、ファイルのリストを取得する。 + */ +export function flattenDroppedFiles(items: DroppedItem[]): DroppedFile[] { + const result = Array.of(); + for (const item of items) { + if (item.isFile) { + result.push(item); + } else { + result.push(...flattenDroppedFiles(item.children)); + } + } + return result; +} diff --git a/packages/frontend/src/scripts/key-event.ts b/packages/frontend/src/scripts/key-event.ts new file mode 100644 index 0000000000..a72776d48c --- /dev/null +++ b/packages/frontend/src/scripts/key-event.ts @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * {@link KeyboardEvent.code} の値を表す文字列。不足分は適宜追加する + * @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values + */ +export type KeyCode = + | 'Backspace' + | 'Tab' + | 'Enter' + | 'Shift' + | 'Control' + | 'Alt' + | 'Pause' + | 'CapsLock' + | 'Escape' + | 'Space' + | 'PageUp' + | 'PageDown' + | 'End' + | 'Home' + | 'ArrowLeft' + | 'ArrowUp' + | 'ArrowRight' + | 'ArrowDown' + | 'Insert' + | 'Delete' + | 'Digit0' + | 'Digit1' + | 'Digit2' + | 'Digit3' + | 'Digit4' + | 'Digit5' + | 'Digit6' + | 'Digit7' + | 'Digit8' + | 'Digit9' + | 'KeyA' + | 'KeyB' + | 'KeyC' + | 'KeyD' + | 'KeyE' + | 'KeyF' + | 'KeyG' + | 'KeyH' + | 'KeyI' + | 'KeyJ' + | 'KeyK' + | 'KeyL' + | 'KeyM' + | 'KeyN' + | 'KeyO' + | 'KeyP' + | 'KeyQ' + | 'KeyR' + | 'KeyS' + | 'KeyT' + | 'KeyU' + | 'KeyV' + | 'KeyW' + | 'KeyX' + | 'KeyY' + | 'KeyZ' + | 'MetaLeft' + | 'MetaRight' + | 'ContextMenu' + | 'F1' + | 'F2' + | 'F3' + | 'F4' + | 'F5' + | 'F6' + | 'F7' + | 'F8' + | 'F9' + | 'F10' + | 'F11' + | 'F12' + | 'NumLock' + | 'ScrollLock' + | 'Semicolon' + | 'Equal' + | 'Comma' + | 'Minus' + | 'Period' + | 'Slash' + | 'Backquote' + | 'BracketLeft' + | 'Backslash' + | 'BracketRight' + | 'Quote' + | 'Meta' + | 'AltGraph' + ; + +/** + * 修飾キーを表す文字列。不足分は適宜追加する。 + */ +export type KeyModifier = + | 'Shift' + | 'Control' + | 'Alt' + | 'Meta' + ; + +/** + * 押下されたキー以外の状態を表す文字列。不足分は適宜追加する。 + */ +export type KeyState = + | 'composing' + | 'repeat' + ; + +export type KeyEventHandler = { + modifiers?: KeyModifier[]; + states?: KeyState[]; + code: KeyCode | 'any'; + handler: (event: KeyboardEvent) => void; +} + +export function handleKeyEvent(event: KeyboardEvent, handlers: KeyEventHandler[]) { + function checkModifier(ev: KeyboardEvent, modifiers? : KeyModifier[]) { + if (modifiers) { + return modifiers.every(modifier => ev.getModifierState(modifier)); + } + return true; + } + + function checkState(ev: KeyboardEvent, states?: KeyState[]) { + if (states) { + return states.every(state => ev.getModifierState(state)); + } + return true; + } + + let hit = false; + for (const handler of handlers.filter(it => it.code === event.code)) { + if (checkModifier(event, handler.modifiers) && checkState(event, handler.states)) { + handler.handler(event); + hit = true; + break; + } + } + + if (!hit) { + for (const handler of handlers.filter(it => it.code === 'any')) { + handler.handler(event); + } + } +} diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts index b037aa8acc..c25b4d73bd 100644 --- a/packages/frontend/src/scripts/select-file.ts +++ b/packages/frontend/src/scripts/select-file.ts @@ -12,14 +12,28 @@ import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { uploadFile } from '@/scripts/upload.js'; -export function chooseFileFromPc(multiple: boolean, keepOriginal = false): Promise { +export function chooseFileFromPc( + multiple: boolean, + options?: { + uploadFolder?: string | null; + keepOriginal?: boolean; + nameConverter?: (file: File) => string | undefined; + }, +): Promise { + const uploadFolder = options?.uploadFolder ?? defaultStore.state.uploadFolder; + const keepOriginal = options?.keepOriginal ?? defaultStore.state.keepOriginalUploading; + const nameConverter = options?.nameConverter ?? (() => undefined); + return new Promise((res, rej) => { const input = document.createElement('input'); input.type = 'file'; input.multiple = multiple; input.onchange = () => { if (!input.files) return res([]); - const promises = Array.from(input.files, file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal)); + const promises = Array.from( + input.files, + file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal), + ); Promise.all(promises).then(driveFiles => { res(driveFiles); @@ -94,7 +108,7 @@ function select(src: HTMLElement | EventTarget | null, label: string | null, mul }, { text: i18n.ts.upload, icon: 'ti ti-upload', - action: () => chooseFileFromPc(multiple, keepOriginal.value).then(files => res(files)), + action: () => chooseFileFromPc(multiple, { keepOriginal: keepOriginal.value }).then(files => res(files)), }, { text: i18n.ts.fromDrive, icon: 'ti ti-cloud', diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 211ddb8287..7098b52205 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1117,6 +1117,9 @@ type EmojiDeleted = { // @public (undocumented) type EmojiDetailed = components['schemas']['EmojiDetailed']; +// @public (undocumented) +type EmojiDetailedAdmin = components['schemas']['EmojiDetailedAdmin']; + // @public (undocumented) type EmojiRequest = operations['emoji']['requestBody']['content']['application/json']; @@ -1294,6 +1297,8 @@ declare namespace entities { AdminEmojiSetCategoryBulkRequest, AdminEmojiSetLicenseBulkRequest, AdminEmojiUpdateRequest, + V2AdminEmojiListRequest, + V2AdminEmojiListResponse, AdminFederationDeleteAllFilesRequest, AdminFederationRefreshRemoteInstanceMetadataRequest, AdminFederationRemoveAllFollowingRequest, @@ -1847,6 +1852,7 @@ declare namespace entities { GalleryPost, EmojiSimple, EmojiDetailed, + EmojiDetailedAdmin, Flash, Signin, RoleCondFormulaLogics, @@ -3420,6 +3426,12 @@ type UsersShowResponse = operations['users___show']['responses']['200']['content // @public (undocumented) type UsersUpdateMemoRequest = operations['users___update-memo']['requestBody']['content']['application/json']; +// @public (undocumented) +type V2AdminEmojiListRequest = operations['v2___admin___emoji___list']['requestBody']['content']['application/json']; + +// @public (undocumented) +type V2AdminEmojiListResponse = operations['v2___admin___emoji___list']['responses']['200']['content']['application/json']; + // Warnings were encountered during analysis: // // src/entities.ts:50:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 3bcdae6a4a..edaa0498e9 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -493,6 +493,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:emoji* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index b016d5bbcf..982717597b 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -62,6 +62,8 @@ import type { AdminEmojiSetCategoryBulkRequest, AdminEmojiSetLicenseBulkRequest, AdminEmojiUpdateRequest, + V2AdminEmojiListRequest, + V2AdminEmojiListResponse, AdminFederationDeleteAllFilesRequest, AdminFederationRefreshRemoteInstanceMetadataRequest, AdminFederationRemoveAllFollowingRequest, @@ -628,6 +630,7 @@ export type Endpoints = { 'admin/emoji/set-category-bulk': { req: AdminEmojiSetCategoryBulkRequest; res: EmptyResponse }; 'admin/emoji/set-license-bulk': { req: AdminEmojiSetLicenseBulkRequest; res: EmptyResponse }; 'admin/emoji/update': { req: AdminEmojiUpdateRequest; res: EmptyResponse }; + 'v2/admin/emoji/list': { req: V2AdminEmojiListRequest; res: V2AdminEmojiListResponse }; 'admin/federation/delete-all-files': { req: AdminFederationDeleteAllFilesRequest; res: EmptyResponse }; 'admin/federation/refresh-remote-instance-metadata': { req: AdminFederationRefreshRemoteInstanceMetadataRequest; res: EmptyResponse }; 'admin/federation/remove-all-following': { req: AdminFederationRemoveAllFollowingRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 02be4848c7..e4299d62c7 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -65,6 +65,8 @@ export type AdminEmojiSetAliasesBulkRequest = operations['admin___emoji___set-al export type AdminEmojiSetCategoryBulkRequest = operations['admin___emoji___set-category-bulk']['requestBody']['content']['application/json']; export type AdminEmojiSetLicenseBulkRequest = operations['admin___emoji___set-license-bulk']['requestBody']['content']['application/json']; export type AdminEmojiUpdateRequest = operations['admin___emoji___update']['requestBody']['content']['application/json']; +export type V2AdminEmojiListRequest = operations['v2___admin___emoji___list']['requestBody']['content']['application/json']; +export type V2AdminEmojiListResponse = operations['v2___admin___emoji___list']['responses']['200']['content']['application/json']; export type AdminFederationDeleteAllFilesRequest = operations['admin___federation___delete-all-files']['requestBody']['content']['application/json']; export type AdminFederationRefreshRemoteInstanceMetadataRequest = operations['admin___federation___refresh-remote-instance-metadata']['requestBody']['content']['application/json']; export type AdminFederationRemoveAllFollowingRequest = operations['admin___federation___remove-all-following']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts index 04574849d4..1a30da4437 100644 --- a/packages/misskey-js/src/autogen/models.ts +++ b/packages/misskey-js/src/autogen/models.ts @@ -33,6 +33,7 @@ export type FederationInstance = components['schemas']['FederationInstance']; export type GalleryPost = components['schemas']['GalleryPost']; export type EmojiSimple = components['schemas']['EmojiSimple']; export type EmojiDetailed = components['schemas']['EmojiDetailed']; +export type EmojiDetailedAdmin = components['schemas']['EmojiDetailedAdmin']; export type Flash = components['schemas']['Flash']; export type Signin = components['schemas']['Signin']; export type RoleCondFormulaLogics = components['schemas']['RoleCondFormulaLogics']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index ada685604d..75a99263d0 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -414,6 +414,15 @@ export type paths = { */ post: operations['admin___emoji___update']; }; + '/v2/admin/emoji/list': { + /** + * v2/admin/emoji/list + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:emoji* + */ + post: operations['v2___admin___emoji___list']; + }; '/admin/federation/delete-all-files': { /** * admin/federation/delete-all-files @@ -4749,6 +4758,29 @@ export type components = { localOnly: boolean; roleIdsThatCanBeUsedThisEmojiAsReaction: string[]; }; + EmojiDetailedAdmin: { + /** Format: id */ + id: string; + /** Format: date-time */ + updatedAt: string | null; + name: string; + /** @description The local host is represented with `null`. */ + host: string | null; + publicUrl: string; + originalUrl: string; + uri: string | null; + type: string | null; + aliases: string[]; + category: string | null; + license: string | null; + localOnly: boolean; + isSensitive: boolean; + roleIdsThatCanBeUsedThisEmojiAsReaction: { + /** Format: misskey:id */ + id: string; + name: string; + }[]; + }; Flash: { /** * Format: id @@ -7872,6 +7904,97 @@ export type operations = { }; }; }; + /** + * v2/admin/emoji/list + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:emoji* + */ + v2___admin___emoji___list: { + requestBody: { + content: { + 'application/json': { + query?: ({ + updatedAtFrom?: string; + updatedAtTo?: string; + name?: string; + host?: string; + uri?: string; + publicUrl?: string; + originalUrl?: string; + type?: string; + aliases?: string; + category?: string; + license?: string; + isSensitive?: boolean; + localOnly?: boolean; + /** + * @default all + * @enum {string} + */ + hostType?: 'local' | 'remote' | 'all'; + roleIds?: string[]; + }) | null; + /** Format: misskey:id */ + sinceId?: string; + /** Format: misskey:id */ + untilId?: string; + /** @default 10 */ + limit?: number; + page?: number; + /** + * @default [ + * "-id" + * ] + */ + sortKeys?: ('+id' | '-id' | '+updatedAt' | '-updatedAt' | '+name' | '-name' | '+host' | '-host' | '+uri' | '-uri' | '+publicUrl' | '-publicUrl' | '+type' | '-type' | '+aliases' | '-aliases' | '+category' | '-category' | '+license' | '-license' | '+isSensitive' | '-isSensitive' | '+localOnly' | '-localOnly' | '+roleIdsThatCanBeUsedThisEmojiAsReaction' | '-roleIdsThatCanBeUsedThisEmojiAsReaction')[]; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': { + emojis: components['schemas']['EmojiDetailedAdmin'][]; + count: number; + allCount: number; + allPages: number; + }; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * admin/federation/delete-all-files * @description No description provided. -- cgit v1.2.3-freya From b92a3255394a727feb4a9aae1eef3c2048e2bea4 Mon Sep 17 00:00:00 2001 From: piuvas Date: Mon, 20 Jan 2025 17:18:56 -0300 Subject: use addOrderBy as not to override orderBy --- packages/backend/src/server/api/endpoints/emojis.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/emojis.ts b/packages/backend/src/server/api/endpoints/emojis.ts index 3cc7f89ab9..f8095dd00e 100644 --- a/packages/backend/src/server/api/endpoints/emojis.ts +++ b/packages/backend/src/server/api/endpoints/emojis.ts @@ -59,7 +59,7 @@ export default class extends Endpoint { // eslint- const emojis = await this.emojisRepository.createQueryBuilder() .where('host IS NULL') .orderBy('LOWER(category)', 'ASC') - .orderBy('LOWER(name)', 'ASC') + .addOrderBy('LOWER(name)', 'ASC') .getMany(); return { emojis: await this.emojiEntityService.packSimpleMany(emojis), -- cgit v1.2.3-freya From 62964c45f8bd2fce818b76faaba88f94c71db74d Mon Sep 17 00:00:00 2001 From: piuvas Date: Mon, 20 Jan 2025 17:26:39 -0300 Subject: cleanup for linter to be happy --- packages/backend/src/server/api/endpoints/emojis.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/emojis.ts b/packages/backend/src/server/api/endpoints/emojis.ts index f8095dd00e..84f2eebe88 100644 --- a/packages/backend/src/server/api/endpoints/emojis.ts +++ b/packages/backend/src/server/api/endpoints/emojis.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { IsNull } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import type { EmojisRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -55,7 +54,7 @@ export default class extends Endpoint { // eslint- private emojiEntityService: EmojiEntityService, ) { - super(meta, paramDef, async (ps, me) => { + super(meta, paramDef, async () => { const emojis = await this.emojisRepository.createQueryBuilder() .where('host IS NULL') .orderBy('LOWER(category)', 'ASC') -- cgit v1.2.3-freya From 716f203171361d6279104b36b868a237f6abae56 Mon Sep 17 00:00:00 2001 From: piuvas Date: Thu, 23 Jan 2025 22:33:19 -0300 Subject: requested changes --- packages/backend/src/server/api/endpoints/emojis.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/emojis.ts b/packages/backend/src/server/api/endpoints/emojis.ts index 84f2eebe88..4909c948e3 100644 --- a/packages/backend/src/server/api/endpoints/emojis.ts +++ b/packages/backend/src/server/api/endpoints/emojis.ts @@ -54,7 +54,7 @@ export default class extends Endpoint { // eslint- private emojiEntityService: EmojiEntityService, ) { - super(meta, paramDef, async () => { + super(meta, paramDef, async (ps, me) => { const emojis = await this.emojisRepository.createQueryBuilder() .where('host IS NULL') .orderBy('LOWER(category)', 'ASC') -- 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') 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 83f2d93d30100f38df2ed34807ec5d8d80a19c4b Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 1 Feb 2025 18:53:32 -0500 Subject: increase rate limit on federation/update-remote-user --- .../src/server/api/endpoints/federation/update-remote-user.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts index 3ec9522c44..5217f79065 100644 --- a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts +++ b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts @@ -13,10 +13,11 @@ export const meta = { requireCredential: false, - // 2 calls per second + // Up to 10 calls, then 4 / second. + // This allows for reliable automation. limit: { - duration: 1000, - max: 2, + max: 10, + dripRate: 250, }, } as const; -- cgit v1.2.3-freya From bd716ed8377ab160abf23f60ccc43affebc87882 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 1 Feb 2025 23:56:41 -0500 Subject: increase the rate limit for `/api/i` endpoint, preventing some 429 errors if multiple tabs reload simultaneously --- packages/backend/src/server/api/endpoints/i.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index 9347c9ca27..48a2e3b40a 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -31,10 +31,12 @@ export const meta = { }, }, - // 3 calls per second + // up to 20 calls, then 1 per second. + // This handles bursty traffic when all tabs reload as a group limit: { - duration: 1000, - max: 3, + max: 20, + dripSize: 1, + dripRate: 1000, }, } as const; -- 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') 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') 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 b84a1542fa01da41de6bf78ae45b85f0fb496c75 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:02:30 -0500 Subject: fix import order in channels/update.ts --- packages/backend/src/server/api/endpoints/channels/update.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index d2a75225ed..7cca688fda 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -4,13 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository, ChannelsRepository } from '@/models/_.js'; import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; -import ms from 'ms'; export const meta = { tags: ['channels'], -- cgit v1.2.3-freya From 606d10f1097c0f28c074b66c354b8c102f0d878b Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:03:42 -0500 Subject: remove unused import from drive/files/delete.ts --- packages/backend/src/server/api/endpoints/drive/files/delete.ts | 1 - 1 file changed, 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/drive/files/delete.ts b/packages/backend/src/server/api/endpoints/drive/files/delete.ts index 7a009b12a1..3065bb6711 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/delete.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/delete.ts @@ -11,7 +11,6 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../../error.js'; -import ms from 'ms'; export const meta = { tags: ['drive'], -- cgit v1.2.3-freya From ffd1c52213dfb1fbb834171d09583b55568c91d1 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:04:19 -0500 Subject: remove unused import from drive/files/update.ts --- packages/backend/src/server/api/endpoints/drive/files/update.ts | 1 - 1 file changed, 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/drive/files/update.ts b/packages/backend/src/server/api/endpoints/drive/files/update.ts index 94a0e673a3..306a646785 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/update.ts @@ -11,7 +11,6 @@ import { RoleService } from '@/core/RoleService.js'; import { DriveService } from '@/core/DriveService.js'; import type { Config } from '@/config.js'; import { ApiError } from '../../../error.js'; -import ms from 'ms'; export const meta = { tags: ['drive'], -- cgit v1.2.3-freya From aeed479ea08e85a777bd135eaad13797cf91b678 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:04:40 -0500 Subject: remove unused import from drive/folders/update.ts --- packages/backend/src/server/api/endpoints/drive/folders/update.ts | 1 - 1 file changed, 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/drive/folders/update.ts b/packages/backend/src/server/api/endpoints/drive/folders/update.ts index cd47c0fc68..8d51d09ea6 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/update.ts @@ -10,7 +10,6 @@ import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityServi import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import ms from 'ms'; export const meta = { tags: ['drive'], -- cgit v1.2.3-freya From 6bb99734ad856a853510ddb9d3223568ee26b0e7 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:05:16 -0500 Subject: fix lint errors in drive/files.ts --- packages/backend/src/server/api/endpoints/drive/files.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts index 8e821da0da..5df212415d 100644 --- a/packages/backend/src/server/api/endpoints/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -4,13 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DI } from '@/di-symbols.js'; import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; -import { Brackets } from 'typeorm'; export const meta = { tags: ['drive'], @@ -45,7 +45,7 @@ export const paramDef = { folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, type: { type: 'string', nullable: true, pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) }, sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size', null] }, - searchQuery: { type: 'string', default: '' } + searchQuery: { type: 'string', default: '' }, }, required: [], } as const; -- cgit v1.2.3-freya From 82345dc77ab558114fc0b6588c6d792505774e6b Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:05:34 -0500 Subject: fix lint error in drive/folder.ts --- packages/backend/src/server/api/endpoints/drive/folders.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/drive/folders.ts b/packages/backend/src/server/api/endpoints/drive/folders.ts index 1245706b0d..525cb8c5d6 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders.ts @@ -42,7 +42,7 @@ export const paramDef = { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, - searchQuery: { type: 'string', default: '' } + searchQuery: { type: 'string', default: '' }, }, required: [], } as const; -- cgit v1.2.3-freya From f469a7b1de293bc89976aa90bfb3f167c014c384 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:06:25 -0500 Subject: fix import order in flash/delete.ts --- packages/backend/src/server/api/endpoints/flash/delete.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/flash/delete.ts b/packages/backend/src/server/api/endpoints/flash/delete.ts index 1010567113..5b7d936b1c 100644 --- a/packages/backend/src/server/api/endpoints/flash/delete.ts +++ b/packages/backend/src/server/api/endpoints/flash/delete.ts @@ -4,13 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import type { FlashsRepository, UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; -import ms from 'ms'; export const meta = { tags: ['flashs'], -- cgit v1.2.3-freya From 294365591c547fa49e37c9efd2913b367ce8679d Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:07:03 -0500 Subject: fix import order in gallery/posts/delete.ts --- packages/backend/src/server/api/endpoints/gallery/posts/delete.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts index 68478ba55c..28c8237761 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts @@ -4,13 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { GalleryPostsRepository, UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../../error.js'; -import ms from 'ms'; export const meta = { tags: ['gallery'], -- cgit v1.2.3-freya From ea35204c9e84e54cad5a5fb34586fb779aa2d371 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:07:34 -0500 Subject: fix import order in i/2fa/done.ts --- packages/backend/src/server/api/endpoints/i/2fa/done.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/i/2fa/done.ts b/packages/backend/src/server/api/endpoints/i/2fa/done.ts index 34b6907338..12d5ef443d 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/done.ts @@ -5,12 +5,12 @@ import * as OTPAuth from 'otpauth'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UserProfilesRepository } from '@/models/_.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; -import ms from 'ms'; export const meta = { requireCredential: true, -- cgit v1.2.3-freya From 96334ef178e65e2e2c18e6dd0482590f10e3671c Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:08:42 -0500 Subject: fix lint errors in i/2fa/key-done.ts --- packages/backend/src/server/api/endpoints/i/2fa/key-done.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts index 084d4af658..370d9915a3 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -6,6 +6,7 @@ //import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; @@ -14,7 +15,6 @@ import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/model import { WebAuthnService } from '@/core/WebAuthnService.js'; import { ApiError } from '@/server/api/error.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import ms from 'ms'; export const meta = { requireCredential: true, @@ -63,8 +63,8 @@ export const paramDef = { required: ['password', 'name', 'credential'], } as const; -// eslint-disable-next-line import/no-default-export @Injectable() +// eslint-disable-next-line import/no-default-export export default class extends Endpoint { constructor( @Inject(DI.userProfilesRepository) -- cgit v1.2.3-freya From 85fbd0edf9217be1e030a5ef23e5e2831bc0bca5 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:09:07 -0500 Subject: fix import order in i/2fa/password-less.ts --- packages/backend/src/server/api/endpoints/i/2fa/password-less.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts index a1c965f603..cd520cff0f 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts @@ -4,13 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/_.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import ms from 'ms'; export const meta = { requireCredential: true, -- cgit v1.2.3-freya From 4c268e4a2bf9db5422ff7192d6ca0eccd4d508a1 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:09:30 -0500 Subject: fix import order in i/2fa/register.ts --- packages/backend/src/server/api/endpoints/i/2fa/register.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts index 888d0fc6ef..d27c14c69b 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -8,13 +8,13 @@ import * as argon2 from 'argon2'; import * as OTPAuth from 'otpauth'; import * as QRCode from 'qrcode'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import type { UserProfilesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { ApiError } from '@/server/api/error.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import ms from 'ms'; export const meta = { requireCredential: true, -- cgit v1.2.3-freya From 51232dc60f857b602012a4271e9283d74b5b8311 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:09:58 -0500 Subject: fix import order in i/2fa/register-key.ts --- packages/backend/src/server/api/endpoints/i/2fa/register-key.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts index 6ab50a57c9..893ea30391 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts @@ -6,13 +6,13 @@ //import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UserProfilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { WebAuthnService } from '@/core/WebAuthnService.js'; import { ApiError } from '@/server/api/error.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import ms from 'ms'; export const meta = { requireCredential: true, -- cgit v1.2.3-freya From 3ae4356ac7d172f748277a94729a9144ab806643 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:10:16 -0500 Subject: fix import order in i/2fa/remove-key.ts --- packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts index 614fd0c498..b01e452056 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -6,6 +6,7 @@ //import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/_.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -13,7 +14,6 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '@/server/api/error.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import ms from 'ms'; export const meta = { requireCredential: true, -- cgit v1.2.3-freya From e04d38e1053a82bfac16c19474a8f47c80fdb6d3 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:10:43 -0500 Subject: fix import order in i/2fa/unregister.ts --- packages/backend/src/server/api/endpoints/i/2fa/unregister.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts index 2773825373..2fe4fdc4c0 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -6,6 +6,7 @@ //import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UserProfilesRepository } from '@/models/_.js'; @@ -13,7 +14,6 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '@/server/api/error.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import ms from 'ms'; export const meta = { requireCredential: true, -- cgit v1.2.3-freya From 27a8ded246ebf0f059bc95cfe94f674e9293ca88 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:16:08 -0500 Subject: fix import order in i/2fa/update-key.ts --- packages/backend/src/server/api/endpoints/i/2fa/update-key.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts index eb8a63b3dc..4a41c7b984 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts @@ -5,13 +5,13 @@ //import bcrypt from 'bcryptjs'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UserSecurityKeysRepository } from '@/models/_.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import ms from 'ms'; export const meta = { requireCredential: true, -- cgit v1.2.3-freya From 6d12bc0e7fb7cf27243beecfb3ab5fcfb2178fad Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:17:18 -0500 Subject: fix import order in i/change-password.ts --- packages/backend/src/server/api/endpoints/i/change-password.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/i/change-password.ts b/packages/backend/src/server/api/endpoints/i/change-password.ts index f131c7e9d1..4069683740 100644 --- a/packages/backend/src/server/api/endpoints/i/change-password.ts +++ b/packages/backend/src/server/api/endpoints/i/change-password.ts @@ -6,11 +6,11 @@ //import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UserProfilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import ms from 'ms'; export const meta = { requireCredential: true, -- cgit v1.2.3-freya From 1f03acb97a21f10c038d3f7e02aec0a27c17271a Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:41:15 -0500 Subject: fix import order in pages/delete.ts --- packages/backend/src/server/api/endpoints/pages/delete.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/pages/delete.ts b/packages/backend/src/server/api/endpoints/pages/delete.ts index c2c3215f49..0ad7a3633a 100644 --- a/packages/backend/src/server/api/endpoints/pages/delete.ts +++ b/packages/backend/src/server/api/endpoints/pages/delete.ts @@ -4,13 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import type { PagesRepository, UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; -import ms from 'ms'; export const meta = { tags: ['pages'], -- cgit v1.2.3-freya From 8e7cac3d778143a618c986e8fcd2f9c46fe38888 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:41:27 -0500 Subject: fix import order in notes/like.ts --- packages/backend/src/server/api/endpoints/notes/like.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/notes/like.ts b/packages/backend/src/server/api/endpoints/notes/like.ts index 9068de2865..d6511faf95 100644 --- a/packages/backend/src/server/api/endpoints/notes/like.ts +++ b/packages/backend/src/server/api/endpoints/notes/like.ts @@ -1,5 +1,5 @@ -import { DI } from '@/di-symbols.js'; import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GetterService } from '@/server/api/GetterService.js'; import { ReactionService } from '@/core/ReactionService.js'; -- cgit v1.2.3-freya From d879e5c03a8b81b14bb5d5cd9bce81503402e04f Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:41:52 -0500 Subject: fix import order in notes/global-timeline.ts --- packages/backend/src/server/api/endpoints/notes/global-timeline.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index c45fcd7c5c..0f2592bd78 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -12,8 +12,8 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; -import { ApiError } from '../../error.js'; import { CacheService } from '@/core/CacheService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], -- cgit v1.2.3-freya From b2b6f2f1d664354cc2ceae525ca4ded14b15c8f6 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:42:08 -0500 Subject: fix lint errors in notes/schedule/create.ts --- packages/backend/src/server/api/endpoints/notes/schedule/create.ts | 5 ----- 1 file changed, 5 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts index c6032fbdae..0c2e00ee8d 100644 --- a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts @@ -14,12 +14,10 @@ import type { NotesRepository, BlockingsRepository, DriveFilesRepository, - ChannelsRepository, NoteScheduleRepository, } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiNote } from '@/models/Note.js'; -import type { MiChannel } from '@/models/Channel.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { QueueService } from '@/core/QueueService.js'; @@ -210,9 +208,6 @@ export default class extends Endpoint { // eslint- @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - private queueService: QueueService, private roleService: RoleService, private idService: IdService, -- cgit v1.2.3-freya From 9086de16486a7dd3e5b3390cd5fac4646a0bcd55 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:42:23 -0500 Subject: fix import order in notes/favorite/delete.ts --- packages/backend/src/server/api/endpoints/notes/favorites/delete.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts b/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts index 19a6a5af54..742c872d45 100644 --- a/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts +++ b/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts @@ -4,12 +4,12 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GetterService } from '@/server/api/GetterService.js'; import { DI } from '@/di-symbols.js'; import type { NoteFavoritesRepository } from '@/models/_.js'; import { ApiError } from '../../../error.js'; -import ms from 'ms'; export const meta = { tags: ['notes', 'favorites'], -- cgit v1.2.3-freya From 6c33542fa7814017816dc625e43650f970f62b59 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:42:41 -0500 Subject: fix import order in mute/delete.ts --- packages/backend/src/server/api/endpoints/mute/delete.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/mute/delete.ts b/packages/backend/src/server/api/endpoints/mute/delete.ts index 1e14bafc87..ccc98c4b10 100644 --- a/packages/backend/src/server/api/endpoints/mute/delete.ts +++ b/packages/backend/src/server/api/endpoints/mute/delete.ts @@ -4,13 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { MutingsRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; import { UserMutingService } from '@/core/UserMutingService.js'; import { ApiError } from '../../error.js'; -import ms from 'ms'; export const meta = { tags: ['account'], -- cgit v1.2.3-freya From 4aed4d6cc289a6a7d688c87f80f0aa7e0a05f568 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:42:54 -0500 Subject: fix import order in i/regenerate-token.ts --- packages/backend/src/server/api/endpoints/i/regenerate-token.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts index 814ffb5488..38328bb7d4 100644 --- a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -6,12 +6,12 @@ //import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; import generateUserToken from '@/misc/generate-native-user-token.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; -import ms from 'ms'; export const meta = { requireCredential: true, -- cgit v1.2.3-freya From c73de4ece0e9930992f1b15052e9a831e5dcd35d Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:43:09 -0500 Subject: fix import order in i/delete-account.ts --- packages/backend/src/server/api/endpoints/i/delete-account.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts index 565eaaafc0..10fb923d4f 100644 --- a/packages/backend/src/server/api/endpoints/i/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -6,12 +6,12 @@ //import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DeleteAccountService } from '@/core/DeleteAccountService.js'; import { DI } from '@/di-symbols.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import ms from 'ms'; export const meta = { requireCredential: true, -- cgit v1.2.3-freya From 5745b942e07518611ec5e6f3c2f648eb0adf3621 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 4 Feb 2025 10:43:51 -0500 Subject: fix import order in i/claim-achievement.ts --- packages/backend/src/server/api/endpoints/i/claim-achievement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts index ebb25ebf1c..52f96e5bbd 100644 --- a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts +++ b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts @@ -3,8 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { DI } from '@/di-symbols.js'; import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { AchievementService, ACHIEVEMENT_TYPES } from '@/core/AchievementService.js'; import type { MiMeta } from '@/models/_.js'; -- cgit v1.2.3-freya From fbc6d0de54031de840c39be3a2c7c63fe522c439 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 5 Feb 2025 10:39:46 +0900 Subject: enhance: ページslugに使用可能な文字を限定 (#15395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * paramの正規表現で弾くように * apiWithDialogを使用するように * Update CHANGELOG.md --------- Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> --- CHANGELOG.md | 2 +- locales/index.d.ts | 14 +-- locales/ja-JP.yml | 5 +- packages/backend/src/models/Page.ts | 2 + .../src/server/api/endpoints/pages/create.ts | 4 +- .../src/server/api/endpoints/pages/update.ts | 5 +- .../frontend/src/pages/page-editor/page-editor.vue | 111 ++++++++++----------- 7 files changed, 59 insertions(+), 84 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/CHANGELOG.md b/CHANGELOG.md index 32b9f91a38..7f48d1c532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,9 @@ - Playが実装されたため、ページ機能の「ソースを見る」は削除されました ### Server +- Enhance: ページのURLに使用可能な文字を限定するように - Fix: 個別お知らせページのmetaタグ出力の条件が間違っていたのを修正 - ## 2025.1.0 ### Note diff --git a/locales/index.d.ts b/locales/index.d.ts index a0540fd228..4e26d5406b 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -4195,7 +4195,7 @@ export interface Locale extends ILocale { */ "invalidParamError": string; /** - * リクエストパラメータに問題があります。通常これはバグですが、入力した文字数が多すぎる等の可能性もあります。 + * リクエストパラメータに問題があります。通常これはバグですが、入力した文字数が多すぎる・許可されていない文字を入力している等の可能性もあります。 */ "invalidParamErrorDescription": string; /** @@ -9180,18 +9180,6 @@ export interface Locale extends ILocale { * ソースを表示中 */ "readPage": string; - /** - * ページを作成しました - */ - "created": string; - /** - * ページを更新しました - */ - "updated": string; - /** - * ページを削除しました - */ - "deleted": string; /** * ページ設定 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a578704434..13d8aec9b8 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1044,7 +1044,7 @@ youCannotCreateAnymore: "これ以上作成することはできません。" cannotPerformTemporary: "一時的に利用できません" cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。" invalidParamError: "パラメータエラー" -invalidParamErrorDescription: "リクエストパラメータに問題があります。通常これはバグですが、入力した文字数が多すぎる等の可能性もあります。" +invalidParamErrorDescription: "リクエストパラメータに問題があります。通常これはバグですが、入力した文字数が多すぎる・許可されていない文字を入力している等の可能性もあります。" permissionDeniedError: "操作が拒否されました" permissionDeniedErrorDescription: "このアカウントにはこの操作を行うための権限がありません。" preset: "プリセット" @@ -2422,9 +2422,6 @@ _pages: newPage: "ページの作成" editPage: "ページの編集" readPage: "ソースを表示中" - created: "ページを作成しました" - updated: "ページを更新しました" - deleted: "ページを削除しました" pageSetting: "ページ設定" nameAlreadyExists: "指定されたページURLは既に存在しています" invalidNameTitle: "不正なページURLです" diff --git a/packages/backend/src/models/Page.ts b/packages/backend/src/models/Page.ts index 1695bf570e..0b59e7a92c 100644 --- a/packages/backend/src/models/Page.ts +++ b/packages/backend/src/models/Page.ts @@ -118,3 +118,5 @@ export class MiPage { } } } + +export const pageNameSchema = { type: 'string', pattern: /^[^\s:\/?#\[\]@!$&'()*+,;=\\%\x00-\x20]{1,256}$/.source } as const; diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts index fa03b0b457..6de5fe3d44 100644 --- a/packages/backend/src/server/api/endpoints/pages/create.ts +++ b/packages/backend/src/server/api/endpoints/pages/create.ts @@ -7,7 +7,7 @@ import ms from 'ms'; import { Inject, Injectable } from '@nestjs/common'; import type { DriveFilesRepository, PagesRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; -import { MiPage } from '@/models/Page.js'; +import { MiPage, pageNameSchema } from '@/models/Page.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { PageEntityService } from '@/core/entities/PageEntityService.js'; import { DI } from '@/di-symbols.js'; @@ -51,7 +51,7 @@ export const paramDef = { type: 'object', properties: { title: { type: 'string' }, - name: { type: 'string', minLength: 1 }, + name: { ...pageNameSchema, minLength: 1 }, summary: { type: 'string', nullable: true }, content: { type: 'array', items: { type: 'object', additionalProperties: true, diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts index e52d9c32df..a6aeb6002e 100644 --- a/packages/backend/src/server/api/endpoints/pages/update.ts +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -10,6 +10,7 @@ import type { PagesRepository, DriveFilesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; +import { pageNameSchema } from '@/models/Page.js'; export const meta = { tags: ['pages'], @@ -31,13 +32,11 @@ export const meta = { code: 'NO_SUCH_PAGE', id: '21149b9e-3616-4778-9592-c4ce89f5a864', }, - accessDenied: { message: 'Access denied.', code: 'ACCESS_DENIED', id: '3c15cd52-3b4b-4274-967d-6456fc4f792b', }, - noSuchFile: { message: 'No such file.', code: 'NO_SUCH_FILE', @@ -56,7 +55,7 @@ export const paramDef = { properties: { pageId: { type: 'string', format: 'misskey:id' }, title: { type: 'string' }, - name: { type: 'string', minLength: 1 }, + name: { ...pageNameSchema, minLength: 1 }, summary: { type: 'string', nullable: true }, content: { type: 'array', items: { type: 'object', additionalProperties: true, diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue index ddb808390c..c08cfebab3 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -96,7 +96,7 @@ const summary = ref(null); const name = ref(Date.now().toString()); const eyeCatchingImage = ref(null); const eyeCatchingImageId = ref(null); -const font = ref('sans-serif'); +const font = ref<'sans-serif' | 'serif'>('sans-serif'); const content = ref([]); const alignCenter = ref(false); const hideTitleWhenPinned = ref(false); @@ -113,7 +113,7 @@ watch(eyeCatchingImageId, async () => { } }); -function getSaveOptions() { +function getSaveOptions(): Misskey.entities.PagesCreateRequest { return { title: title.value.trim(), name: name.value.trim(), @@ -128,80 +128,69 @@ function getSaveOptions() { }; } -function save() { +async function save() { const options = getSaveOptions(); - const onError = err => { - if (err.id === '3d81ceae-475f-4600-b2a8-2bc116157532') { - if (err.info.param === 'name') { - os.alert({ - type: 'error', - title: i18n.ts._pages.invalidNameTitle, - text: i18n.ts._pages.invalidNameText, - }); - } - } else if (err.code === 'NAME_ALREADY_EXISTS') { - os.alert({ - type: 'error', + if (pageId.value) { + const updateOptions: Misskey.entities.PagesUpdateRequest = { + pageId: pageId.value, + ...options, + }; + + await os.apiWithDialog('pages/update', updateOptions, undefined, { + '2298a392-d4a1-44c5-9ebb-ac1aeaa5a9ab': { + title: i18n.ts.somethingHappened, text: i18n.ts._pages.nameAlreadyExists, - }); - } - }; + }, + }); - if (pageId.value) { - options.pageId = pageId.value; - misskeyApi('pages/update', options) - .then(page => { - currentName.value = name.value.trim(); - os.alert({ - type: 'success', - text: i18n.ts._pages.updated, - }); - }).catch(onError); + currentName.value = name.value.trim(); } else { - misskeyApi('pages/create', options) - .then(created => { - pageId.value = created.id; - currentName.value = name.value.trim(); - os.alert({ - type: 'success', - text: i18n.ts._pages.created, - }); - mainRouter.push(`/pages/edit/${pageId.value}`); - }).catch(onError); + const created = await os.apiWithDialog('pages/create', options, undefined, { + '4650348e-301c-499a-83c9-6aa988c66bc1': { + title: i18n.ts.somethingHappened, + text: i18n.ts._pages.nameAlreadyExists, + }, + }); + + pageId.value = created.id; + currentName.value = name.value.trim(); + mainRouter.replace(`/pages/edit/${pageId.value}`); } } -function del() { - os.confirm({ +async function del() { + if (!pageId.value) return; + + const { canceled } = await os.confirm({ type: 'warning', text: i18n.tsx.removeAreYouSure({ x: title.value.trim() }), - }).then(({ canceled }) => { - if (canceled) return; - misskeyApi('pages/delete', { - pageId: pageId.value, - }).then(() => { - os.alert({ - type: 'success', - text: i18n.ts._pages.deleted, - }); - mainRouter.push('/pages'); - }); }); + + if (canceled) return; + + await os.apiWithDialog('pages/delete', { + pageId: pageId.value, + }); + + mainRouter.replace('/pages'); } -function duplicate() { +async function duplicate() { title.value = title.value + ' - copy'; name.value = name.value + '-copy'; - misskeyApi('pages/create', getSaveOptions()).then(created => { - pageId.value = created.id; - currentName.value = name.value.trim(); - os.alert({ - type: 'success', - text: i18n.ts._pages.created, - }); - mainRouter.push(`/pages/edit/${pageId.value}`); + + const created = await os.apiWithDialog('pages/create', getSaveOptions(), undefined, { + '4650348e-301c-499a-83c9-6aa988c66bc1': { + title: i18n.ts.somethingHappened, + text: i18n.ts._pages.nameAlreadyExists, + }, }); + + pageId.value = created.id; + currentName.value = name.value.trim(); + + mainRouter.push(`/pages/edit/${pageId.value}`); } async function add() { @@ -216,7 +205,7 @@ async function add() { content.value.push({ id, type }); } -function setEyeCatchingImage(img) { +function setEyeCatchingImage(img: Event) { selectFile(img.currentTarget ?? img.target, null).then(file => { eyeCatchingImageId.value = file.id; }); -- cgit v1.2.3-freya From 1bedf954f296a7151e0e878cecb0154508c93967 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 27 Jan 2025 01:57:14 -0500 Subject: increase rate limit for `users/show` endpoint --- packages/backend/src/server/api/endpoints/users/show.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 7ebca78a7d..118362149d 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -57,10 +57,10 @@ export const meta = { }, }, - // 5 calls per 2 seconds + // up to 50 calls @ 4 per second limit: { - duration: 1000 * 2, - max: 5, + max: 50, + dripRate: 250, }, } as const; -- cgit v1.2.3-freya From 788dc69d11fac8dd9e8912c4c0a2ad33581e606b Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Wed, 29 Jan 2025 22:53:53 -0500 Subject: use leaky bucket rate limit for ap/show --- packages/backend/src/server/api/endpoints/ap/show.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 616a77e337..474ff81822 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -4,7 +4,6 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { MiNote } from '@/models/Note.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; @@ -26,9 +25,10 @@ export const meta = { requireCredential: true, kind: 'read:account', + // Up to 30 calls, then 1 per 1/2 second limit: { - duration: ms('1minute'), max: 30, + dripRate: 500, }, errors: { -- cgit v1.2.3-freya From b92591e2eddb7a1ebda75195a65ff61f655b876a Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Wed, 29 Jan 2025 23:46:43 -0500 Subject: allow ap/show to follow cross-domain redirects --- .../backend/src/server/api/endpoints/ap/show.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 474ff81822..4904e3cb87 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { MiNote } from '@/models/Note.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; -import { isActor, isPost, getApId } from '@/core/activitypub/type.js'; +import { isActor, isPost, getApId, getNullableApId, ObjectWithId } from '@/core/activitypub/type.js'; import type { SchemaType } from '@/misc/json-schema.js'; import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; @@ -17,6 +17,8 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; +import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; +import { InstanceActorService } from '@/core/InstanceActorService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -94,6 +96,8 @@ export default class extends Endpoint { // eslint- private apDbResolverService: ApDbResolverService, private apPersonService: ApPersonService, private apNoteService: ApNoteService, + private readonly apRequestService: ApRequestService, + private readonly instanceActorService: InstanceActorService, ) { super(meta, paramDef, async (ps, me) => { const object = await this.fetchAny(ps.uri, me); @@ -118,6 +122,12 @@ export default class extends Endpoint { // eslint- ])); if (local != null) return local; + // No local object found with that uri. + // Before we fetch, resolve the URI in case it has a cross-origin redirect or anything like that. + // Resolver.resolve() uses strict verification, which is overly paranoid for a user-provided lookup. + uri = await this.resolveCanonicalUri(uri); // eslint-disable-line no-param-reassign + if (!this.utilityService.isFederationAllowedUri(uri)) return null; + const host = this.utilityService.extractDbHost(uri); // local object, not found in db? fail @@ -167,4 +177,13 @@ export default class extends Endpoint { // eslint- return null; } + + /** + * Resolves an arbitrary URI to its canonical, post-redirect form. + */ + private async resolveCanonicalUri(uri: string): Promise { + const user = await this.instanceActorService.getInstanceActor(); + const res = await this.apRequestService.signedGet(uri, user, true) as ObjectWithId; + return getNullableApId(res) ?? uri; + } } -- cgit v1.2.3-freya From 74407bc8ee43a8c7b4bc8b7e16bdfb8acd2c794c Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 1 Feb 2025 17:07:34 -0500 Subject: add MiUserProfile.defaultCW property and API --- locales/index.d.ts | 2 +- .../1738446745738-add_user_profile_default_cw.js | 11 +++++++++++ packages/backend/src/core/entities/UserEntityService.ts | 1 + packages/backend/src/models/UserProfile.ts | 10 ++++++++-- packages/backend/src/models/json-schema/user.ts | 4 ++++ packages/backend/src/server/api/endpoints/i/update.ts | 17 +++++++++++++++++ packages/misskey-js/src/autogen/types.ts | 5 +++++ 7 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 packages/backend/migration/1738446745738-add_user_profile_default_cw.js (limited to 'packages/backend/src/server/api/endpoints') diff --git a/locales/index.d.ts b/locales/index.d.ts index 70eba52ea0..af5faefe1a 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -11631,7 +11631,7 @@ export interface Locale extends ILocale { */ "robotsTxt": string; /** - * Adding entries here will override the default robots.txt packaged with Sharkey. Maximum 2048 characters. + * Adding entries here will override the default robots.txt packaged with Sharkey. */ "robotsTxtDescription": string; } diff --git a/packages/backend/migration/1738446745738-add_user_profile_default_cw.js b/packages/backend/migration/1738446745738-add_user_profile_default_cw.js new file mode 100644 index 0000000000..205ca2087a --- /dev/null +++ b/packages/backend/migration/1738446745738-add_user_profile_default_cw.js @@ -0,0 +1,11 @@ +export class AddUserProfileDefaultCw1738446745738 { + name = 'AddUserProfileDefaultCw1738446745738' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "default_cw" text`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "default_cw"`); + } +} diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 6bfe865038..0ca784fa52 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -669,6 +669,7 @@ export class UserEntityService implements OnModuleInit { achievements: profile!.achievements, loggedInDays: profile!.loggedInDates.length, policies: this.roleService.getUserPolicies(user.id), + defaultCW: profile?.defaultCW ?? null, } : {}), ...(opts.includeSecrets ? { diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 751b1aff08..3c2362227e 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -36,10 +36,10 @@ export class MiUserProfile { }) public birthday: string | null; - @Column("varchar", { + @Column('varchar', { length: 128, nullable: true, - comment: "The ListenBrainz username of the User.", + comment: 'The ListenBrainz username of the User.', }) public listenbrainz: string | null; @@ -290,6 +290,12 @@ export class MiUserProfile { unlockedAt: number; }[]; + @Column('text', { + name: 'default_cw', + nullable: true, + }) + public defaultCW: string | null; + //#region Denormalized fields @Index() @Column('varchar', { diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index f953008b3f..f6c7bd2151 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -752,6 +752,10 @@ export const packedMeDetailedOnlySchema = { }, }, //#endregion + defaultCW: { + type: 'string', + nullable: true, optional: false, + }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 09c06a108d..e487562687 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -133,6 +133,12 @@ export const meta = { id: '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191', httpStatusCode: 422, }, + + maxCwLength: { + message: 'You tried setting a default content warning which is too long.', + code: 'MAX_CW_LENGTH', + id: '7004c478-bda3-4b4f-acb2-4316398c9d52', + }, }, res: { @@ -243,6 +249,7 @@ export const paramDef = { uniqueItems: true, items: { type: 'string' }, }, + defaultCW: { type: 'string', nullable: true }, }, } as const; @@ -494,6 +501,16 @@ export default class extends Endpoint { // eslint- updates.alsoKnownAs = newAlsoKnownAs.size > 0 ? Array.from(newAlsoKnownAs) : null; } + let defaultCW = ps.defaultCW; + if (defaultCW !== undefined) { + if (defaultCW === '') defaultCW = null; + if (defaultCW && defaultCW.length > this.config.maxCwLength) { + throw new ApiError(meta.errors.maxCwLength); + } + + profileUpdates.defaultCW = defaultCW; + } + //#region emojis/tags let emojis = [] as string[]; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 888e46e008..78dac5f08b 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4217,6 +4217,7 @@ export type components = { /** Format: date-time */ lastUsed: string; }[]; + defaultCW: string | null; }; UserDetailedNotMe: components['schemas']['UserLite'] & components['schemas']['UserDetailedNotMeOnly']; MeDetailed: components['schemas']['UserLite'] & components['schemas']['UserDetailedNotMeOnly'] & components['schemas']['MeDetailedOnly']; @@ -5224,6 +5225,7 @@ export type components = { enableFC: boolean; fcSiteKey: string | null; enableAchievements: boolean | null; + robotsTxt: string | null; enableTestcaptcha: boolean; swPublickey: string | null; /** @default /assets/ai.png */ @@ -5434,6 +5436,7 @@ export type operations = { enableStatsForFederatedInstances: boolean; enableServerMachineStats: boolean; enableAchievements: boolean; + robotsTxt: string | null; enableIdenticonGeneration: boolean; manifestJsonOverride: string; policies: Record; @@ -10163,6 +10166,7 @@ export type operations = { enableStatsForFederatedInstances?: boolean; enableServerMachineStats?: boolean; enableAchievements?: boolean; + robotsTxt?: string | null; enableIdenticonGeneration?: boolean; serverRules?: string[]; bannedEmailDomains?: string[]; @@ -21631,6 +21635,7 @@ export type operations = { }; emailNotificationTypes?: string[]; alsoKnownAs?: string[]; + defaultCW?: string | null; }; }; }; -- cgit v1.2.3-freya From c8f8a61a00d07802dc5056eae48144e49bce742c Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 1 Feb 2025 23:15:02 -0500 Subject: add MiUserProfile.defaultCWPriority property and API --- .../1738468079662-add_user_profile_default_cw_priority.js | 13 +++++++++++++ packages/backend/src/core/entities/UserEntityService.ts | 7 +++++-- packages/backend/src/models/UserProfile.ts | 9 ++++++++- packages/backend/src/models/json-schema/user.ts | 5 +++++ packages/backend/src/server/api/endpoints/i/update.ts | 8 ++++++++ packages/backend/src/types.ts | 2 ++ packages/misskey-js/src/autogen/types.ts | 4 ++++ 7 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 packages/backend/migration/1738468079662-add_user_profile_default_cw_priority.js (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/migration/1738468079662-add_user_profile_default_cw_priority.js b/packages/backend/migration/1738468079662-add_user_profile_default_cw_priority.js new file mode 100644 index 0000000000..90de25e06f --- /dev/null +++ b/packages/backend/migration/1738468079662-add_user_profile_default_cw_priority.js @@ -0,0 +1,13 @@ +export class AddUserProfileDefaultCwPriority1738468079662 { + name = 'AddUserProfileDefaultCwPriority1738468079662' + + async up(queryRunner) { + await queryRunner.query(`CREATE TYPE "public"."user_profile_default_cw_priority_enum" AS ENUM ('default', 'parent', 'defaultParent', 'parentDefault')`); + await queryRunner.query(`ALTER TABLE "user_profile" ADD "default_cw_priority" "public"."user_profile_default_cw_priority_enum" NOT NULL DEFAULT 'parent'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "default_cw_priority"`); + await queryRunner.query(`DROP TYPE "public"."user_profile_default_cw_priority_enum"`); + } +} diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 0ca784fa52..6ea2d6629a 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -49,11 +49,13 @@ import { IdService } from '@/core/IdService.js'; import type { AnnouncementService } from '@/core/AnnouncementService.js'; import type { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; +import { isSystemAccount } from '@/misc/is-system-account.js'; import type { OnModuleInit } from '@nestjs/common'; import type { NoteEntityService } from './NoteEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; import type { PageEntityService } from './PageEntityService.js'; -import { isSystemAccount } from '@/misc/is-system-account.js'; + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ const Ajv = _Ajv.default; const ajv = new Ajv(); @@ -669,7 +671,8 @@ export class UserEntityService implements OnModuleInit { achievements: profile!.achievements, loggedInDays: profile!.loggedInDates.length, policies: this.roleService.getUserPolicies(user.id), - defaultCW: profile?.defaultCW ?? null, + defaultCW: profile!.defaultCW, + defaultCWPriority: profile!.defaultCWPriority, } : {}), ...(opts.includeSecrets ? { diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 3c2362227e..449c2f370b 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -4,7 +4,7 @@ */ import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; -import { obsoleteNotificationTypes, followingVisibilities, followersVisibilities, notificationTypes } from '@/types.js'; +import { obsoleteNotificationTypes, followingVisibilities, followersVisibilities, notificationTypes, noteVisibilities, defaultCWPriorities } from '@/types.js'; import { id } from './util/id.js'; import { MiUser } from './User.js'; import { MiPage } from './Page.js'; @@ -296,6 +296,13 @@ export class MiUserProfile { }) public defaultCW: string | null; + @Column('enum', { + name: 'default_cw_priority', + enum: defaultCWPriorities, + default: 'parent', + }) + public defaultCWPriority: typeof defaultCWPriorities[number]; + //#region Denormalized fields @Index() @Column('varchar', { diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index f6c7bd2151..93b031e9c5 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -756,6 +756,11 @@ export const packedMeDetailedOnlySchema = { type: 'string', nullable: true, optional: false, }, + defaultCWPriority: { + type: 'string', + enum: ['default', 'parent', 'defaultParent', 'parentDefault'], + nullable: false, optional: false, + }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index e487562687..e1552fed8a 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -250,6 +250,11 @@ export const paramDef = { items: { type: 'string' }, }, defaultCW: { type: 'string', nullable: true }, + defaultCWPriority: { + type: 'string', + enum: ['default', 'parent', 'defaultParent', 'parentDefault'], + nullable: false, + }, }, } as const; @@ -510,6 +515,9 @@ export default class extends Endpoint { // eslint- profileUpdates.defaultCW = defaultCW; } + if (ps.defaultCWPriority !== undefined) { + profileUpdates.defaultCWPriority = ps.defaultCWPriority; + } //#region emojis/tags diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 37bed27fb1..067481d9da 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -58,6 +58,8 @@ export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; export const followingVisibilities = ['public', 'followers', 'private'] as const; export const followersVisibilities = ['public', 'followers', 'private'] as const; +export const defaultCWPriorities = ['default', 'parent', 'defaultParent', 'parentDefault'] as const; + /** * ユーザーがエクスポートできるものの種類 * diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 78dac5f08b..c7268ade6a 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4218,6 +4218,8 @@ export type components = { lastUsed: string; }[]; defaultCW: string | null; + /** @enum {string} */ + defaultCWPriority: 'default' | 'parent' | 'defaultParent' | 'parentDefault'; }; UserDetailedNotMe: components['schemas']['UserLite'] & components['schemas']['UserDetailedNotMeOnly']; MeDetailed: components['schemas']['UserLite'] & components['schemas']['UserDetailedNotMeOnly'] & components['schemas']['MeDetailedOnly']; @@ -21636,6 +21638,8 @@ export type operations = { emailNotificationTypes?: string[]; alsoKnownAs?: string[]; defaultCW?: string | null; + /** @enum {string} */ + defaultCWPriority?: 'default' | 'parent' | 'defaultParent' | 'parentDefault'; }; }; }; -- 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') 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 5a1d1394d42d116ae5bcdbda3670a9047158d42b Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 31 Jan 2025 11:12:00 -0500 Subject: add `memo` and `isInstanceMuted` to UserRelation API entity --- .../backend/src/core/entities/UserEntityService.ts | 50 ++++++++++++++++++++++ .../src/server/api/endpoints/users/relation.ts | 16 +++++++ 2 files changed, 66 insertions(+) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 6ea2d6629a..ef0b5213c8 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -83,6 +83,8 @@ export type UserRelation = { isBlocked: boolean isMuted: boolean isRenoteMuted: boolean + isInstanceMuted?: boolean + memo?: string | null } @Injectable() @@ -182,6 +184,9 @@ export class UserEntityService implements OnModuleInit { isBlocked, isMuted, isRenoteMuted, + host, + memo, + mutedInstances, ] = await Promise.all([ this.followingsRepository.findOneBy({ followerId: me, @@ -229,8 +234,25 @@ export class UserEntityService implements OnModuleInit { muteeId: target, }, }), + this.usersRepository.createQueryBuilder('u') + .select('u.host') + .where({ id: target }) + .getRawOne<{ u_host: string }>() + .then(it => it?.u_host ?? null), + this.userMemosRepository.createQueryBuilder('m') + .select('m.memo') + .where({ userId: me, targetUserId: target }) + .getRawOne<{ m_memo: string | null }>() + .then(it => it?.m_memo ?? null), + this.userProfilesRepository.createQueryBuilder('p') + .select('p.mutedInstances') + .where({ userId: me }) + .getRawOne<{ p_mutedInstances: string[] }>() + .then(it => it?.p_mutedInstances ?? []), ]); + const isInstanceMuted = !!host && mutedInstances.includes(host); + return { id: target, following, @@ -242,6 +264,8 @@ export class UserEntityService implements OnModuleInit { isBlocked, isMuted, isRenoteMuted, + isInstanceMuted, + memo, }; } @@ -256,6 +280,9 @@ export class UserEntityService implements OnModuleInit { blockees, muters, renoteMuters, + hosts, + memos, + mutedInstances, ] = await Promise.all([ this.followingsRepository.findBy({ followerId: me }) .then(f => new Map(f.map(it => [it.followeeId, it]))), @@ -294,6 +321,27 @@ export class UserEntityService implements OnModuleInit { .where('m.muterId = :me', { me }) .getRawMany<{ m_muteeId: string }>() .then(it => it.map(it => it.m_muteeId)), + this.usersRepository.createQueryBuilder('u') + .select(['u.id', 'u.host']) + .where({ id: In(targets) } ) + .getRawMany<{ m_id: string, m_host: string }>() + .then(it => it.reduce((map, it) => { + map[it.m_id] = it.m_host; + return map; + }, {} as Record)), + this.userMemosRepository.createQueryBuilder('m') + .select(['m.targetUserId', 'm.memo']) + .where({ userId: me, targetUserId: In(targets) }) + .getRawMany<{ m_targetUserId: string, m_memo: string | null }>() + .then(it => it.reduce((map, it) => { + map[it.m_targetUserId] = it.m_memo; + return map; + }, {} as Record)), + this.userProfilesRepository.createQueryBuilder('p') + .select('p.mutedInstances') + .where({ userId: me }) + .getRawOne<{ p_mutedInstances: string[] }>() + .then(it => it?.p_mutedInstances ?? []), ]); return new Map( @@ -313,6 +361,8 @@ export class UserEntityService implements OnModuleInit { isBlocked: blockees.includes(target), isMuted: muters.includes(target), isRenoteMuted: renoteMuters.includes(target), + isInstanceMuted: mutedInstances.includes(hosts[target]), + memo: memos[target] ?? null, }, ]; }), diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts index e659c46713..c7016d8d32 100644 --- a/packages/backend/src/server/api/endpoints/users/relation.ts +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -58,6 +58,14 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isInstanceMuted: { + type: 'boolean', + optional: true, nullable: false, + }, + memo: { + type: 'string', + optional: true, nullable: true, + }, }, }, { @@ -103,6 +111,14 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isInstanceMuted: { + type: 'boolean', + optional: true, nullable: false, + }, + memo: { + type: 'string', + optional: true, nullable: true, + }, }, }, }, -- cgit v1.2.3-freya From 7b507485b54eaae283ca388dd9948c35592a4ced Mon Sep 17 00:00:00 2001 From: dakkar Date: Mon, 10 Feb 2025 10:16:27 +0000 Subject: search-by-tags returns "home" notes - fixes #933 featured / trending tags count both "home" and "public" notes, so this should do the same --- packages/backend/src/server/api/endpoints/notes/search-by-tag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index 227ac0ebbf..6bba7bf37e 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -87,7 +87,7 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere('note.visibility = \'public\'') + .andWhere("note.visibility IN ('public', 'home')") // keep in sync with NoteCreateService call to `hashtagService.updateHashtags()` .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') -- cgit v1.2.3-freya From ed981a6970df4cecedb3fa7553f5fa8d43665a51 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 13 Feb 2025 09:28:46 -0500 Subject: add new note search file types (module, flash) and optimize file type query --- locales/index.d.ts | 36 +++++++ .../1739451520729-index_note_attachedFileTypes.js | 12 +++ packages/backend/src/core/SearchService.ts | 107 +++++++++++---------- packages/backend/src/models/Note.ts | 1 + .../src/server/api/endpoints/notes/search.ts | 8 +- packages/frontend/src/pages/search.note.vue | 16 +-- packages/misskey-js/src/autogen/types.ts | 3 +- sharkey-locales/en-US.yml | 11 +++ 8 files changed, 135 insertions(+), 59 deletions(-) create mode 100644 packages/backend/migration/1739451520729-index_note_attachedFileTypes.js (limited to 'packages/backend/src/server/api/endpoints') diff --git a/locales/index.d.ts b/locales/index.d.ts index a1bfc4a595..7e90a779dc 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -11706,6 +11706,42 @@ export interface Locale extends ILocale { */ "title": string; }; + "_noteSearch": { + /** + * Sort by newest to oldest + */ + "newestToOldest": string; + /** + * File Type + */ + "fileType": string; + "_fileType": { + /** + * None + */ + "none": string; + /** + * Images + */ + "image": string; + /** + * Videos + */ + "video": string; + /** + * Audio + */ + "audio": string; + /** + * Module + */ + "module": string; + /** + * Flash + */ + "flash": string; + }; + }; } declare const locales: { [lang: string]: Locale; diff --git a/packages/backend/migration/1739451520729-index_note_attachedFileTypes.js b/packages/backend/migration/1739451520729-index_note_attachedFileTypes.js new file mode 100644 index 0000000000..351908a68c --- /dev/null +++ b/packages/backend/migration/1739451520729-index_note_attachedFileTypes.js @@ -0,0 +1,12 @@ +// https://stackoverflow.com/a/4059785 +export class IndexNoteAttachedFileTypes1739451520729 { + name = 'IndexNoteAttachedFileTypes1739451520729' + + async up(queryRunner) { + await queryRunner.query(`CREATE INDEX "IDX_NOTE_ATTACHED_FILE_TYPES" ON "note" USING GIN ("attachedFileTypes" array_ops)`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "IDX_NOTE_ATTACHED_FILE_TYPES"`); + } +} diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index 6dc3e85fc8..a8c6ac61f3 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -61,6 +61,60 @@ function compileQuery(q: Q): string { } } +const fileTypes = { + image: [ + 'image/webp', + 'image/png', + 'image/jpeg', + 'image/avif', + 'image/apng', + 'image/gif', + ], + video: [ + 'video/mp4', + 'video/webm', + 'video/mpeg', + 'video/x-m4v', + ], + audio: [ + 'audio/mpeg', + 'audio/flac', + 'audio/wav', + 'audio/aac', + 'audio/webm', + 'audio/opus', + 'audio/ogg', + 'audio/x-m4a', + 'audio/mod', + 'audio/s3m', + 'audio/xm', + 'audio/it', + 'audio/x-mod', + 'audio/x-s3m', + 'audio/x-xm', + 'audio/x-it', + ], + // Keep in sync with frontend-shared/js/const.ts + module: [ + 'audio/mod', + 'audio/x-mod', + 'audio/s3m', + 'audio/x-s3m', + 'audio/xm', + 'audio/x-xm', + 'audio/it', + 'audio/x-it', + ], + flash: [ + 'application/x-shockwave-flash', + 'application/vnd.adobe.flash.movie', + ], +}; + +// Make sure to regenerate misskey-js and check search.note.vue after changing these +export const fileTypeCategories = ['image', 'video', 'audio', 'module', 'flash'] as const; +export type FileTypeCategory = typeof fileTypeCategories[number]; + @Injectable() export class SearchService { private readonly meilisearchIndexScope: 'local' | 'global' | string[] = 'local'; @@ -163,7 +217,7 @@ export class SearchService { userId?: MiNote['userId'] | null; channelId?: MiNote['channelId'] | null; host?: string | null; - filetype?: string | null; + filetype?: FileTypeCategory | null; order?: string | null; disableMeili?: boolean | null; }, pagination: { @@ -188,42 +242,8 @@ export class SearchService { } } if (opts.filetype) { - if (opts.filetype === 'image') { - filter.qs.push({ op: 'or', qs: [ - { op: '=', k: 'attachedFileTypes', v: 'image/webp' }, - { op: '=', k: 'attachedFileTypes', v: 'image/png' }, - { op: '=', k: 'attachedFileTypes', v: 'image/jpeg' }, - { op: '=', k: 'attachedFileTypes', v: 'image/avif' }, - { op: '=', k: 'attachedFileTypes', v: 'image/apng' }, - { op: '=', k: 'attachedFileTypes', v: 'image/gif' }, - ] }); - } else if (opts.filetype === 'video') { - filter.qs.push({ op: 'or', qs: [ - { op: '=', k: 'attachedFileTypes', v: 'video/mp4' }, - { op: '=', k: 'attachedFileTypes', v: 'video/webm' }, - { op: '=', k: 'attachedFileTypes', v: 'video/mpeg' }, - { op: '=', k: 'attachedFileTypes', v: 'video/x-m4v' }, - ] }); - } else if (opts.filetype === 'audio') { - filter.qs.push({ op: 'or', qs: [ - { op: '=', k: 'attachedFileTypes', v: 'audio/mpeg' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/flac' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/wav' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/aac' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/webm' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/opus' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/ogg' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/x-m4a' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/mod' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/s3m' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/xm' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/it' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/x-mod' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/x-s3m' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/x-xm' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/x-it' }, - ] }); - } + const filters = fileTypes[opts.filetype].map(mime => ({ op: '=' as const, k: 'attachedFileTypes', v: mime })); + filter.qs.push({ op: 'or', qs: filters }); } const res = await this.meilisearchNoteIndex!.search(q, { sort: [`createdAt:${opts.order ? opts.order : 'desc'}`], @@ -274,18 +294,7 @@ export class SearchService { } if (opts.filetype) { - /* this is very ugly, but the "correct" solution would - be `and exists (select 1 from - unnest(note."attachedFileTypes") x(t) where t like - :type)` and I can't find a way to get TypeORM to - generate that; this hack works because `~*` is - "regexp match, ignoring case" and the stringified - version of an array of varchars (which is what - `attachedFileTypes` is) looks like `{foo,bar}`, so - we're looking for opts.filetype as the first half of - a MIME type, either at start of the array (after the - `{`) or later (after a `,`) */ - query.andWhere(`note."attachedFileTypes"::varchar ~* :type`, { type: `[{,]${opts.filetype}/` }); + query.andWhere('note."attachedFileTypes" && :types', { types: fileTypes[opts.filetype] }); } this.queryService.generateVisibilityQuery(query, me); diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 408e023ff7..8b5265e8fe 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -143,6 +143,7 @@ export class MiNote { }) public fileIds: MiDriveFile['id'][]; + @Index('IDX_NOTE_ATTACHED_FILE_TYPES', { synchronize: false }) @Column('varchar', { length: 256, array: true, default: '{}', }) diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts index eca55cd085..f46f4d2adb 100644 --- a/packages/backend/src/server/api/endpoints/notes/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { SearchService } from '@/core/SearchService.js'; +import { fileTypeCategories, SearchService } from '@/core/SearchService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; @@ -52,7 +52,11 @@ export const paramDef = { type: 'string', description: 'The local host is represented with `.`.', }, - filetype: { type: 'string', nullable: true }, + filetype: { + type: 'string', + nullable: true, + enum: fileTypeCategories, + }, userId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, channelId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, order: { type: 'string' }, diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue index d64537d289..e080aea064 100644 --- a/packages/frontend/src/pages/search.note.vue +++ b/packages/frontend/src/pages/search.note.vue @@ -22,13 +22,15 @@ SPDX-License-Identifier: AGPL-3.0-only - Sort by newest to oldest + {{ i18n.ts._noteSearch.newestToOldest }} - - - - - + + + + + + + @@ -97,7 +99,7 @@ const notePagination = ref(); const user = ref(null); const hostInput = ref(toRef(props, 'host').value); const order = ref(false); -const filetype = ref(null); +const filetype = ref<'image' | 'video' | 'audio' | 'module' | 'flash' | null>(null); const noteSearchableScope = instance.noteSearchableScope ?? 'local'; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 5d252fc030..816641bf76 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -25029,7 +25029,8 @@ export type operations = { offset?: number; /** @description The local host is represented with `.`. */ host?: string; - filetype?: string | null; + /** @enum {string|null} */ + filetype?: 'image' | 'video' | 'audio' | 'module' | 'flash'; /** * Format: misskey:id * @default null diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index 57889ec995..8118227ce1 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -458,3 +458,14 @@ genKeys: "Generate Keys" _genKeysDialog: text: "Are you sure that you want to generate new keys? This will stop push notifications for all users who have already enabled them." title: "Generate new keys" + +_noteSearch: + newestToOldest: "Sort by newest to oldest" + fileType: "File Type" + _fileType: + none: "None" + image: "Images" + video: "Videos" + audio: "Audio" + module: "Module" + flash: "Flash" -- 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') 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') 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') 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') 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 17ec2df3e1ae3e5b0d8be0778b59ae399b940eab Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 16 Feb 2025 01:12:28 -0500 Subject: increase rate limit on server-info to avoid errors and blank graphs --- packages/backend/src/server/api/endpoints/server-info.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts index e765163f8e..528de76707 100644 --- a/packages/backend/src/server/api/endpoints/server-info.ts +++ b/packages/backend/src/server/api/endpoints/server-info.ts @@ -64,10 +64,11 @@ export const meta = { }, }, - // 2 calls per second + // 24 calls, then 7 per second-ish (1 for each type of server info graph) limit: { - duration: 1000, - max: 2, + max: 24, + dripSize: 7, + dripRate: 900, }, } as const; -- 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') 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