diff --git a/packages/backend-rs/index.d.ts b/packages/backend-rs/index.d.ts index 9b8a64142f..a47495508e 100644 --- a/packages/backend-rs/index.d.ts +++ b/packages/backend-rs/index.d.ts @@ -156,6 +156,14 @@ export function nyaify(text: string, lang?: string | undefined | null): string export function hashPassword(password: string): string export function verifyPassword(password: string, hash: string): boolean export function isOldPasswordAlgorithm(hash: string): boolean +export interface DecodedReaction { + reaction: string + name: string | null + host: string | null +} +export function decodeReaction(reaction: string): DecodedReaction +export function countReactions(reactions: Record): Record +export function toDbReaction(reaction?: string | undefined | null, host?: string | undefined | null): Promise export interface AbuseUserReport { id: string createdAt: Date diff --git a/packages/backend-rs/index.js b/packages/backend-rs/index.js index 16de16297f..6fca851430 100644 --- a/packages/backend-rs/index.js +++ b/packages/backend-rs/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { readServerConfig, stringToAcct, acctToString, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, AntennaSrcEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initIdGenerator, getTimestamp, genId, secureRndstr } = nativeBinding +const { readServerConfig, stringToAcct, acctToString, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, AntennaSrcEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initIdGenerator, getTimestamp, genId, secureRndstr } = nativeBinding module.exports.readServerConfig = readServerConfig module.exports.stringToAcct = stringToAcct @@ -333,6 +333,9 @@ module.exports.nyaify = nyaify module.exports.hashPassword = hashPassword module.exports.verifyPassword = verifyPassword module.exports.isOldPasswordAlgorithm = isOldPasswordAlgorithm +module.exports.decodeReaction = decodeReaction +module.exports.countReactions = countReactions +module.exports.toDbReaction = toDbReaction module.exports.AntennaSrcEnum = AntennaSrcEnum module.exports.MutedNoteReasonEnum = MutedNoteReasonEnum module.exports.NoteVisibilityEnum = NoteVisibilityEnum diff --git a/packages/backend-rs/src/misc/mod.rs b/packages/backend-rs/src/misc/mod.rs index 3fd447f4d5..45fd31cdcd 100644 --- a/packages/backend-rs/src/misc/mod.rs +++ b/packages/backend-rs/src/misc/mod.rs @@ -8,3 +8,4 @@ pub mod mastodon_id; pub mod meta; pub mod nyaify; pub mod password; +pub mod reaction; diff --git a/packages/backend-rs/src/misc/reaction.rs b/packages/backend-rs/src/misc/reaction.rs new file mode 100644 index 0000000000..a29ddf95de --- /dev/null +++ b/packages/backend-rs/src/misc/reaction.rs @@ -0,0 +1,191 @@ +use crate::database::db_conn; +use crate::misc::{convert_host::to_puny, emoji::is_unicode_emoji, meta::fetch_meta}; +use crate::model::entity::emoji; +use once_cell::sync::Lazy; +use regex::Regex; +use sea_orm::prelude::*; +use std::collections::HashMap; + +#[derive(PartialEq, Debug)] +#[crate::export(object)] +pub struct DecodedReaction { + pub reaction: String, + pub name: Option, + pub host: Option, +} + +#[crate::export] +pub fn decode_reaction(reaction: &str) -> DecodedReaction { + // Misskey allows you to include "+" and "-" in emoji shortcodes + // MFM spec: https://github.com/misskey-dev/mfm.js/blob/6aaf68089023c6adebe44123eebbc4dcd75955e0/docs/syntax.md?plain=1#L583 + // Misskey's implementation: https://github.com/misskey-dev/misskey/blob/bba3097765317cbf95d09627961b5b5dce16a972/packages/backend/src/core/ReactionService.ts#L68 + static RE: Lazy = + Lazy::new(|| Regex::new(r"^:([0-9A-Za-z_+-]+)(?:@([0-9A-Za-z_.-]+))?:$").unwrap()); + + if let Some(captures) = RE.captures(reaction) { + let name = &captures[1]; + let host = captures.get(2).map(|s| s.as_str()); + + DecodedReaction { + reaction: format!(":{}@{}:", name, host.unwrap_or(".")), + name: Some(name.to_owned()), + host: host.map(|s| s.to_owned()), + } + } else { + DecodedReaction { + reaction: reaction.to_owned(), + name: None, + host: None, + } + } +} + +#[crate::export] +pub fn count_reactions(reactions: &HashMap) -> HashMap { + let mut res = HashMap::::new(); + + for (reaction, count) in reactions.iter() { + if count > &0 { + let decoded = decode_reaction(reaction).reaction; + let total = res.entry(decoded).or_insert(0); + *total += count; + } + } + + res +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Idna error: {0}")] + IdnaError(#[from] idna::Errors), + #[error("Database error: {0}")] + DbError(#[from] DbErr), +} + +#[crate::export] +pub async fn to_db_reaction(reaction: Option<&str>, host: Option<&str>) -> Result { + if let Some(reaction) = reaction { + // FIXME: Is it okay to do this only here? + // This was introduced in https://firefish.dev/firefish/firefish/-/commit/af730e75b6fc1a57ca680ce83459d7e433b130cf + if reaction.contains('❤') || reaction.contains("♥️") { + return Ok("❤️".to_owned()); + } + + if is_unicode_emoji(reaction) { + return Ok(reaction.to_owned()); + } + + static RE: Lazy = + Lazy::new(|| Regex::new(r"^:([0-9A-Za-z_+-]+)(?:@\.)?:$").unwrap()); + + if let Some(captures) = RE.captures(reaction) { + let name = &captures[1]; + let db = db_conn().await?; + + if let Some(host) = host { + // remote emoji + let ascii_host = to_puny(host)?; + + // TODO: Does SeaORM have the `exists` method? + if emoji::Entity::find() + .filter(emoji::Column::Name.eq(name)) + .filter(emoji::Column::Host.eq(&ascii_host)) + .one(db) + .await? + .is_some() + { + return Ok(format!(":{name}@{ascii_host}:")); + } + } else { + // local emoji + // TODO: Does SeaORM have the `exists` method? + if emoji::Entity::find() + .filter(emoji::Column::Name.eq(name)) + .filter(emoji::Column::Host.is_null()) + .one(db) + .await? + .is_some() + { + return Ok(format!(":{name}:")); + } + } + }; + }; + + Ok(fetch_meta(true).await?.default_reaction) +} + +#[cfg(test)] +mod unit_test { + use super::{decode_reaction, DecodedReaction}; + use pretty_assertions::{assert_eq, assert_ne}; + + #[test] + fn test_decode_reaction() { + let unicode_emoji_1 = DecodedReaction { + reaction: "⭐".to_string(), + name: None, + host: None, + }; + let unicode_emoji_2 = DecodedReaction { + reaction: "🩷".to_string(), + name: None, + host: None, + }; + + assert_eq!(decode_reaction("⭐"), unicode_emoji_1); + assert_eq!(decode_reaction("🩷"), unicode_emoji_2); + + assert_ne!(decode_reaction("⭐"), unicode_emoji_2); + assert_ne!(decode_reaction("🩷"), unicode_emoji_1); + + let unicode_emoji_3 = DecodedReaction { + reaction: "🖖🏿".to_string(), + name: None, + host: None, + }; + assert_eq!(decode_reaction("🖖🏿"), unicode_emoji_3); + + let local_emoji = DecodedReaction { + reaction: ":meow_melt_tears@.:".to_string(), + name: Some("meow_melt_tears".to_string()), + host: None, + }; + assert_eq!(decode_reaction(":meow_melt_tears:"), local_emoji); + + let remote_emoji_1 = DecodedReaction { + reaction: ":meow_uwu@some-domain.example.org:".to_string(), + name: Some("meow_uwu".to_string()), + host: Some("some-domain.example.org".to_string()), + }; + assert_eq!( + decode_reaction(":meow_uwu@some-domain.example.org:"), + remote_emoji_1 + ); + + let remote_emoji_2 = DecodedReaction { + reaction: ":C++23@xn--eckwd4c7c.example.org:".to_string(), + name: Some("C++23".to_string()), + host: Some("xn--eckwd4c7c.example.org".to_string()), + }; + assert_eq!( + decode_reaction(":C++23@xn--eckwd4c7c.example.org:"), + remote_emoji_2 + ); + + let invalid_reaction_1 = DecodedReaction { + reaction: ":foo".to_string(), + name: None, + host: None, + }; + assert_eq!(decode_reaction(":foo"), invalid_reaction_1); + + let invalid_reaction_2 = DecodedReaction { + reaction: ":foo&@example.com:".to_string(), + name: None, + host: None, + }; + assert_eq!(decode_reaction(":foo&@example.com:"), invalid_reaction_2); + } +} diff --git a/packages/backend/src/misc/populate-emojis.ts b/packages/backend/src/misc/populate-emojis.ts index f18b23c9a4..4ca60b222f 100644 --- a/packages/backend/src/misc/populate-emojis.ts +++ b/packages/backend/src/misc/populate-emojis.ts @@ -3,8 +3,7 @@ import { Emojis } from "@/models/index.js"; import type { Emoji } from "@/models/entities/emoji.js"; import type { Note } from "@/models/entities/note.js"; import { Cache } from "./cache.js"; -import { isSelfHost, toPuny } from "backend-rs"; -import { decodeReaction } from "./reaction-lib.js"; +import { decodeReaction, isSelfHost, toPuny } from "backend-rs"; import config from "@/config/index.js"; import { query } from "@/prelude/url.js"; import { redisClient } from "@/db/redis.js"; diff --git a/packages/backend/src/misc/reaction-lib.ts b/packages/backend/src/misc/reaction-lib.ts deleted file mode 100644 index ac9ba5652d..0000000000 --- a/packages/backend/src/misc/reaction-lib.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { fetchMeta, isUnicodeEmoji, toPuny } from "backend-rs"; -import { Emojis } from "@/models/index.js"; -import { IsNull } from "typeorm"; - -export function convertReactions(reactions: Record) { - const result = new Map(); - - for (const reaction in reactions) { - if (reactions[reaction] <= 0) continue; - - const decoded = decodeReaction(reaction).reaction; - result.set(decoded, (result.get(decoded) || 0) + reactions[reaction]); - } - - return Object.fromEntries(result); -} - -export async function toDbReaction( - reaction?: string | null, - reacterHost?: string | null, -): Promise { - if (!reaction) return (await fetchMeta(true)).defaultReaction; - - if (reaction.includes("❤") || reaction.includes("♥️")) return "❤️"; - - // Allow unicode reactions - if (isUnicodeEmoji(reaction)) { - return reaction; - } - - reacterHost = reacterHost == null ? null : toPuny(reacterHost); - - const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/); - if (custom) { - const name = custom[1]; - const emoji = await Emojis.findOneBy({ - host: reacterHost || IsNull(), - name, - }); - - if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; - } - - return (await fetchMeta(true)).defaultReaction; -} - -type DecodedReaction = { - /** - * リアクション名 (Unicode Emoji or ':name@hostname' or ':name@.') - */ - reaction: string; - - /** - * name (カスタム絵文字の場合name, Emojiクエリに使う) - */ - name?: string; - - /** - * host (カスタム絵文字の場合host, Emojiクエリに使う) - */ - host?: string | null; -}; - -export function decodeReaction(str: string): DecodedReaction { - const custom = str.match(/^:([\w+-]+)(?:@([\w.-]+))?:$/); - - if (custom) { - const name = custom[1]; - const host = custom[2] || null; - - return { - reaction: `:${name}@${host || "."}:`, // ローカル分は@以降を省略するのではなく.にする - name, - host, - }; - } - - return { - reaction: str, - name: undefined, - host: undefined, - }; -} diff --git a/packages/backend/src/models/repositories/note-reaction.ts b/packages/backend/src/models/repositories/note-reaction.ts index 20aae2876f..47e16ced0c 100644 --- a/packages/backend/src/models/repositories/note-reaction.ts +++ b/packages/backend/src/models/repositories/note-reaction.ts @@ -2,7 +2,7 @@ import { db } from "@/db/postgre.js"; import { NoteReaction } from "@/models/entities/note-reaction.js"; import { Notes, Users } from "../index.js"; import type { Packed } from "@/misc/schema.js"; -import { decodeReaction } from "@/misc/reaction-lib.js"; +import { decodeReaction } from "backend-rs"; import type { User } from "@/models/entities/user.js"; export const NoteReactionRepository = db.getRepository(NoteReaction).extend({ diff --git a/packages/backend/src/models/repositories/note.ts b/packages/backend/src/models/repositories/note.ts index ed6ecc4a5c..1bf4ef657f 100644 --- a/packages/backend/src/models/repositories/note.ts +++ b/packages/backend/src/models/repositories/note.ts @@ -12,9 +12,8 @@ import { Channels, } from "../index.js"; import type { Packed } from "@/misc/schema.js"; -import { nyaify } from "backend-rs"; +import { countReactions, decodeReaction, nyaify } from "backend-rs"; import { awaitAll } from "@/prelude/await-all.js"; -import { convertReactions, decodeReaction } from "@/misc/reaction-lib.js"; import type { NoteReaction } from "@/models/entities/note-reaction.js"; import { aggregateNoteEmojis, @@ -214,7 +213,7 @@ export const NoteRepository = db.getRepository(Note).extend({ note.visibility === "specified" ? note.visibleUserIds : undefined, renoteCount: note.renoteCount, repliesCount: note.repliesCount, - reactions: convertReactions(note.reactions), + reactions: countReactions(note.reactions), reactionEmojis: reactionEmoji, emojis: noteEmoji, tags: note.tags.length > 0 ? note.tags : undefined, diff --git a/packages/backend/src/services/note/reaction/create.ts b/packages/backend/src/services/note/reaction/create.ts index a07ffdabad..3b8b97cefd 100644 --- a/packages/backend/src/services/note/reaction/create.ts +++ b/packages/backend/src/services/note/reaction/create.ts @@ -2,7 +2,6 @@ import { publishNoteStream } from "@/services/stream.js"; import { renderLike } from "@/remote/activitypub/renderer/like.js"; import DeliverManager from "@/remote/activitypub/deliver-manager.js"; import { renderActivity } from "@/remote/activitypub/renderer/index.js"; -import { toDbReaction, decodeReaction } from "@/misc/reaction-lib.js"; import type { User, IRemoteUser } from "@/models/entities/user.js"; import type { Note } from "@/models/entities/note.js"; import { @@ -14,7 +13,7 @@ import { Blockings, } from "@/models/index.js"; import { IsNull, Not } from "typeorm"; -import { genId } from "backend-rs"; +import { decodeReaction, genId, toDbReaction } from "backend-rs"; import { createNotification } from "@/services/create-notification.js"; import deleteReaction from "./delete.js"; import { isDuplicateKeyValueError } from "@/misc/is-duplicate-key-value-error.js"; @@ -95,7 +94,7 @@ export default async ( const emoji = await Emojis.findOne({ where: { - name: decodedReaction.name, + name: decodedReaction.name ?? undefined, host: decodedReaction.host ?? IsNull(), }, select: ["name", "host", "originalUrl", "publicUrl"], diff --git a/packages/backend/src/services/note/reaction/delete.ts b/packages/backend/src/services/note/reaction/delete.ts index 49879a0c02..e5416a78a8 100644 --- a/packages/backend/src/services/note/reaction/delete.ts +++ b/packages/backend/src/services/note/reaction/delete.ts @@ -7,7 +7,7 @@ import { IdentifiableError } from "@/misc/identifiable-error.js"; import type { User, IRemoteUser } from "@/models/entities/user.js"; import type { Note } from "@/models/entities/note.js"; import { NoteReactions, Users, Notes } from "@/models/index.js"; -import { decodeReaction } from "@/misc/reaction-lib.js"; +import { decodeReaction } from "backend-rs"; export default async ( user: { id: User["id"]; host: User["host"] },