Files
FrozenFriendsYume/packages/backend/built/misc/populate-emojis.js
T
2026-07-26 18:25:37 +09:00

286 lines
11 KiB
JavaScript

import { In, IsNull } from "typeorm";
import { DriveFiles, Emojis, UserEmojis, UserGroups, UserProfiles, Users } from "../models/index.js";
import { Cache } from "./cache.js";
import { isSelfHost, toPunyNullable } from "./convert-host.js";
import { decodeReaction } from "./reaction-lib.js";
import config from "../config/index.js";
import { query } from "../prelude/url.js";
import { redisClient } from "../db/redis.js";
import { resolveUser } from "../remote/resolve-user.js";
const cache = new Cache("populateEmojis", 60 * 60 * 12);
const userEmojiCache = new Cache("populateUserEmojis", 60 * 60 * 12);
function normalizeHost(src, noteUserHost) {
// クエリに使うホスト
let host = src === "." ? null // .はローカルホスト (ここがマッチするのはリアクションのみ)
: src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない)
: isSelfHost(src) ? null // 自ホスト指定
: src || noteUserHost; // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない)
host = toPunyNullable(host);
return host;
}
function parseEmojiStr(emojiName, noteUserHost) {
// emojiName may be of the form `emoji@host`, turn it into a suitable form
const match = emojiName.split("@");
const name = match[0];
const host = toPunyNullable(normalizeHost(match[1], noteUserHost));
return {
name,
host
};
}
function proxiedUrl(url, host) {
if (host == null) return url;
return `${config.url}/proxy/${encodeURIComponent(new URL(url).pathname)}?${query({
url
})}`;
}
function proxiedGlyphUrl(url, host) {
if (host == null) return url;
return `${config.url}/proxy/${encodeURIComponent(new URL(url).pathname)}?${query({
url,
glyph: "1"
})}`;
}
function parseUserIconEmoji(emojiName) {
const match = emojiName.match(/^@([^@:\s]+)(?:@([^@:\s]+))?$/);
if (!match) return null;
return {
username: match[1],
host: toPunyNullable(normalizeHost(match[2], null))
};
}
function parseGroupSymbolEmoji(emojiName) {
const match = emojiName.match(/^@@([a-zA-Z0-9_]{1,64})$/);
if (!match) return null;
return {
username: match[1].toLowerCase()
};
}
function parseGroupEmoji(emojiName) {
const match = emojiName.match(/^([a-z0-9_]{1,64})@@([a-zA-Z0-9_]{1,64})$/);
if (!match) return null;
return {
name: match[1],
username: match[2].toLowerCase()
};
}
function parseUserEmoji(emojiName) {
const parts = emojiName.split("@");
if (parts.length !== 2 && parts.length !== 3) return null;
if (!parts[0] || !parts[1]) return null;
return {
name: parts[0],
username: parts[1],
host: toPunyNullable(normalizeHost(parts[2], null))
};
}
async function findUserByAcct(username, host) {
const user = await Users.findOneBy({
usernameLower: username.toLowerCase(),
host: host ?? IsNull()
});
if (user) return user;
if (host == null) return null;
return resolveUser(username, host).catch(()=>null);
}
async function populateUserIconEmoji(emojiName) {
const parsed = parseUserIconEmoji(emojiName);
if (!parsed) return null;
const user = await findUserByAcct(parsed.username, parsed.host);
if (!user) return null;
const profile = await UserProfiles.findOneBy({
userId: user.id
});
if (profile?.symbolFileId) {
const symbol = await DriveFiles.findOneBy({
id: profile.symbolFileId
});
if (symbol) {
const symbolUrl = symbol.webpublicUrl ?? symbol.url;
return {
name: emojiName,
url: proxiedUrl(symbolUrl, user.host),
glyph: true,
glyphUrl: proxiedGlyphUrl(symbol.url, user.host),
width: null,
height: null
};
}
}
const avatarUrl = user.avatarUrl ?? await Users.getAvatarUrl(user);
return {
name: emojiName,
url: proxiedUrl(avatarUrl, user.host),
glyph: false,
glyphUrl: null,
width: null,
height: null
};
}
async function populateUserEmoji(emojiName) {
const parsed = parseUserEmoji(emojiName);
if (!parsed) return null;
const user = await findUserByAcct(parsed.username, parsed.host);
if (!user) return null;
const userEmoji = await UserEmojis.findOneBy({
name: parsed.name,
userId: user.id
});
if (!userEmoji) return null;
const emojiUrl = userEmoji.publicUrl || userEmoji.originalUrl;
return {
name: emojiName,
url: proxiedUrl(emojiUrl, user.host),
glyph: userEmoji.glyph,
glyphUrl: userEmoji.glyph ? proxiedGlyphUrl(userEmoji.originalUrl, user.host) : null,
width: userEmoji.width,
height: userEmoji.height
};
}
async function populateGroupSymbolEmoji(emojiName) {
const parsed = parseGroupSymbolEmoji(emojiName);
if (!parsed) return null;
const group = await UserGroups.findOneBy({
username: parsed.username
});
if (!group?.symbolFileId && !group?.iconFileId) return null;
const file = await DriveFiles.findOneBy({
id: group.symbolFileId ?? group.iconFileId
});
if (!file) return null;
const symbolUrl = file.webpublicUrl ?? file.url;
return {
name: emojiName,
url: proxiedUrl(symbolUrl, null),
glyph: true,
glyphUrl: proxiedGlyphUrl(file.url, null),
width: null,
height: null
};
}
async function populateGroupEmoji(emojiName) {
const parsed = parseGroupEmoji(emojiName);
if (!parsed) return null;
const group = await UserGroups.findOneBy({
username: parsed.username
});
if (!group) return null;
const groupEmoji = await UserEmojis.findOneBy({
name: parsed.name,
userGroupId: group.id
});
if (!groupEmoji) return null;
const emojiUrl = groupEmoji.publicUrl || groupEmoji.originalUrl;
return {
name: emojiName,
url: proxiedUrl(emojiUrl, null),
glyph: groupEmoji.glyph,
glyphUrl: groupEmoji.glyph ? proxiedGlyphUrl(groupEmoji.originalUrl, null) : null,
width: groupEmoji.width,
height: groupEmoji.height
};
}
/**
* 添付用絵文字情報を解決する
* @param emojiName ノートやユーザープロフィールに添付された、またはリアクションのカスタム絵文字名 (:は含めない, リアクションでローカルホストの場合は@.を付ける (これはdecodeReactionで可能))
* @param noteUserHost ノートやユーザープロフィールの所有者のホスト
* @returns 絵文字情報, nullは未マッチを意味する
*/ export async function populateEmoji(emojiName, noteUserHost) {
const { name, host } = parseEmojiStr(emojiName, noteUserHost);
if (name == null) return null;
const queryOrNull = async ()=>await Emojis.findOneBy({
name,
host: host ?? IsNull()
}) || null;
const cacheKey = `${name} ${host}`;
let emoji = await cache.fetch(cacheKey, queryOrNull);
if (emoji && !(emoji.width && emoji.height)) {
emoji = await queryOrNull();
await cache.set(cacheKey, emoji);
}
if (emoji == null) return null;
const isLocal = emoji.host == null;
const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため
const url = proxiedUrl(emojiUrl, isLocal ? null : emoji.host);
return {
name: emojiName,
url,
glyph: emoji.glyph,
glyphUrl: emoji.glyph ? proxiedGlyphUrl(emoji.originalUrl, isLocal ? null : emoji.host) : null,
width: emoji.width,
height: emoji.height
};
}
export async function populateEmojiOrUserEmoji(emojiName, noteUserHost) {
const emoji = await populateEmoji(emojiName, noteUserHost);
if (emoji) return emoji;
const userEmoji = await userEmojiCache.fetchMaybe(`user ${emojiName}`, async ()=>await populateUserIconEmoji(emojiName) ?? await populateUserEmoji(emojiName) ?? await populateGroupSymbolEmoji(emojiName) ?? await populateGroupEmoji(emojiName) ?? undefined, false, (cached)=>cached != null);
return userEmoji ?? null;
}
export async function clearUserEmojiCache(name, username, host) {
const keys = new Set([
`user ${name}@${username}`
]);
if (host) {
keys.add(`user ${name}@${username}@${host}`);
} else {
keys.add(`user ${name}@${username}@${config.host}`);
}
await userEmojiCache.delete(...keys);
}
export async function clearGroupEmojiCache(name, groupUsername) {
if (!groupUsername) return;
await userEmojiCache.delete(`user ${name}@@${groupUsername}`, `user @@${groupUsername}`);
}
/**
* 複数の添付用絵文字情報を解決する (キャシュ付き, 存在しないものは結果から除外される)
*/ export async function populateEmojis(emojiNames, noteUserHost) {
const emojis = await Promise.all(emojiNames.map((x)=>populateEmojiOrUserEmoji(x, noteUserHost)));
return emojis.filter((x)=>x != null);
}
export function aggregateNoteEmojis(notes) {
let emojis = [];
for (const note of notes){
emojis = emojis.concat(note.emojis.map((e)=>parseEmojiStr(e, note.userHost)));
if (note.renote) {
emojis = emojis.concat(note.renote.emojis.map((e)=>parseEmojiStr(e, note.renote.userHost)));
if (note.renote.user) {
emojis = emojis.concat(note.renote.user.emojis.map((e)=>parseEmojiStr(e, note.renote.userHost)));
}
}
const customReactions = Object.keys(note.reactions).map((x)=>decodeReaction(x)).filter((x)=>x.name != null);
emojis = emojis.concat(customReactions);
if (note.user) {
emojis = emojis.concat(note.user.emojis.map((e)=>parseEmojiStr(e, note.userHost)));
}
}
return emojis.filter((x)=>x.name != null);
}
/**
* 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します
*/ export async function prefetchEmojis(emojis) {
const notCachedEmojis = emojis.filter(async (emoji)=>!await cache.get(`${emoji.name} ${emoji.host}`));
const emojisQuery = [];
const hosts = new Set(notCachedEmojis.map((e)=>e.host));
for (const host of hosts){
emojisQuery.push({
name: In(notCachedEmojis.filter((e)=>e.host === host).map((e)=>e.name)),
host: host ?? IsNull()
});
}
const _emojis = emojisQuery.length > 0 ? await Emojis.find({
where: emojisQuery,
select: [
"name",
"host",
"originalUrl",
"publicUrl"
]
}) : [];
const trans = redisClient.multi();
for (const emoji of _emojis){
cache.set(`${emoji.name} ${emoji.host}`, emoji, trans);
}
await trans.exec();
}