diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 7deac4d963..c4f539b03a 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { setImmediate } from 'node:timers/promises'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { In, IsNull } from 'typeorm'; import * as Redis from 'ioredis'; @@ -21,6 +22,53 @@ import { ModerationLogService } from '@/core/ModerationLogService.js'; const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/; +export const fetchEmojisHostTypes = [ + 'local', + 'remote', + 'all', +] as const; +export type FetchEmojisHostTypes = typeof fetchEmojisHostTypes[number]; +export const fetchEmojisSortKeys = [ + 'id', + 'updatedAt', + 'name', + 'host', + 'uri', + 'publicUrl', + 'type', + 'aliases', + 'category', + 'license', + 'isSensitive', + 'localOnly', +] as const; +export type FetchEmojisSortKeys = typeof fetchEmojisSortKeys[number]; +export type FetchEmojisParams = { + query?: { + updatedAtFrom?: string; + updatedAtTo?: string; + name?: string; + host?: string; + uri?: string; + publicUrl?: string; + type?: string; + aliases?: string; + category?: string; + license?: string; + isSensitive?: boolean; + localOnly?: boolean; + hostType?: FetchEmojisHostTypes; + }, + sinceId?: string; + untilId?: string; + limit?: number; + page?: number; + sort?: { + key : FetchEmojisSortKeys; + order : 'ASC' | 'DESC'; + }[] +} + @Injectable() export class CustomEmojiService implements OnApplicationShutdown { private cache: MemoryKVCache; @@ -99,64 +147,6 @@ export class CustomEmojiService implements OnApplicationShutdown { return emoji; } - @bindThis - public async addBulk( - params: { - driveFile: MiDriveFile; - name: string; - category: string | null; - aliases: string[]; - host: string | null; - license: string | null; - isSensitive: boolean; - localOnly: boolean; - roleIdsThatCanBeUsedThisEmojiAsReaction: MiRole['id'][]; - }[], - moderator?: MiUser, - ): Promise { - const emojis = await this.emojisRepository - .insert( - params.map(it => ({ - id: this.idService.gen(), - updatedAt: new Date(), - name: it.name, - category: it.category, - host: it.host, - aliases: it.aliases, - originalUrl: it.driveFile.url, - publicUrl: it.driveFile.webpublicUrl ?? it.driveFile.url, - type: it.driveFile.webpublicType ?? it.driveFile.type, - license: it.license, - isSensitive: it.isSensitive, - localOnly: it.localOnly, - roleIdsThatCanBeUsedThisEmojiAsReaction: it.roleIdsThatCanBeUsedThisEmojiAsReaction, - })), - ) - .then(x => this.emojisRepository.createQueryBuilder('emoji').whereInIds(x.identifiers).getMany()); - - const localEmojis = emojis.filter(it => it.host == null); - if (localEmojis.length > 0) { - this.localEmojisCache.refresh(); - - this.emojiEntityService.packDetailedMany(localEmojis).then(it => { - for (const emoji of it) { - this.globalEventService.publishBroadcastStream('emojiAdded', { emoji }); - } - }); - - if (moderator) { - for (const emoji of localEmojis) { - this.moderationLogService.log(moderator, 'addCustomEmoji', { - emojiId: emoji.id, - emoji: emoji, - }); - } - } - } - - return emojis; - } - @bindThis public async update(id: MiEmoji['id'], data: { driveFile?: MiDriveFile; @@ -214,103 +204,6 @@ export class CustomEmojiService implements OnApplicationShutdown { } } - @bindThis - public async updateBulk( - params: { - id: MiEmoji['id']; - driveFile?: MiDriveFile; - name?: string; - category?: string | null; - aliases?: string[]; - license?: string | null; - isSensitive?: boolean; - localOnly?: boolean; - roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][]; - }[], - moderator?: MiUser, - ): Promise { - const ids = params.map(it => it.id); - - // IDに対応するものと、新しく設定しようとしている名前と同じ名前を持つレコードをそれぞれ取得する - const [storedEmojis, sameNameEmojis] = await Promise.all([ - this.emojisRepository.createQueryBuilder('emoji') - .whereInIds(ids) - .getMany() - .then(emojis => new Map(emojis.map(it => [it.id, it]))), - this.emojisRepository.createQueryBuilder('emoji') - .where('emoji.name IN (:...names) AND emoji.host IS NULL', { names: params.map(it => it.name) }) - .getMany(), - ]); - - // 新しく設定しようとしている名前と同じ名前を持つ別レコードがある場合、重複とみなしてエラーとする - const alreadyExists = Array.of(); - for (const sameNameEmoji of sameNameEmojis) { - const emoji = storedEmojis.get(sameNameEmoji.id); - if (emoji != null && emoji.id !== sameNameEmoji.id) { - alreadyExists.push(sameNameEmoji.name); - } - } - if (alreadyExists.length > 0) { - throw new Error(`name already exists: ${alreadyExists.join(', ')}`); - } - - for (const emoji of params) { - await this.emojisRepository.update(emoji.id, { - updatedAt: new Date(), - name: emoji.name, - category: emoji.category, - aliases: emoji.aliases, - license: emoji.license, - isSensitive: emoji.isSensitive, - localOnly: emoji.localOnly, - originalUrl: emoji.driveFile != null ? emoji.driveFile.url : undefined, - publicUrl: emoji.driveFile != null ? (emoji.driveFile.webpublicUrl ?? emoji.driveFile.url) : undefined, - type: emoji.driveFile != null ? (emoji.driveFile.webpublicType ?? emoji.driveFile.type) : undefined, - roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined, - }); - } - - this.localEmojisCache.refresh(); - - // 名前が変わっていないものはそのまま更新としてイベント発信 - const updateEmojis = params.filter(it => storedEmojis.get(it.id)?.name === it.name); - if (updateEmojis.length > 0) { - const packedList = await this.emojiEntityService.packDetailedMany(updateEmojis); - this.globalEventService.publishBroadcastStream('emojiUpdated', { - emojis: packedList, - }); - } - - // 名前が変わったものは削除・追加としてイベント発信 - const nameChangeEmojis = params.filter(it => storedEmojis.get(it.id)?.name !== it.name); - if (nameChangeEmojis.length > 0) { - const packedList = await this.emojiEntityService.packDetailedMany(nameChangeEmojis); - this.globalEventService.publishBroadcastStream('emojiDeleted', { - emojis: packedList, - }); - - for (const packed of packedList) { - this.globalEventService.publishBroadcastStream('emojiAdded', { - emoji: packed, - }); - } - } - - if (moderator) { - const updatedEmojis = await this.emojisRepository.createQueryBuilder('emoji') - .whereInIds(storedEmojis.keys()) - .getMany() - .then(it => new Map(it.map(it => [it.id, it]))); - for (const emoji of storedEmojis.values()) { - this.moderationLogService.log(moderator, 'updateCustomEmoji', { - emojiId: emoji.id, - before: emoji, - after: updatedEmojis.get(emoji.id), - }); - } - } - } - @bindThis public async addAliasesBulk(ids: MiEmoji['id'][], aliases: string[]) { const emojis = await this.emojisRepository.findBy({ @@ -545,6 +438,265 @@ export class CustomEmojiService implements OnApplicationShutdown { return this.emojisRepository.findOneBy({ id }); } + @bindThis + public async fetchEmojis(params?: FetchEmojisParams) { + const builder = this.emojisRepository.createQueryBuilder('emoji'); + if (params?.query) { + const q = params.query; + if (q.updatedAtFrom) { + // noIndexScan + builder.andWhere('emoji.updatedAt >= :updateAtFrom', { updateAtFrom: q.updatedAtFrom }); + } + if (q.updatedAtTo) { + // noIndexScan + builder.andWhere('emoji.updatedAt <= :updateAtTo', { updateAtTo: q.updatedAtTo }); + } + if (q.name) { + builder.andWhere('emoji.name LIKE :name', { name: `%${q.name}%` }); + } + if (q.hostType === 'local') { + builder.andWhere('emoji.host LIKE :host', { host: `%${q.host}%` }); + } else { + if (q.host) { + // noIndexScan + builder.andWhere('emoji.host LIKE :host', { host: `%${q.host}%` }); + } else { + builder.andWhere('emoji.host IS NOT NULL'); + } + } + if (q.uri) { + // noIndexScan + builder.andWhere('emoji.uri LIKE :uri', { url: `%${q.uri}%` }); + } + if (q.publicUrl) { + // noIndexScan + builder.andWhere('emoji.publicUrl LIKE :publicUrl', { publicUrl: `%${q.publicUrl}%` }); + } + if (q.type) { + // noIndexScan + builder.andWhere('emoji.type LIKE :type', { type: `%${q.type}%` }); + } + if (q.aliases) { + // noIndexScan + builder.andWhere('emoji.aliases ANY(:aliases)', { aliases: q.aliases }); + } + if (q.category) { + // noIndexScan + builder.andWhere('emoji.category LIKE :category', { category: `%${q.category}%` }); + } + if (q.license) { + // noIndexScan + builder.andWhere('emoji.license LIKE :license', { license: `%${q.license}%` }); + } + if (q.isSensitive != null) { + // noIndexScan + builder.andWhere('emoji.isSensitive = :isSensitive', { isSensitive: q.isSensitive }); + } + if (q.localOnly != null) { + // noIndexScan + builder.andWhere('emoji.localOnly = :localOnly', { localOnly: q.localOnly }); + } + } + + if (params?.sinceId) { + builder.andWhere('emoji.id > :sinceId', { sinceId: params.sinceId }); + } + if (params?.untilId) { + builder.andWhere('emoji.id < :untilId', { untilId: params.untilId }); + } + + if (params?.sort) { + for (const sort of params.sort) { + builder.addOrderBy(`emoji.${sort.key}`, sort.order); + } + } else { + builder.addOrderBy('emoji.id', 'DESC'); + } + + const limit = params?.limit ?? 10; + if (params?.page) { + builder.skip((params.page - 1) * limit); + } + + builder.take(limit); + + const [emojis, count] = await builder.getManyAndCount(); + + return { + emojis, + count: (count > limit ? emojis.length : count), + allCount: count, + allPages: Math.ceil(count / limit), + }; + } + + @bindThis + public async addBulk( + params: { + driveFile: MiDriveFile; + name: string; + category: string | null; + aliases: string[]; + host: string | null; + license: string | null; + isSensitive: boolean; + localOnly: boolean; + roleIdsThatCanBeUsedThisEmojiAsReaction: MiRole['id'][]; + }[], + moderator?: MiUser, + ): Promise { + const emojis = await this.emojisRepository + .insert( + params.map(it => ({ + id: this.idService.gen(), + updatedAt: new Date(), + name: it.name, + category: it.category, + host: it.host, + aliases: it.aliases, + originalUrl: it.driveFile.url, + publicUrl: it.driveFile.webpublicUrl ?? it.driveFile.url, + type: it.driveFile.webpublicType ?? it.driveFile.type, + license: it.license, + isSensitive: it.isSensitive, + localOnly: it.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: it.roleIdsThatCanBeUsedThisEmojiAsReaction, + })), + ) + .then(x => this.emojisRepository.createQueryBuilder('emoji') + .where({ id: In(x.identifiers) }) + .getMany(), + ); + + // 以降は絵文字登録による副作用なのでリクエストから切り離して実行 + + // noinspection ES6MissingAwait + setImmediate(async () => { + const localEmojis = emojis.filter(it => it.host == null); + if (localEmojis.length > 0) { + await this.localEmojisCache.refresh(); + + const packedEmojis = await this.emojiEntityService.packDetailedMany(localEmojis); + for (const emoji of packedEmojis) { + this.globalEventService.publishBroadcastStream('emojiAdded', { emoji }); + } + + if (moderator) { + for (const emoji of localEmojis) { + await this.moderationLogService.log(moderator, 'addCustomEmoji', { + emojiId: emoji.id, + emoji: emoji, + }); + } + } + } + }); + + return emojis; + } + + @bindThis + public async updateBulk( + params: { + id: MiEmoji['id']; + driveFile?: MiDriveFile; + name?: string; + category?: string | null; + aliases?: string[]; + license?: string | null; + isSensitive?: boolean; + localOnly?: boolean; + roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][]; + }[], + moderator?: MiUser, + ): Promise { + const ids = params.map(it => it.id); + + // IDに対応するものと、新しく設定しようとしている名前と同じ名前を持つレコードをそれぞれ取得する + const [storedEmojis, sameNameEmojis] = await Promise.all([ + this.emojisRepository.createQueryBuilder('emoji') + .whereInIds(ids) + .getMany() + .then(emojis => new Map(emojis.map(it => [it.id, it]))), + this.emojisRepository.createQueryBuilder('emoji') + .where('emoji.name IN (:...names) AND emoji.host IS NULL', { names: params.map(it => it.name) }) + .getMany(), + ]); + + // 新しく設定しようとしている名前と同じ名前を持つ別レコードがある場合、重複とみなしてエラーとする + const alreadyExists = Array.of(); + for (const sameNameEmoji of sameNameEmojis) { + const emoji = storedEmojis.get(sameNameEmoji.id); + if (emoji != null && emoji.id !== sameNameEmoji.id) { + alreadyExists.push(sameNameEmoji.name); + } + } + if (alreadyExists.length > 0) { + throw new Error(`name already exists: ${alreadyExists.join(', ')}`); + } + + for (const emoji of params) { + await this.emojisRepository.update(emoji.id, { + updatedAt: new Date(), + name: emoji.name, + category: emoji.category, + aliases: emoji.aliases, + license: emoji.license, + isSensitive: emoji.isSensitive, + localOnly: emoji.localOnly, + originalUrl: emoji.driveFile != null ? emoji.driveFile.url : undefined, + publicUrl: emoji.driveFile != null ? (emoji.driveFile.webpublicUrl ?? emoji.driveFile.url) : undefined, + type: emoji.driveFile != null ? (emoji.driveFile.webpublicType ?? emoji.driveFile.type) : undefined, + roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined, + }); + } + + // 以降は絵文字更新による副作用なのでリクエストから切り離して実行 + + // noinspection ES6MissingAwait + setImmediate(async () => { + await this.localEmojisCache.refresh(); + + // 名前が変わっていないものはそのまま更新としてイベント発信 + const updateEmojis = params.filter(it => storedEmojis.get(it.id)?.name === it.name); + if (updateEmojis.length > 0) { + const packedList = await this.emojiEntityService.packDetailedMany(updateEmojis); + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: packedList, + }); + } + + // 名前が変わったものは削除・追加としてイベント発信 + const nameChangeEmojis = params.filter(it => storedEmojis.get(it.id)?.name !== it.name); + if (nameChangeEmojis.length > 0) { + const packedList = await this.emojiEntityService.packDetailedMany(nameChangeEmojis); + this.globalEventService.publishBroadcastStream('emojiDeleted', { + emojis: packedList, + }); + + for (const packed of packedList) { + this.globalEventService.publishBroadcastStream('emojiAdded', { + emoji: packed, + }); + } + } + + if (moderator) { + const updatedEmojis = await this.emojisRepository.createQueryBuilder('emoji') + .whereInIds(storedEmojis.keys()) + .getMany() + .then(it => new Map(it.map(it => [it.id, it]))); + for (const emoji of storedEmojis.values()) { + await this.moderationLogService.log(moderator, 'updateCustomEmoji', { + emojiId: emoji.id, + before: emoji, + after: updatedEmojis.get(emoji.id), + }); + } + } + }); + } + @bindThis public dispose(): void { this.cache.dispose(); diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index 663cef38ca..cb12543d1e 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -70,5 +70,35 @@ export class EmojiEntityService { ): Promise[]> { return Promise.all(emojis.map(x => this.packDetailed(x))); } + + @bindThis + public async packDetailedAdmin( + src: MiEmoji['id'] | MiEmoji, + ): Promise> { + const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src }); + + return { + id: emoji.id, + updatedAt: emoji.updatedAt?.toISOString() ?? null, + name: emoji.name, + host: emoji.host, + uri: emoji.uri, + type: emoji.type, + aliases: emoji.aliases, + category: emoji.category, + publicUrl: emoji.publicUrl, + license: emoji.license, + localOnly: emoji.localOnly, + isSensitive: emoji.isSensitive, + roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction, + }; + } + + @bindThis + public packDetailedAdminMany( + emojis: any[], + ): Promise[]> { + return Promise.all(emojis.map(x => this.packDetailedAdmin(x))); + } } diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index b4f0541712..773a4fea90 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -33,7 +33,11 @@ import { packedClipSchema } from '@/models/json-schema/clip.js'; import { packedFederationInstanceSchema } from '@/models/json-schema/federation-instance.js'; import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js'; -import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; +import { + packedEmojiDetailedAdminSchema, + packedEmojiDetailedSchema, + packedEmojiSimpleSchema, +} from '@/models/json-schema/emoji.js'; import { packedFlashSchema } from '@/models/json-schema/flash.js'; import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; import { packedSigninSchema } from '@/models/json-schema/signin.js'; @@ -75,6 +79,7 @@ export const refs = { GalleryPost: packedGalleryPostSchema, EmojiSimple: packedEmojiSimpleSchema, EmojiDetailed: packedEmojiDetailedSchema, + EmojiDetailedAdmin: packedEmojiDetailedAdminSchema, Flash: packedFlashSchema, Signin: packedSigninSchema, RoleLite: packedRoleLiteSchema, diff --git a/packages/backend/src/models/json-schema/emoji.ts b/packages/backend/src/models/json-schema/emoji.ts index 99a58f8773..2c1e8f8afd 100644 --- a/packages/backend/src/models/json-schema/emoji.ts +++ b/packages/backend/src/models/json-schema/emoji.ts @@ -100,3 +100,74 @@ export const packedEmojiDetailedSchema = { }, }, } as const; + +export const packedEmojiDetailedAdminSchema = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'id', + optional: false, nullable: false, + }, + updatedAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: true, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + host: { + type: 'string', + optional: false, nullable: true, + description: 'The local host is represented with `null`.', + }, + publicUrl: { + type: 'string', + optional: false, nullable: false, + }, + uri: { + type: 'string', + optional: false, nullable: true, + }, + type: { + type: 'string', + optional: false, nullable: true, + }, + aliases: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + format: 'id', + optional: false, nullable: false, + }, + }, + category: { + type: 'string', + optional: false, nullable: true, + }, + license: { + type: 'string', + optional: false, nullable: true, + }, + localOnly: { + type: 'boolean', + optional: false, nullable: false, + }, + isSensitive: { + type: 'boolean', + optional: false, nullable: false, + }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + }, +} as const; diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index e74441834e..03e8e927e8 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -43,6 +43,7 @@ import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-al import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; +import * as ep___admin_emoji_v2_list from './endpoints/admin/emoji/v2/list.js'; import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js'; @@ -414,6 +415,7 @@ const $admin_emoji_setAliasesBulk: Provider = { provide: 'ep:admin/emoji/set-ali const $admin_emoji_setCategoryBulk: Provider = { provide: 'ep:admin/emoji/set-category-bulk', useClass: ep___admin_emoji_setCategoryBulk.default }; const $admin_emoji_setLicenseBulk: Provider = { provide: 'ep:admin/emoji/set-license-bulk', useClass: ep___admin_emoji_setLicenseBulk.default }; const $admin_emoji_update: Provider = { provide: 'ep:admin/emoji/update', useClass: ep___admin_emoji_update.default }; +const $admin_emoji_v2_list: Provider = { provide: 'ep:admin/emoji/v2/list', useClass: ep___admin_emoji_v2_list.default }; const $admin_federation_deleteAllFiles: Provider = { provide: 'ep:admin/federation/delete-all-files', useClass: ep___admin_federation_deleteAllFiles.default }; const $admin_federation_refreshRemoteInstanceMetadata: Provider = { provide: 'ep:admin/federation/refresh-remote-instance-metadata', useClass: ep___admin_federation_refreshRemoteInstanceMetadata.default }; const $admin_federation_removeAllFollowing: Provider = { provide: 'ep:admin/federation/remove-all-following', useClass: ep___admin_federation_removeAllFollowing.default }; @@ -789,6 +791,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_emoji_setCategoryBulk, $admin_emoji_setLicenseBulk, $admin_emoji_update, + $admin_emoji_v2_list, $admin_federation_deleteAllFiles, $admin_federation_refreshRemoteInstanceMetadata, $admin_federation_removeAllFollowing, @@ -1158,6 +1161,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_emoji_setCategoryBulk, $admin_emoji_setLicenseBulk, $admin_emoji_update, + $admin_emoji_v2_list, $admin_federation_deleteAllFiles, $admin_federation_refreshRemoteInstanceMetadata, $admin_federation_removeAllFollowing, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 4a88216d06..44bb4e1321 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -44,6 +44,7 @@ import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-al import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; +import * as ep___admin_emoji_v2_list from './endpoints/admin/emoji/v2/list.js'; import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js'; @@ -413,6 +414,7 @@ const eps = [ ['admin/emoji/set-category-bulk', ep___admin_emoji_setCategoryBulk], ['admin/emoji/set-license-bulk', ep___admin_emoji_setLicenseBulk], ['admin/emoji/update', ep___admin_emoji_update], + ['admin/emoji/v2/list', ep___admin_emoji_v2_list], ['admin/federation/delete-all-files', ep___admin_federation_deleteAllFiles], ['admin/federation/refresh-remote-instance-metadata', ep___admin_federation_refreshRemoteInstanceMetadata], ['admin/federation/remove-all-following', ep___admin_federation_removeAllFollowing], diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/v2/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/v2/list.ts new file mode 100644 index 0000000000..4fc707f1b2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/v2/list.ts @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { CustomEmojiService, FetchEmojisParams } from '@/core/CustomEmojiService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canManageCustomEmojis', + kind: 'read:admin:emoji', + + res: { + type: 'object', + properties: { + emojis: { + type: 'array', + items: { + type: 'object', + ref: 'EmojiDetailedAdmin', + }, + }, + count: { type: 'integer' }, + allCount: { type: 'integer' }, + allPages: { type: 'integer' }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + query: { + type: 'object', + nullable: true, + properties: { + updatedAtFrom: { type: 'string' }, + updatedAtTo: { type: 'string' }, + name: { type: 'string' }, + host: { type: 'string' }, + uri: { type: 'string' }, + publicUrl: { type: 'string' }, + type: { type: 'string' }, + aliases: { type: 'string' }, + category: { type: 'string' }, + license: { type: 'string' }, + isSensitive: { type: 'boolean' }, + localOnly: { type: 'boolean' }, + hostType: { type: 'string', enum: ['local', 'remote', 'all'], default: 'all' }, + }, + }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + page: { type: 'integer' }, + sort: { + type: 'array', + items: { + type: 'object', + properties: { + key: { + type: 'string', + enum: [ + 'id', + 'updatedAt', + 'name', + 'host', + 'uri', + 'publicUrl', + 'type', + 'aliases', + 'category', + 'license', + 'isSensitive', + 'localOnly', + ], + default: 'id', + }, + order: { + type: 'string', + enum: ['ASC', 'DESC'], + default: 'DESC', + }, + }, + required: ['key', 'order'], + }, + }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private customEmojiService: CustomEmojiService, + private emojiEntityService: EmojiEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const params: FetchEmojisParams = {}; + + if (ps.query) { + params.query = { + updatedAtFrom: ps.query.updatedAtFrom, + updatedAtTo: ps.query.updatedAtTo, + name: ps.query.name, + host: ps.query.host, + uri: ps.query.uri, + publicUrl: ps.query.publicUrl, + type: ps.query.type, + aliases: ps.query.aliases, + category: ps.query.category, + license: ps.query.license, + isSensitive: ps.query.isSensitive, + localOnly: ps.query.localOnly, + hostType: ps.query.hostType, + }; + } + + params.sinceId = ps.sinceId; + params.untilId = ps.untilId; + params.limit = ps.limit; + params.page = ps.page; + params.sort = ps.sort?.map(it => ({ + key: it.key, + order: it.order, + })); + + const result = await this.customEmojiService.fetchEmojis(params); + + return { + emojis: await this.emojiEntityService.packDetailedAdminMany(result.emojis), + count: result.count, + allCount: result.allCount, + allPages: result.allPages, + }; + }); + } +} diff --git a/packages/frontend/src/pages/admin/custom-emojis-grid.impl.ts b/packages/frontend/src/pages/admin/custom-emojis-grid.impl.ts index 89e2dee93c..1ee40e4038 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-grid.impl.ts +++ b/packages/frontend/src/pages/admin/custom-emojis-grid.impl.ts @@ -22,12 +22,12 @@ export type GridItem = { roleIdsThatCanBeUsedThisEmojiAsReaction: string; } -export function fromEmojiDetailed(it: Misskey.entities.EmojiDetailed): GridItem { +export function fromEmojiDetailedAdmin(it: Misskey.entities.EmojiDetailedAdmin): GridItem { return { checked: false, id: it.id, fileId: undefined, - url: it.url, + url: it.publicUrl, name: it.name, host: it.host ?? '', category: it.category ?? '', diff --git a/packages/frontend/src/pages/admin/custom-emojis-grid.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-grid.local.list.vue index 5a33bf3b8b..628fc80d4c 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-grid.local.list.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-grid.local.list.vue @@ -37,7 +37,7 @@ import { computed, ref, toRefs, watch } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; -import { fromEmojiDetailed, GridItem } from '@/pages/admin/custom-emojis-grid.impl.js'; +import { fromEmojiDetailedAdmin, GridItem } from '@/pages/admin/custom-emojis-grid.impl.js'; import MkGrid from '@/components/grid/MkGrid.vue'; import { i18n } from '@/i18n.js'; import MkInput from '@/components/MkInput.vue'; @@ -81,7 +81,7 @@ const emit = defineEmits<{ }>(); const props = defineProps<{ - customEmojis: Misskey.entities.EmojiDetailed[]; + customEmojis: Misskey.entities.EmojiDetailedAdmin[]; }>(); const { customEmojis } = toRefs(props); @@ -287,7 +287,7 @@ function onGridKeyDown(event: GridKeyDownEvent, currentState: GridCurrentState) } function refreshGridItems() { - gridItems.value = customEmojis.value.map(it => fromEmojiDetailed(it)); + gridItems.value = customEmojis.value.map(it => fromEmojiDetailedAdmin(it)); originGridItems.value = JSON.parse(JSON.stringify(gridItems.value)); } diff --git a/packages/frontend/src/pages/admin/custom-emojis-grid.local.vue b/packages/frontend/src/pages/admin/custom-emojis-grid.local.vue index 6638827b8d..8e8bc517a1 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-grid.local.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-grid.local.vue @@ -29,22 +29,14 @@ import XRegisterComponent from '@/pages/admin/custom-emojis-grid.local.register. type PageMode = 'list' | 'register'; -const customEmojis = ref([]); +const customEmojis = ref([]); const modeTab = ref('list'); const query = ref(); async function refreshCustomEmojis(query?: string, sinceId?: string, untilId?: string) { - const emojis = await misskeyApi('admin/emoji/list', { + const emojis = await misskeyApi('admin/emoji/v2/list', { limit: 100, - query: query?.length ? query : undefined, - sinceId, - untilId, - }); - - if (sinceId) { - // 通常はID降順だが、sinceIdを設定すると昇順での並び替えとなるので、逆順にする必要がある - emojis.reverse(); - } + }).then(it => it.emojis); customEmojis.value = emojis; } diff --git a/packages/frontend/src/pages/admin/custom-emojis-grid.remote.vue b/packages/frontend/src/pages/admin/custom-emojis-grid.remote.vue index 0565639358..ed6a499779 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-grid.remote.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-grid.remote.vue @@ -43,7 +43,7 @@ import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkGrid from '@/components/grid/MkGrid.vue'; import { ColumnSetting } from '@/components/grid/column.js'; -import { fromEmojiDetailed, GridItem } from '@/pages/admin/custom-emojis-grid.impl.js'; +import { fromEmojiDetailedAdmin, GridItem } from '@/pages/admin/custom-emojis-grid.impl.js'; import { GridCellContextMenuEvent, GridCellValueChangeEvent, @@ -187,7 +187,7 @@ async function refreshCustomEmojis(query?: string, host?: string, sinceId?: stri customEmojis.value = emojis; console.log(customEmojis.value); - gridItems.value = customEmojis.value.map(it => fromEmojiDetailed(it)); + gridItems.value = customEmojis.value.map(it => fromEmojiDetailedAdmin(it)); } onMounted(async () => { diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 26f100e452..61d9e48ec9 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -169,6 +169,12 @@ type AdminEmojiSetLicenseBulkRequest = operations['admin/emoji/set-license-bulk' // @public (undocumented) type AdminEmojiUpdateRequest = operations['admin/emoji/update']['requestBody']['content']['application/json']; +// @public (undocumented) +type AdminEmojiV2ListRequest = operations['admin/emoji/v2/list']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminEmojiV2ListResponse = operations['admin/emoji/v2/list']['responses']['200']['content']['application/json']; + // @public (undocumented) type AdminFederationDeleteAllFilesRequest = operations['admin/federation/delete-all-files']['requestBody']['content']['application/json']; @@ -996,6 +1002,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']; @@ -1133,6 +1142,8 @@ declare namespace entities { AdminEmojiSetCategoryBulkRequest, AdminEmojiSetLicenseBulkRequest, AdminEmojiUpdateRequest, + AdminEmojiV2ListRequest, + AdminEmojiV2ListResponse, AdminFederationDeleteAllFilesRequest, AdminFederationRefreshRemoteInstanceMetadataRequest, AdminFederationRemoveAllFollowingRequest, @@ -1668,6 +1679,7 @@ declare namespace entities { GalleryPost, EmojiSimple, EmojiDetailed, + EmojiDetailedAdmin, Flash, Signin, RoleLite, diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 3c52e022d1..a87ea470ae 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -1,6 +1,6 @@ /* * version: 2024.2.0-beta.7 - * generatedAt: 2024-02-04T07:16:03.625Z + * generatedAt: 2024-02-05T06:03:40.656Z */ import type { SwitchCaseResponseType } from '../api.js'; @@ -416,6 +416,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 599eae4f33..b6f154730e 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -1,6 +1,6 @@ /* * version: 2024.2.0-beta.7 - * generatedAt: 2024-02-04T07:16:03.623Z + * generatedAt: 2024-02-05T06:03:40.654Z */ import type { @@ -54,6 +54,8 @@ import type { AdminEmojiSetCategoryBulkRequest, AdminEmojiSetLicenseBulkRequest, AdminEmojiUpdateRequest, + AdminEmojiV2ListRequest, + AdminEmojiV2ListResponse, AdminFederationDeleteAllFilesRequest, AdminFederationRefreshRemoteInstanceMetadataRequest, AdminFederationRemoveAllFollowingRequest, @@ -596,6 +598,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 }; + 'admin/emoji/v2/list': { req: AdminEmojiV2ListRequest; res: AdminEmojiV2ListResponse }; '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 086a9f0613..4765e4d827 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -1,6 +1,6 @@ /* * version: 2024.2.0-beta.7 - * generatedAt: 2024-02-04T07:16:03.621Z + * generatedAt: 2024-02-05T06:03:40.652Z */ import { operations } from './types.js'; @@ -56,6 +56,8 @@ export type AdminEmojiSetAliasesBulkRequest = operations['admin/emoji/set-aliase 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 AdminEmojiV2ListRequest = operations['admin/emoji/v2/list']['requestBody']['content']['application/json']; +export type AdminEmojiV2ListResponse = operations['admin/emoji/v2/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 e3845e1b98..83fdfb8c7c 100644 --- a/packages/misskey-js/src/autogen/models.ts +++ b/packages/misskey-js/src/autogen/models.ts @@ -1,6 +1,6 @@ /* * version: 2024.2.0-beta.7 - * generatedAt: 2024-02-04T07:16:03.620Z + * generatedAt: 2024-02-05T06:03:40.651Z */ import { components } from './types.js'; @@ -37,6 +37,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 RoleLite = components['schemas']['RoleLite']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 60dfb47c16..4c454286eb 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -3,7 +3,7 @@ /* * version: 2024.2.0-beta.7 - * generatedAt: 2024-02-04T07:16:03.539Z + * generatedAt: 2024-02-05T06:03:40.574Z */ /** @@ -351,6 +351,15 @@ export type paths = { */ post: operations['admin/emoji/update']; }; + '/admin/emoji/v2/list': { + /** + * admin/emoji/v2/list + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:emoji* + */ + post: operations['admin/emoji/v2/list']; + }; '/admin/federation/delete-all-files': { /** * admin/federation/delete-all-files @@ -4281,6 +4290,24 @@ 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; + uri: string | null; + type: string | null; + aliases: string[]; + category: string | null; + license: string | null; + localOnly: boolean; + isSensitive: boolean; + roleIdsThatCanBeUsedThisEmojiAsReaction: string[]; + }; Flash: { /** * Format: id @@ -6839,6 +6866,103 @@ export type operations = { }; }; }; + /** + * admin/emoji/v2/list + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:emoji* + */ + 'admin/emoji/v2/list': { + requestBody: { + content: { + 'application/json': { + query?: ({ + /** Format: date-time */ + updatedAtFrom?: string; + /** Format: date-time */ + updatedAtTo?: string; + name?: string; + host?: string; + uri?: string; + publicUrl?: string; + type?: string; + aliases?: string; + category?: string; + license?: string; + isSensitive?: boolean; + localOnly?: boolean; + /** + * @default all + * @enum {string} + */ + hostType?: 'local' | 'remote' | 'all'; + }) | null; + /** Format: misskey:id */ + sinceId?: string; + /** Format: misskey:id */ + untilId?: string; + /** @default 10 */ + limit?: number; + page?: number; + sort?: ({ + /** + * @default id + * @enum {string} + */ + key: 'id' | 'updatedAt' | 'name' | 'host' | 'uri' | 'publicUrl' | 'type' | 'aliases' | 'category' | 'license' | 'isSensitive' | 'localOnly'; + /** + * @default DESC + * @enum {string} + */ + order: 'ASC' | 'DESC'; + })[]; + }; + }; + }; + 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.