Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions locales/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12096,6 +12096,18 @@ export interface Locale extends ILocale {
* Propose a custom emoji for this server. Your image remains in your Drive while moderators review it.
*/
"emojiSuggestionDescription": string;
/**
* Browse remote emojis
*/
"browseRemoteEmojis": string;
/**
* Search emojis cached from other servers.
*/
"browseRemoteEmojisDescription": string;
/**
* Select an emoji to propose it for this server. A copy will be added to your Drive for moderators to review.
*/
"remoteEmojiSuggestionDescription": string;
/**
* There are no pending emoji suggestions.
*/
Expand Down
35 changes: 26 additions & 9 deletions packages/backend/src/core/DriveService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ type UploadFromUrlArgs = {
isForImport?: boolean;
};

type AddFileResult = {
file: MiDriveFile;
isNew: boolean;
};

@Injectable()
export class DriveService {
public static NoSuchFolderError = class extends Error {};
Expand Down Expand Up @@ -492,7 +497,11 @@ export class DriveService {
*
*/
@bindThis
public async addFile({
public async addFile(args: AddFileArgs): Promise<MiDriveFile> {
return (await this.addFileWithResult(args)).file;
}

private async addFileWithResult({
user,
path,
name = null,
Expand All @@ -506,7 +515,7 @@ export class DriveService {
requestIp = null,
requestHeaders = null,
ext = null,
}: AddFileArgs): Promise<MiDriveFile> {
}: AddFileArgs): Promise<AddFileResult> {
const userRoleNSFW = user && (await this.roleService.getUserPolicies(user.id)).alwaysMarkNsfw;
const info = await this.fileInfoService.getFileInfo(path);

Expand Down Expand Up @@ -535,7 +544,7 @@ export class DriveService {
await this.driveFilesRepository.update({ id: matched.id }, { isSensitive: true });
matched.isSensitive = true;
}
return matched;
return { file: matched, isNew: false };
}
}

Expand Down Expand Up @@ -645,6 +654,8 @@ export class DriveService {
file.uri = uri;
}

let isNew = true;

if (isLink) {
try {
file.size = 0;
Expand All @@ -657,6 +668,7 @@ export class DriveService {
} catch (err) {
// duplicate key error (when already registered)
if (isDuplicateKeyValueError(err)) {
isNew = false;
this.registerLogger.debug(`already registered ${file.uri}`);

file = await this.driveFilesRepository.findOneBy({
Expand Down Expand Up @@ -692,7 +704,7 @@ export class DriveService {
}
}

return file;
return { file, isNew };
}

@bindThis
Expand Down Expand Up @@ -875,7 +887,12 @@ export class DriveService {
}

@bindThis
public async uploadFromUrl({
public async uploadFromUrl(args: UploadFromUrlArgs): Promise<MiDriveFile> {
return (await this.uploadFromUrlWithResult(args)).file;
}

@bindThis
public async uploadFromUrlWithResult({
url,
user,
folderId = null,
Expand All @@ -887,7 +904,7 @@ export class DriveService {
requestIp = null,
requestHeaders = null,
isForImport = false,
}: UploadFromUrlArgs): Promise<MiDriveFile> {
}: UploadFromUrlArgs): Promise<AddFileResult> {
// Create temp file
const [path, cleanup] = await createTemp();

Expand All @@ -904,9 +921,9 @@ export class DriveService {
comment = null;
}

const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders });
this.downloaderLogger.debug(`Upload succeeded: created file ${driveFile.id}`);
return driveFile!;
const result = await this.addFileWithResult({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders });
this.downloaderLogger.debug(`Upload succeeded: created file ${result.file.id}`);
return result;
} catch (err) {
this.downloaderLogger.error(`Failed to create drive file from ${url}: ${renderInlineError(err)}`);
throw err;
Expand Down
87 changes: 75 additions & 12 deletions packages/backend/src/core/EmojiSuggestionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type EmojiSuggestionError =
| 'duplicateName'
| 'duplicateSuggestion'
| 'noSuchFile'
| 'noSuchRemoteEmoji'
| 'noSuchSuggestion'
| 'tooManyPendingSuggestions'
| 'unsupportedFileType';
Expand All @@ -42,16 +43,20 @@ export type EmojiSuggestionResult<T> =
| { ok: true, value: T }
| { ok: false, reason: EmojiSuggestionError };

export type CreateEmojiSuggestionOptions = {
type CreateEmojiSuggestionBaseOptions = {
name: string;
fileId: string;
category: string | null;
aliases: string[];
license: string | null;
localOnly: boolean;
isSensitive: boolean;
};

export type CreateEmojiSuggestionOptions = CreateEmojiSuggestionBaseOptions & (
| { fileId: string; remoteEmojiId?: never }
| { fileId?: never; remoteEmojiId: string }
);

@Injectable()
export class EmojiSuggestionService {
private readonly logger: Logger;
Expand Down Expand Up @@ -83,26 +88,78 @@ export class EmojiSuggestionService {
user: MiUser,
): Promise<EmojiSuggestionResult<MiEmojiSuggestion>> {
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' };
let file: MiDriveFile | null = null;
let remoteSource: { url: string; isSensitive: boolean } | null = null;
let remoteFileIsNew = false;

const cleanupRemoteFile = async () => {
if (!remoteFileIsNew || file == null) return;

await this.driveService.deleteFileSync(file);
Comment thread
PrivateGER marked this conversation as resolved.
remoteFileIsNew = false;
};

const [pendingCount, duplicateSuggestion] = await Promise.all([
if (options.fileId != null) {
file = await this.driveFilesRepository.findOneBy({
id: options.fileId,
userId: user.id,
});
if (file == null) return { ok: false, reason: 'noSuchFile' };
} else {
const emoji = await this.customEmojiService.emojisByIdCache.fetchMaybe(options.remoteEmojiId);
if (emoji == null || emoji.host == null) return { ok: false, reason: 'noSuchRemoteEmoji' };
remoteSource = {
url: emoji.originalUrl,
isSensitive: emoji.isSensitive,
};
}

const [isDuplicateName, pendingCount, duplicateSuggestion] = await Promise.all([
this.customEmojiService.checkDuplicate(name),
this.emojiSuggestionsRepository.countBy({ userId: user.id }),
this.emojiSuggestionsRepository.exists({
where: [
{ userId: user.id, name },
where: file == null ? { name } : [
{ name },
{ fileId: file.id },
],
}),
]);
if (isDuplicateName) return { ok: false, reason: 'duplicateName' };
if (pendingCount >= MAX_PENDING_EMOJI_SUGGESTIONS) return { ok: false, reason: 'tooManyPendingSuggestions' };
if (duplicateSuggestion) return { ok: false, reason: 'duplicateSuggestion' };

if (remoteSource != null) {
const upload = await this.driveService.uploadFromUrlWithResult({
url: remoteSource.url,
user,
sensitive: remoteSource.isSensitive,
});
file = upload.file;
remoteFileIsNew = upload.isNew;
}

if (file == null) return { ok: false, reason: 'noSuchFile' };
let duplicateFile: boolean;
try {
duplicateFile = await this.emojiSuggestionsRepository.exists({ where: { fileId: file.id } });
} catch (error) {
try {
await cleanupRemoteFile();
} catch (cleanupError) {
throw new AggregateError([error, cleanupError]);
}
throw error;
}

if (duplicateFile) {
await cleanupRemoteFile();
return { ok: false, reason: 'duplicateSuggestion' };
}
if (!FILE_TYPE_IMAGE.includes(file.type)) {
await cleanupRemoteFile();
return { ok: false, reason: 'unsupportedFileType' };
}

let suggestion: MiEmojiSuggestion;
try {
suggestion = await this.emojiSuggestionsRepository.insertOne({
Expand All @@ -122,6 +179,12 @@ export class EmojiSuggestionService {
},
});
} catch (error) {
try {
await cleanupRemoteFile();
} catch (cleanupError) {
throw new AggregateError([error, cleanupError]);
}

// 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' };
Expand Down
12 changes: 11 additions & 1 deletion packages/backend/src/server/api/emoji-suggestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export const emojiSuggestionErrors = {
code: 'UNSUPPORTED_FILE_TYPE',
id: '63a9ff92-f992-4bc9-9d43-9fe1ea0ea3ec',
},
noSuchRemoteEmoji: {
message: 'No such remote emoji.',
code: 'NO_SUCH_REMOTE_EMOJI',
id: '413b2a5e-c6f5-47cf-b7ad-bb18d5eec9e9',
},
duplicateName: {
message: 'An emoji with this name already exists.',
code: 'DUPLICATE_NAME',
Expand Down Expand Up @@ -47,6 +52,7 @@ export const emojiSuggestionParamDef = {
properties: {
name: { type: 'string', maxLength: 128, pattern: '^[\\p{Letter}\\p{Number}\\p{Mark}_+-]+$' },
fileId: { type: 'string', format: 'misskey:id' },
remoteEmojiId: { type: 'string', format: 'misskey:id' },
category: { type: 'string', nullable: true, maxLength: 128 },
aliases: {
type: 'array',
Expand All @@ -57,7 +63,11 @@ export const emojiSuggestionParamDef = {
isSensitive: { type: 'boolean' },
localOnly: { type: 'boolean' },
},
required: ['name', 'fileId'],
required: ['name'],
oneOf: [
{ required: ['fileId'] },
{ required: ['remoteEmojiId'] },
],
} as const;

export const emojiSuggestionListParamDef = {
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/server/api/endpoint-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +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/list-remote' from './endpoints/emoji/list-remote.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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private readonly emojiSuggestionEntityService: EmojiSuggestionEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const source = ps.fileId != null
? { fileId: ps.fileId }
: { remoteEmojiId: ps.remoteEmojiId! };
const result = await this.emojiSuggestionService.create({
name: ps.name,
fileId: ps.fileId,
...source,
category: ps.category ?? null,
aliases: ps.aliases ?? [],
license: ps.license ?? null,
Expand Down
87 changes: 87 additions & 0 deletions packages/backend/src/server/api/endpoints/emoji/list-remote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* SPDX-FileCopyrightText: Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { Inject, Injectable } from '@nestjs/common';
import type { EmojisRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { DI } from '@/di-symbols.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';

export const meta = {
tags: ['emoji-suggestions'],
requireCredential: true,
kind: 'read:account',
limit: {
duration: 1000 * 5,
max: 10,
},
res: {
type: 'array',
optional: false,
nullable: false,
items: {
type: 'object',
optional: false,
nullable: false,
ref: 'EmojiDetailed',
},
},
} as const;

export const paramDef = {
type: 'object',
properties: {
query: { type: 'string', nullable: true, default: null },
host: { type: 'string', nullable: true, default: null },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
},
required: [],
} as const;

@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.emojisRepository)
private readonly emojisRepository: EmojisRepository,

private readonly queryService: QueryService,
private readonly emojiEntityService: EmojiEntityService,
) {
super(meta, paramDef, async (ps) => {
const query = this.queryService.makePaginationQuery(
this.emojisRepository.createQueryBuilder('emoji'),
ps.sinceId,
ps.untilId,
).andWhere('emoji.host IS NOT NULL');

if (ps.query) {
const names = ps.query
.normalize('NFC')
.split(/\s/)
.filter(value => value.length > 0)
.map(value => `%${sqlLikeEscape(value)}%`);
query.andWhere('emoji.name ~~ ANY(ARRAY[:...names])', { names });
}

if (ps.host) {
const hosts = ps.host
.split(/\s/)
.filter(value => value.length > 0)
.map(value => `%${sqlLikeEscape(value)}%`);
query.andWhere('emoji.host ~~ ANY(ARRAY[:...hosts])', { hosts });
}

const emojis = await query
.take(ps.limit)
.getMany();

return await this.emojiEntityService.packDetailedMany(emojis);
});
}
}
Loading
Loading