diff --git a/locales/index.d.ts b/locales/index.d.ts index 0a9d09cfe3..fba8794289 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -12072,6 +12072,38 @@ export interface Locale extends ILocale { * Approvals */ "approvals": string; + /** + * Emoji suggestions + */ + "emojiSuggestions": string; + /** + * Suggest an emoji + */ + "suggestEmoji": string; + /** + * Propose a custom emoji for this server. Your image remains in your Drive while moderators review it. + */ + "emojiSuggestionDescription": string; + /** + * There are no pending emoji suggestions. + */ + "emojiSuggestionNoPending": string; + /** + * Proposed by + */ + "emojiSuggestionProposedBy": string; + /** + * Accept :{name}: and add it to this server? + */ + "emojiSuggestionConfirmAccept": ParameterizedString<"name">; + /** + * Reject the suggestion for :{name}:? The image will remain in the proposer's Drive. + */ + "emojiSuggestionConfirmReject": ParameterizedString<"name">; + /** + * Cancel your suggestion for :{name}:? The image will remain in your Drive. + */ + "emojiSuggestionConfirmCancel": ParameterizedString<"name">; /** * Open remote profile */ diff --git a/packages/backend/migration/1786507200000-EmojiSuggestion.js b/packages/backend/migration/1786507200000-EmojiSuggestion.js new file mode 100644 index 0000000000..2a72035f1a --- /dev/null +++ b/packages/backend/migration/1786507200000-EmojiSuggestion.js @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +export class EmojiSuggestion1786507200000 { + name = 'EmojiSuggestion1786507200000'; + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "emoji_suggestion" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "fileId" character varying(32) NOT NULL, "name" character varying(128) NOT NULL, "category" character varying(128), "aliases" character varying(128) array NOT NULL DEFAULT '{}', "license" character varying(1024), "localOnly" boolean NOT NULL DEFAULT false, "isSensitive" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_emoji_suggestion" PRIMARY KEY ("id"), CONSTRAINT "FK_emoji_suggestion_user" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_emoji_suggestion_file" FOREIGN KEY ("fileId") REFERENCES "drive_file"("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); + await queryRunner.query(`CREATE INDEX "IDX_emoji_suggestion_user" ON "emoji_suggestion" ("userId")`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_emoji_suggestion_file" ON "emoji_suggestion" ("fileId")`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_emoji_suggestion_name" ON "emoji_suggestion" ("name")`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`DROP TABLE "emoji_suggestion"`); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 80d4b32432..768fe909ba 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -33,6 +33,7 @@ import { AchievementService } from './AchievementService.js'; import { AvatarDecorationService } from './AvatarDecorationService.js'; import { CaptchaService } from './CaptchaService.js'; import { CustomEmojiService } from './CustomEmojiService.js'; +import { EmojiSuggestionService } from './EmojiSuggestionService.js'; import { DeleteAccountService } from './DeleteAccountService.js'; import { DownloadService } from './DownloadService.js'; import { DriveService } from './DriveService.js'; @@ -115,6 +116,7 @@ import { ClipEntityService } from './entities/ClipEntityService.js'; import { DriveFileEntityService } from './entities/DriveFileEntityService.js'; import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js'; import { EmojiEntityService } from './entities/EmojiEntityService.js'; +import { EmojiSuggestionEntityService } from './entities/EmojiSuggestionEntityService.js'; import { FollowingEntityService } from './entities/FollowingEntityService.js'; import { FollowRequestEntityService } from './entities/FollowRequestEntityService.js'; import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js'; @@ -175,6 +177,7 @@ const $AchievementService: Provider = { provide: 'AchievementService', useExisti const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService }; const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService }; +const $EmojiSuggestionService: Provider = { provide: 'EmojiSuggestionService', useExisting: EmojiSuggestionService }; const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useExisting: DeleteAccountService }; const $DownloadService: Provider = { provide: 'DownloadService', useExisting: DownloadService }; const $DriveService: Provider = { provide: 'DriveService', useExisting: DriveService }; @@ -269,6 +272,7 @@ const $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService }; const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService }; const $EmojiEntityService: Provider = { provide: 'EmojiEntityService', useExisting: EmojiEntityService }; +const $EmojiSuggestionEntityService: Provider = { provide: 'EmojiSuggestionEntityService', useExisting: EmojiSuggestionEntityService }; const $FollowingEntityService: Provider = { provide: 'FollowingEntityService', useExisting: FollowingEntityService }; const $FollowRequestEntityService: Provider = { provide: 'FollowRequestEntityService', useExisting: FollowRequestEntityService }; const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService', useExisting: GalleryLikeEntityService }; @@ -339,6 +343,7 @@ const $Imports = [ AvatarDecorationService, CaptchaService, CustomEmojiService, + EmojiSuggestionService, DeleteAccountService, DownloadService, DriveService, @@ -430,6 +435,7 @@ const $Imports = [ DriveFileEntityService, DriveFolderEntityService, EmojiEntityService, + EmojiSuggestionEntityService, FollowingEntityService, FollowRequestEntityService, GalleryLikeEntityService, @@ -494,6 +500,7 @@ const $Imports = [ $AvatarDecorationService, $CaptchaService, $CustomEmojiService, + $EmojiSuggestionService, $DeleteAccountService, $DownloadService, $DriveService, @@ -588,6 +595,7 @@ const $Imports = [ $DriveFileEntityService, $DriveFolderEntityService, $EmojiEntityService, + $EmojiSuggestionEntityService, $FollowingEntityService, $FollowRequestEntityService, $GalleryLikeEntityService, @@ -654,6 +662,7 @@ const $Imports = [ AvatarDecorationService, CaptchaService, CustomEmojiService, + EmojiSuggestionService, DeleteAccountService, DownloadService, DriveService, @@ -744,6 +753,7 @@ const $Imports = [ DriveFileEntityService, DriveFolderEntityService, EmojiEntityService, + EmojiSuggestionEntityService, FollowingEntityService, FollowRequestEntityService, GalleryLikeEntityService, @@ -808,6 +818,7 @@ const $Imports = [ $AvatarDecorationService, $CaptchaService, $CustomEmojiService, + $EmojiSuggestionService, $DeleteAccountService, $DownloadService, $DriveService, @@ -900,6 +911,7 @@ const $Imports = [ $DriveFileEntityService, $DriveFolderEntityService, $EmojiEntityService, + $EmojiSuggestionEntityService, $FollowingEntityService, $FollowRequestEntityService, $GalleryLikeEntityService, diff --git a/packages/backend/src/core/EmojiSuggestionService.ts b/packages/backend/src/core/EmojiSuggestionService.ts new file mode 100644 index 0000000000..39a615e627 --- /dev/null +++ b/packages/backend/src/core/EmojiSuggestionService.ts @@ -0,0 +1,242 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { + DriveFilesRepository, + EmojisRepository, + EmojiSuggestionsRepository, + MiDriveFile, + MiEmoji, + MiEmojiSuggestion, + MiUser, +} from '@/models/_.js'; +import { FILE_TYPE_IMAGE } from '@/const.js'; +import { bindThis } from '@/decorators.js'; +import { IdService } from '@/core/IdService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { DriveService } from '@/core/DriveService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; +import type Logger from '@/logger.js'; + +export const MAX_PENDING_EMOJI_SUGGESTIONS = 20; + +export type EmojiSuggestionError = + | 'duplicateName' + | 'duplicateSuggestion' + | 'noSuchFile' + | 'noSuchSuggestion' + | 'tooManyPendingSuggestions' + | 'unsupportedFileType'; + +export type EmojiSuggestionResult = + | { ok: true, value: T } + | { ok: false, reason: EmojiSuggestionError }; + +export type CreateEmojiSuggestionOptions = { + name: string; + fileId: string; + category: string | null; + aliases: string[]; + license: string | null; + localOnly: boolean; + isSensitive: boolean; +}; + +@Injectable() +export class EmojiSuggestionService { + private readonly logger: Logger; + + constructor( + @Inject(DI.emojiSuggestionsRepository) + private readonly emojiSuggestionsRepository: EmojiSuggestionsRepository, + + @Inject(DI.driveFilesRepository) + private readonly driveFilesRepository: DriveFilesRepository, + + @Inject(DI.emojisRepository) + private readonly emojisRepository: EmojisRepository, + + private readonly customEmojiService: CustomEmojiService, + private readonly driveService: DriveService, + private readonly idService: IdService, + loggerService: LoggerService, + ) { + this.logger = loggerService.getLogger('emoji-suggestion'); + } + + @bindThis + public async create( + options: CreateEmojiSuggestionOptions, + user: MiUser, + ): Promise> { + const name = options.name.normalize('NFC'); + const file = await this.driveFilesRepository.findOneBy({ + id: options.fileId, + userId: user.id, + }); + if (file == null) return { ok: false, reason: 'noSuchFile' }; + if (!FILE_TYPE_IMAGE.includes(file.type)) return { ok: false, reason: 'unsupportedFileType' }; + if (await this.customEmojiService.checkDuplicate(name)) return { ok: false, reason: 'duplicateName' }; + + const [pendingCount, duplicateSuggestion] = await Promise.all([ + this.emojiSuggestionsRepository.countBy({ userId: user.id }), + this.emojiSuggestionsRepository.exists({ + where: [ + { userId: user.id, name }, + { fileId: file.id }, + ], + }), + ]); + if (pendingCount >= MAX_PENDING_EMOJI_SUGGESTIONS) return { ok: false, reason: 'tooManyPendingSuggestions' }; + if (duplicateSuggestion) return { ok: false, reason: 'duplicateSuggestion' }; + + let suggestion: MiEmojiSuggestion; + try { + suggestion = await this.emojiSuggestionsRepository.insertOne({ + id: this.idService.gen(), + userId: user.id, + fileId: file.id, + name, + category: options.category?.normalize('NFC') ?? null, + aliases: options.aliases.map(alias => alias.normalize('NFC')), + license: options.license, + localOnly: options.localOnly, + isSensitive: options.isSensitive, + }, { + relations: { + file: true, + user: true, + }, + }); + } catch (error) { + // The preflight check gives a useful early response, while the unique + // constraints close the race between simultaneous submissions. + if (isDuplicateKeyValueError(error)) return { ok: false, reason: 'duplicateSuggestion' }; + throw error; + } + + return { ok: true, value: suggestion }; + } + + @bindThis + public async accept( + suggestionId: string, + moderator: MiUser, + ): Promise> { + const suggestion = await this.emojiSuggestionsRepository.findOne({ + where: { id: suggestionId }, + relations: { file: true }, + }); + if (suggestion == null) return { ok: false, reason: 'noSuchSuggestion' }; + + // Consume the suggestion before doing any work. This makes acceptance, + // cancellation, rejection, and another acceptance mutually exclusive. + const claimed = await this.emojiSuggestionsRepository.delete({ + id: suggestion.id, + userId: suggestion.userId, + fileId: suggestion.fileId, + }); + if (claimed.affected !== 1) return { ok: false, reason: 'noSuchSuggestion' }; + + const restoreSuggestion = async () => { + await this.emojiSuggestionsRepository.insert({ + id: suggestion.id, + userId: suggestion.userId, + fileId: suggestion.fileId, + name: suggestion.name, + category: suggestion.category, + aliases: suggestion.aliases, + license: suggestion.license, + localOnly: suggestion.localOnly, + isSensitive: suggestion.isSensitive, + }); + }; + + let isDuplicate: boolean; + try { + isDuplicate = await this.customEmojiService.checkDuplicate(suggestion.name); + } catch (error) { + await restoreSuggestion(); + throw error; + } + if (isDuplicate) { + await restoreSuggestion(); + return { ok: false, reason: 'duplicateName' }; + } + + let emojiFile: MiDriveFile | undefined; + try { + // A suggestion may reference an avatar, banner, page image, or other + // shared Drive row. Give the emoji its own system-owned copy so its + // lifecycle cannot mutate or delete the proposer's original file. + emojiFile = await this.driveService.uploadFromUrl({ + url: suggestion.file.url, + user: null, + force: true, + }); + + const emoji = await this.customEmojiService.createEmoji({ + originalUrl: emojiFile.url, + publicUrl: emojiFile.webpublicUrl ?? emojiFile.url, + name: suggestion.name, + category: suggestion.category, + aliases: suggestion.aliases, + host: null, + license: suggestion.license, + isSensitive: suggestion.isSensitive, + localOnly: suggestion.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], + }, { moderator }); + + return { ok: true, value: emoji }; + } catch (error) { + if (emojiFile != null) { + // createEmoji inserts before publishing and moderation logging. If a + // post-insert hook failed, the durable result is still an acceptance. + const insertedEmoji = await this.emojisRepository.findOneBy({ + name: suggestion.name, + host: IsNull(), + originalUrl: emojiFile.url, + }); + if (insertedEmoji != null) return { ok: true, value: insertedEmoji }; + + try { + await this.driveService.deleteFile(emojiFile, false, moderator); + } catch (cleanupError) { + this.logger.error(`Failed to delete the emoji file copy after acceptance failed: ${renderInlineError(cleanupError)}`); + } + } + + try { + await restoreSuggestion(); + } catch (restoreError) { + this.logger.error(`Failed to restore emoji suggestion ${suggestion.id} after acceptance failed: ${renderInlineError(restoreError)}`); + } + + if (isDuplicateKeyValueError(error)) return { ok: false, reason: 'duplicateName' }; + throw error; + } + } + + @bindThis + public async cancel(suggestionId: string, user: MiUser): Promise { + const result = await this.emojiSuggestionsRepository.delete({ + id: suggestionId, + userId: user.id, + }); + return result.affected === 1; + } + + @bindThis + public async reject(suggestionId: string): Promise { + const result = await this.emojiSuggestionsRepository.delete(suggestionId); + return result.affected === 1; + } +} diff --git a/packages/backend/src/core/entities/EmojiSuggestionEntityService.ts b/packages/backend/src/core/entities/EmojiSuggestionEntityService.ts new file mode 100644 index 0000000000..cec74c766c --- /dev/null +++ b/packages/backend/src/core/entities/EmojiSuggestionEntityService.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import type { MiEmojiSuggestion, MiUser } from '@/models/_.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { bindThis } from '@/decorators.js'; +import { IdService } from '@/core/IdService.js'; +import type { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class EmojiSuggestionEntityService implements OnModuleInit { + private userEntityService: UserEntityService; + + constructor( + private readonly moduleRef: ModuleRef, + private readonly idService: IdService, + ) { + } + + @bindThis + public onModuleInit() { + this.userEntityService = this.moduleRef.get('UserEntityService'); + } + + @bindThis + public async pack( + suggestion: MiEmojiSuggestion, + me: MiUser, + ): Promise> { + return { + id: suggestion.id, + createdAt: this.idService.parse(suggestion.id).date.toISOString(), + name: suggestion.name, + category: suggestion.category, + aliases: suggestion.aliases, + license: suggestion.license, + localOnly: suggestion.localOnly, + isSensitive: suggestion.isSensitive, + url: suggestion.file.webpublicUrl ?? suggestion.file.url, + user: await this.userEntityService.pack(suggestion.user, me, { schema: 'UserLite' }), + }; + } + + @bindThis + public async packMany( + suggestions: MiEmojiSuggestion[], + me: MiUser, + ): Promise[]> { + return await Promise.all(suggestions.map(suggestion => this.pack(suggestion, me))); + } +} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index dcabf48ee9..fff2a3aaa5 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -54,6 +54,7 @@ export const DI = { followRequestsRepository: Symbol('followRequestsRepository'), instancesRepository: Symbol('instancesRepository'), emojisRepository: Symbol('emojisRepository'), + emojiSuggestionsRepository: Symbol('emojiSuggestionsRepository'), driveFilesRepository: Symbol('driveFilesRepository'), driveFoldersRepository: Symbol('driveFoldersRepository'), metasRepository: Symbol('metasRepository'), diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 8c9749ba2b..dfc13fe42a 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -45,6 +45,7 @@ import { packedEmojiDetailedSchema, packedEmojiSimpleSchema, } from '@/models/json-schema/emoji.js'; +import { packedEmojiSuggestionSchema } from '@/models/json-schema/emoji-suggestion.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'; @@ -119,6 +120,7 @@ export const refs = { EmojiSimple: packedEmojiSimpleSchema, EmojiDetailed: packedEmojiDetailedSchema, EmojiDetailedAdmin: packedEmojiDetailedAdminSchema, + EmojiSuggestion: packedEmojiSuggestionSchema, Flash: packedFlashSchema, Signin: packedSigninSchema, RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema, diff --git a/packages/backend/src/models/EmojiSuggestion.ts b/packages/backend/src/models/EmojiSuggestion.ts new file mode 100644 index 0000000000..ab67a4cbae --- /dev/null +++ b/packages/backend/src/models/EmojiSuggestion.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { MiDriveFile } from '@/models/DriveFile.js'; +import { MiUser } from '@/models/User.js'; +import { id } from './util/id.js'; + +@Entity('emoji_suggestion') +export class MiEmojiSuggestion { + @PrimaryColumn(id()) + public id: string; + + @Index('IDX_emoji_suggestion_user') + @Column(id()) + public userId: MiUser['id']; + + @ManyToOne(() => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser; + + @Index('IDX_emoji_suggestion_file', { unique: true }) + @Column(id()) + public fileId: MiDriveFile['id']; + + @ManyToOne(() => MiDriveFile, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public file: MiDriveFile; + + @Index('IDX_emoji_suggestion_name', { unique: true }) + @Column('varchar', { + length: 128, + }) + public name: string; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public category: string | null; + + @Column('varchar', { + array: true, + length: 128, + default: '{}', + }) + public aliases: string[]; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public license: string | null; + + @Column('boolean', { + default: false, + }) + public localOnly: boolean; + + @Column('boolean', { + default: false, + }) + public isSensitive: boolean; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index e114654c77..b4e27f8e31 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -27,6 +27,7 @@ import { MiDriveFile, MiDriveFolder, MiEmoji, + MiEmojiSuggestion, MiFlash, MiFlashLike, MiFollowing, @@ -271,6 +272,12 @@ const $emojisRepository: Provider = { inject: [DI.db], }; +const $emojiSuggestionsRepository: Provider = { + provide: DI.emojiSuggestionsRepository, + useFactory: (db: DataSource) => db.getRepository(MiEmojiSuggestion).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $driveFilesRepository: Provider = { provide: DI.driveFilesRepository, useFactory: (db: DataSource) => db.getRepository(MiDriveFile).extend(miRepository as MiRepository), @@ -603,6 +610,7 @@ export const repositoryProviders: Provider[] = [ $followRequestsRepository, $instancesRepository, $emojisRepository, + $emojiSuggestionsRepository, $driveFilesRepository, $driveFoldersRepository, $metasRepository, diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 7fc5878672..c3dd66f0bb 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -29,6 +29,7 @@ import { MiClipNote } from '@/models/ClipNote.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiEmoji } from '@/models/Emoji.js'; +import { MiEmojiSuggestion } from '@/models/EmojiSuggestion.js'; import { MiFlash } from '@/models/Flash.js'; import { MiFlashLike } from '@/models/FlashLike.js'; import { MiFollowing } from '@/models/Following.js'; @@ -170,6 +171,7 @@ export { MiDriveFile, MiDriveFolder, MiEmoji, + MiEmojiSuggestion, MiFollowing, MiFollowRequest, MiGalleryLike, @@ -250,6 +252,7 @@ export type ClipFavoritesRepository = Repository & MiRepository< export type DriveFilesRepository = Repository & MiRepository; export type DriveFoldersRepository = Repository & MiRepository; export type EmojisRepository = Repository & MiRepository; +export type EmojiSuggestionsRepository = Repository & MiRepository; export type FollowingsRepository = Repository & MiRepository; export type FollowRequestsRepository = Repository & MiRepository; export type GalleryLikesRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/emoji-suggestion.ts b/packages/backend/src/models/json-schema/emoji-suggestion.ts new file mode 100644 index 0000000000..e7368f0fbd --- /dev/null +++ b/packages/backend/src/models/json-schema/emoji-suggestion.ts @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedEmojiSuggestionSchema = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'id', + optional: false, + nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, + nullable: false, + }, + name: { + type: 'string', + optional: false, + nullable: false, + }, + category: { + type: 'string', + optional: false, + nullable: true, + }, + aliases: { + type: 'array', + optional: false, + nullable: false, + items: { + type: 'string', + optional: false, + nullable: false, + }, + }, + license: { + type: 'string', + optional: false, + nullable: true, + }, + localOnly: { + type: 'boolean', + optional: false, + nullable: false, + }, + isSensitive: { + type: 'boolean', + optional: false, + nullable: false, + }, + url: { + type: 'string', + optional: false, + nullable: false, + }, + user: { + type: 'object', + optional: false, + nullable: false, + ref: 'UserLite', + }, + }, +} as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 1997182624..99162286aa 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -33,6 +33,7 @@ import { MiClipFavorite } from '@/models/ClipFavorite.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiEmoji } from '@/models/Emoji.js'; +import { MiEmojiSuggestion } from '@/models/EmojiSuggestion.js'; import { MiFollowing } from '@/models/Following.js'; import { MiFollowRequest } from '@/models/FollowRequest.js'; import { MiGalleryLike } from '@/models/GalleryLike.js'; @@ -261,6 +262,7 @@ export const entities = [ MiPoll, MiPollVote, MiEmoji, + MiEmojiSuggestion, MiHashtag, MiSwSubscription, MiSystemAccount, diff --git a/packages/backend/src/server/api/emoji-suggestion.ts b/packages/backend/src/server/api/emoji-suggestion.ts new file mode 100644 index 0000000000..6c0a4cb4eb --- /dev/null +++ b/packages/backend/src/server/api/emoji-suggestion.ts @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { EmojiSuggestionError } from '@/core/EmojiSuggestionService.js'; + +export const emojiSuggestionErrors = { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'd7c980e6-221b-4b60-a50e-a6f77ac8f3f9', + }, + unsupportedFileType: { + message: 'Unsupported file type.', + code: 'UNSUPPORTED_FILE_TYPE', + id: '63a9ff92-f992-4bc9-9d43-9fe1ea0ea3ec', + }, + duplicateName: { + message: 'An emoji with this name already exists.', + code: 'DUPLICATE_NAME', + id: '90a1f02e-45f9-4d19-a43e-a0e634b094ea', + }, + duplicateSuggestion: { + message: 'A matching emoji suggestion is already pending.', + code: 'DUPLICATE_SUGGESTION', + id: '7bdf044b-3753-484b-8d51-acd0ea2df264', + }, + tooManyPendingSuggestions: { + message: 'You have too many pending emoji suggestions.', + code: 'TOO_MANY_PENDING_SUGGESTIONS', + id: '16049091-cb93-45e6-a843-b9d9171bc6e5', + }, + noSuchSuggestion: { + message: 'No such emoji suggestion.', + code: 'NO_SUCH_EMOJI_SUGGESTION', + id: 'd023907b-288c-4d29-9e63-743b6ccc8320', + }, +} as const satisfies Record; + +export const emojiSuggestionParamDef = { + type: 'object', + properties: { + name: { type: 'string', maxLength: 128, pattern: '^[\\p{Letter}\\p{Number}\\p{Mark}_+-]+$' }, + fileId: { type: 'string', format: 'misskey:id' }, + category: { type: 'string', nullable: true, maxLength: 128 }, + aliases: { + type: 'array', + maxItems: 100, + items: { type: 'string', maxLength: 128 }, + }, + license: { type: 'string', nullable: true, maxLength: 1024 }, + isSensitive: { type: 'boolean' }, + localOnly: { type: 'boolean' }, + }, + required: ['name', 'fileId'], +} as const; + +export const emojiSuggestionListParamDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: [], +} as const; + +export const emojiSuggestionListResponse = { + type: 'array', + optional: false, + nullable: false, + items: { + type: 'object', + optional: false, + nullable: false, + ref: 'EmojiSuggestion', + }, +} as const; diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 11bc981984..9d6502502d 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -57,6 +57,9 @@ export * as 'admin/emoji/set-aliases-bulk' from './endpoints/admin/emoji/set-ali export * as 'admin/emoji/set-category-bulk' from './endpoints/admin/emoji/set-category-bulk.js'; export * as 'admin/emoji/set-license-bulk' from './endpoints/admin/emoji/set-license-bulk.js'; export * as 'admin/emoji/update' from './endpoints/admin/emoji/update.js'; +export * as 'admin/emoji-suggestions/accept' from './endpoints/admin/emoji-suggestions/accept.js'; +export * as 'admin/emoji-suggestions/list' from './endpoints/admin/emoji-suggestions/list.js'; +export * as 'admin/emoji-suggestions/reject' from './endpoints/admin/emoji-suggestions/reject.js'; export * as 'admin/federation/delete-all-files' from './endpoints/admin/federation/delete-all-files.js'; export * as 'admin/federation/refresh-remote-instance-metadata' from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; export * as 'admin/federation/remove-all-following' from './endpoints/admin/federation/remove-all-following.js'; @@ -460,4 +463,7 @@ export * as 'chat/history' from './endpoints/chat/history.js'; export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js'; export * as 'admin/antennas/global' from './endpoints/admin/antennas/global.js'; export * as 'drive/files/generate-alt-text' from './endpoints/drive/files/generate-alt.js'; +export * as 'emoji-suggestions/cancel' from './endpoints/emoji-suggestions/cancel.js'; +export * as 'emoji-suggestions/create' from './endpoints/emoji-suggestions/create.js'; +export * as 'emoji-suggestions/list' from './endpoints/emoji-suggestions/list.js'; export * as 'top-posts' from './endpoints/top-posts.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/accept.ts b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/accept.ts new file mode 100644 index 0000000000..e7a6da6e84 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/accept.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { EmojiSuggestionService } from '@/core/EmojiSuggestionService.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { emojiSuggestionErrors } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['admin', 'emoji-suggestions'], + requireCredential: true, + requireModerator: true, + kind: 'write:admin:emoji', + errors: emojiSuggestionErrors, + res: { + type: 'object', + ref: 'EmojiDetailed', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + suggestionId: { type: 'string', format: 'misskey:id' }, + }, + required: ['suggestionId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private readonly emojiSuggestionService: EmojiSuggestionService, + private readonly emojiEntityService: EmojiEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const result = await this.emojiSuggestionService.accept(ps.suggestionId, me); + if (!result.ok) throw new ApiError(meta.errors[result.reason]); + + return await this.emojiEntityService.packDetailed(result.value); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/list.ts new file mode 100644 index 0000000000..bd7011dc41 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/list.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { EmojiSuggestionsRepository } from '@/models/_.js'; +import { EmojiSuggestionEntityService } from '@/core/entities/EmojiSuggestionEntityService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { emojiSuggestionListParamDef, emojiSuggestionListResponse } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['admin', 'emoji-suggestions'], + requireCredential: true, + requireModerator: true, + kind: 'read:admin:emoji', + res: emojiSuggestionListResponse, +} as const; + +export const paramDef = emojiSuggestionListParamDef; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.emojiSuggestionsRepository) + private readonly emojiSuggestionsRepository: EmojiSuggestionsRepository, + private readonly emojiSuggestionEntityService: EmojiSuggestionEntityService, + private readonly queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery( + this.emojiSuggestionsRepository.createQueryBuilder('suggestion'), + ps.sinceId, + ps.untilId, + ) + .innerJoinAndSelect('suggestion.file', 'file') + .innerJoinAndSelect('suggestion.user', 'user'); + + const suggestions = await query.limit(ps.limit).getMany(); + return await this.emojiSuggestionEntityService.packMany(suggestions, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/reject.ts b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/reject.ts new file mode 100644 index 0000000000..895d487c9f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/reject.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { EmojiSuggestionService } from '@/core/EmojiSuggestionService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { emojiSuggestionErrors } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['admin', 'emoji-suggestions'], + requireCredential: true, + requireModerator: true, + kind: 'write:admin:emoji', + errors: emojiSuggestionErrors, +} as const; + +export const paramDef = { + type: 'object', + properties: { + suggestionId: { type: 'string', format: 'misskey:id' }, + }, + required: ['suggestionId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor(private readonly emojiSuggestionService: EmojiSuggestionService) { + super(meta, paramDef, async (ps) => { + if (!await this.emojiSuggestionService.reject(ps.suggestionId)) { + throw new ApiError(meta.errors.noSuchSuggestion); + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/emoji-suggestions/cancel.ts b/packages/backend/src/server/api/endpoints/emoji-suggestions/cancel.ts new file mode 100644 index 0000000000..2ab87b2e18 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/emoji-suggestions/cancel.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { EmojiSuggestionService } from '@/core/EmojiSuggestionService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { emojiSuggestionErrors } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['emoji-suggestions'], + requireCredential: true, + kind: 'write:drive', + errors: emojiSuggestionErrors, +} as const; + +export const paramDef = { + type: 'object', + properties: { + suggestionId: { type: 'string', format: 'misskey:id' }, + }, + required: ['suggestionId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor(private readonly emojiSuggestionService: EmojiSuggestionService) { + super(meta, paramDef, async (ps, me) => { + if (!await this.emojiSuggestionService.cancel(ps.suggestionId, me)) { + throw new ApiError(meta.errors.noSuchSuggestion); + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/emoji-suggestions/create.ts b/packages/backend/src/server/api/endpoints/emoji-suggestions/create.ts new file mode 100644 index 0000000000..02b69ee9d9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/emoji-suggestions/create.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { EmojiSuggestionService } from '@/core/EmojiSuggestionService.js'; +import { EmojiSuggestionEntityService } from '@/core/entities/EmojiSuggestionEntityService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { emojiSuggestionErrors, emojiSuggestionParamDef } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['emoji-suggestions'], + requireCredential: true, + kind: 'write:drive', + limit: { + duration: 1000 * 60, + max: 10, + }, + errors: emojiSuggestionErrors, + res: { + type: 'object', + ref: 'EmojiSuggestion', + }, +} as const; + +export const paramDef = emojiSuggestionParamDef; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private readonly emojiSuggestionService: EmojiSuggestionService, + private readonly emojiSuggestionEntityService: EmojiSuggestionEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const result = await this.emojiSuggestionService.create({ + name: ps.name, + fileId: ps.fileId, + category: ps.category ?? null, + aliases: ps.aliases ?? [], + license: ps.license ?? null, + isSensitive: ps.isSensitive ?? false, + localOnly: ps.localOnly ?? false, + }, me); + if (!result.ok) throw new ApiError(meta.errors[result.reason]); + + return await this.emojiSuggestionEntityService.pack(result.value, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/emoji-suggestions/list.ts b/packages/backend/src/server/api/endpoints/emoji-suggestions/list.ts new file mode 100644 index 0000000000..4e43f955a8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/emoji-suggestions/list.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { EmojiSuggestionsRepository } from '@/models/_.js'; +import { EmojiSuggestionEntityService } from '@/core/entities/EmojiSuggestionEntityService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { emojiSuggestionListParamDef, emojiSuggestionListResponse } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['emoji-suggestions'], + requireCredential: true, + kind: 'read:drive', + res: emojiSuggestionListResponse, +} as const; + +export const paramDef = emojiSuggestionListParamDef; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.emojiSuggestionsRepository) + private readonly emojiSuggestionsRepository: EmojiSuggestionsRepository, + private readonly emojiSuggestionEntityService: EmojiSuggestionEntityService, + private readonly queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery( + this.emojiSuggestionsRepository.createQueryBuilder('suggestion'), + ps.sinceId, + ps.untilId, + ) + .innerJoinAndSelect('suggestion.file', 'file') + .innerJoinAndSelect('suggestion.user', 'user') + .andWhere('suggestion.userId = :userId', { userId: me.id }); + + const suggestions = await query.limit(ps.limit).getMany(); + return await this.emojiSuggestionEntityService.packMany(suggestions, me); + }); + } +} diff --git a/packages/backend/test/unit/EmojiSuggestionService.ts b/packages/backend/test/unit/EmojiSuggestionService.ts new file mode 100644 index 0000000000..126706798e --- /dev/null +++ b/packages/backend/test/unit/EmojiSuggestionService.ts @@ -0,0 +1,260 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { jest } from '@jest/globals'; +import { IsNull, QueryFailedError } from 'typeorm'; +import { EmojiSuggestionService } from '@/core/EmojiSuggestionService.js'; + +describe('EmojiSuggestionService', () => { + const user = { id: '9abc000001', username: 'proposer', host: null }; + const moderator = { id: '9abc000002', username: 'moderator', host: null }; + const file = { + id: '9abc000003', + userId: user.id, + type: 'image/png', + url: 'https://example.test/original.png', + webpublicUrl: 'https://example.test/public.webp', + }; + const emojiFile = { + ...file, + id: '9abc000006', + userId: null, + url: 'https://example.test/emoji-original.png', + webpublicUrl: 'https://example.test/emoji-public.webp', + }; + const suggestion = { + id: '9abc000004', + userId: user.id, + fileId: file.id, + file, + name: 'party_blob', + category: 'Blobs', + aliases: ['party'], + license: 'CC0', + localOnly: false, + isSensitive: false, + }; + + function createService(overrides?: { + suggestions?: Record; + drive?: Record; + emojis?: Record; + customEmoji?: Record; + driveService?: Record; + }) { + const suggestions = { + countBy: jest.fn(async () => 0), + exists: jest.fn(async () => false), + insertOne: jest.fn(async () => ({ ...suggestion, user })), + insert: jest.fn(async () => ({ identifiers: [{ id: suggestion.id }] })), + findOne: jest.fn(async () => suggestion), + delete: jest.fn(async () => ({ affected: 1 })), + ...overrides?.suggestions, + }; + const drive = { + findOneBy: jest.fn(async () => file), + ...overrides?.drive, + }; + const emoji = { id: '9abc000005', name: suggestion.name, originalUrl: emojiFile.url }; + const emojis = { + findOneBy: jest.fn(async () => null), + ...overrides?.emojis, + }; + const customEmoji = { + checkDuplicate: jest.fn(async () => false), + createEmoji: jest.fn(async () => emoji), + ...overrides?.customEmoji, + }; + const driveService = { + uploadFromUrl: jest.fn(async () => emojiFile), + deleteFile: jest.fn(async () => undefined), + ...overrides?.driveService, + }; + const logger = { + error: jest.fn(), + }; + const service = new EmojiSuggestionService( + suggestions as any, + drive as any, + emojis as any, + customEmoji as any, + driveService as any, + { gen: jest.fn(() => suggestion.id) } as any, + { getLogger: jest.fn(() => logger) } as any, + ); + + return { service, suggestions, drive, emojis, customEmoji, driveService, logger, emoji }; + } + + test('submission only accepts an image owned by the proposer', async () => { + const { service, suggestions, drive } = createService({ + drive: { findOneBy: jest.fn(async () => null) }, + }); + + await expect(service.create({ + name: suggestion.name, + fileId: file.id, + category: null, + aliases: [], + license: null, + localOnly: false, + isSensitive: false, + }, user as any)).resolves.toEqual({ ok: false, reason: 'noSuchFile' }); + + expect(drive.findOneBy).toHaveBeenCalledWith({ id: file.id, userId: user.id }); + expect(suggestions.insertOne).not.toHaveBeenCalled(); + }); + + test('a simultaneous duplicate submission returns a domain error', async () => { + const driverError = Object.assign(new Error('duplicate key'), { code: '23505' }); + const duplicateError = new QueryFailedError('', [], driverError); + const { service } = createService({ + suggestions: { insertOne: jest.fn(async () => { throw duplicateError; }) }, + }); + + await expect(service.create({ + name: suggestion.name, + fileId: file.id, + category: null, + aliases: [], + license: null, + localOnly: false, + isSensitive: false, + }, user as any)).resolves.toEqual({ ok: false, reason: 'duplicateSuggestion' }); + }); + + test('acceptance consumes the suggestion and creates the emoji from a dedicated file', async () => { + const { service, suggestions, drive, customEmoji, driveService, emoji } = createService(); + + await expect(service.accept(suggestion.id, moderator as any)).resolves.toEqual({ ok: true, value: emoji }); + + expect(suggestions.delete).toHaveBeenCalledWith({ + id: suggestion.id, + userId: user.id, + fileId: file.id, + }); + expect(driveService.uploadFromUrl).toHaveBeenCalledWith({ + url: file.url, + user: null, + force: true, + }); + expect(customEmoji.createEmoji).toHaveBeenCalledWith({ + originalUrl: emojiFile.url, + publicUrl: emojiFile.webpublicUrl, + name: suggestion.name, + category: suggestion.category, + aliases: suggestion.aliases, + host: null, + license: suggestion.license, + isSensitive: suggestion.isSensitive, + localOnly: suggestion.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], + }, { moderator }); + expect(drive.findOneBy).not.toHaveBeenCalled(); + expect(suggestions.insert).not.toHaveBeenCalled(); + }); + + test('a lost suggestion claim cannot create a second emoji', async () => { + const { service, customEmoji, suggestions, driveService } = createService({ + suggestions: { delete: jest.fn(async () => ({ affected: 0 })) }, + }); + + await expect(service.accept(suggestion.id, moderator as any)).resolves.toEqual({ ok: false, reason: 'noSuchSuggestion' }); + expect(customEmoji.createEmoji).not.toHaveBeenCalled(); + expect(driveService.uploadFromUrl).not.toHaveBeenCalled(); + expect(suggestions.insert).not.toHaveBeenCalled(); + }); + + test('cancellation cannot succeed after acceptance has consumed the suggestion', async () => { + let pending = true; + let releaseCopy!: () => void; + let markCopyStarted!: () => void; + const copyStarted = new Promise(resolve => { + markCopyStarted = resolve; + }); + const copying = new Promise(resolve => { + releaseCopy = () => resolve(emojiFile); + }); + const { service } = createService({ + suggestions: { + delete: jest.fn(async () => { + if (!pending) return { affected: 0 }; + pending = false; + return { affected: 1 }; + }), + }, + driveService: { + uploadFromUrl: jest.fn(async () => { + markCopyStarted(); + return await copying; + }), + }, + }); + + const accepting = service.accept(suggestion.id, moderator as any); + await copyStarted; + await expect(service.cancel(suggestion.id, user as any)).resolves.toBe(false); + releaseCopy(); + await expect(accepting).resolves.toEqual({ ok: true, value: expect.objectContaining({ id: '9abc000005' }) }); + }); + + test('a failed emoji creation deletes its copy and restores the suggestion', async () => { + const failure = new Error('creation failed'); + const { service, suggestions, driveService } = createService({ + customEmoji: { createEmoji: jest.fn(async () => { throw failure; }) }, + }); + + await expect(service.accept(suggestion.id, moderator as any)).rejects.toBe(failure); + expect(driveService.deleteFile).toHaveBeenCalledWith(emojiFile, false, moderator); + expect(suggestions.insert).toHaveBeenCalledWith({ + id: suggestion.id, + userId: user.id, + fileId: file.id, + name: suggestion.name, + category: suggestion.category, + aliases: suggestion.aliases, + license: suggestion.license, + localOnly: suggestion.localOnly, + isSensitive: suggestion.isSensitive, + }); + }); + + test('cleanup failures do not mask a duplicate-name result', async () => { + const driverError = Object.assign(new Error('duplicate key'), { code: '23505' }); + const duplicateError = new QueryFailedError('', [], driverError); + const deleteFailure = new Error('copy deletion failed'); + const restoreFailure = new Error('suggestion restoration failed'); + const { service, suggestions, driveService, logger } = createService({ + suggestions: { insert: jest.fn(async () => { throw restoreFailure; }) }, + customEmoji: { createEmoji: jest.fn(async () => { throw duplicateError; }) }, + driveService: { deleteFile: jest.fn(async () => { throw deleteFailure; }) }, + }); + + await expect(service.accept(suggestion.id, moderator as any)).resolves.toEqual({ ok: false, reason: 'duplicateName' }); + expect(driveService.deleteFile).toHaveBeenCalledWith(emojiFile, false, moderator); + expect(suggestions.insert).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledTimes(2); + }); + + test('a post-insert hook failure is treated as an accepted suggestion', async () => { + const failure = new Error('broadcast failed'); + const insertedEmoji = { id: '9abc000005', name: suggestion.name, originalUrl: emojiFile.url }; + const { service, suggestions, emojis, driveService } = createService({ + emojis: { findOneBy: jest.fn(async () => insertedEmoji) }, + customEmoji: { createEmoji: jest.fn(async () => { throw failure; }) }, + }); + + await expect(service.accept(suggestion.id, moderator as any)).resolves.toEqual({ ok: true, value: insertedEmoji }); + expect(emojis.findOneBy).toHaveBeenCalledWith({ + name: suggestion.name, + host: IsNull(), + originalUrl: emojiFile.url, + }); + expect(driveService.deleteFile).not.toHaveBeenCalled(); + expect(suggestions.insert).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue index b166dfd940..940e790e4c 100644 --- a/packages/frontend/src/pages/about.emojis.vue +++ b/packages/frontend/src/pages/about.emojis.vue @@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only