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(); }