From 49825853c147188b6f629cb349c3108f9c8deaab Mon Sep 17 00:00:00 2001 From: naskya <m@naskya.net> Date: Mon, 6 May 2024 02:20:39 +0900 Subject: [PATCH] refactor (backend): port nodeinfo generator to backend-rs --- packages/backend-rs/index.d.ts | 2 + packages/backend-rs/index.js | 4 +- packages/backend-rs/src/service/mod.rs | 1 + packages/backend-rs/src/service/nodeinfo.rs | 327 ++++++++++++++++++ .../backend/src/prelude/undefined-to-null.ts | 16 +- packages/backend/src/server/nodeinfo.ts | 101 +----- packages/backend/src/services/note/create.ts | 4 +- 7 files changed, 354 insertions(+), 101 deletions(-) create mode 100644 packages/backend-rs/src/service/nodeinfo.rs diff --git a/packages/backend-rs/index.d.ts b/packages/backend-rs/index.d.ts index 0a92da8596..3eef51ab43 100644 --- a/packages/backend-rs/index.d.ts +++ b/packages/backend-rs/index.d.ts @@ -1155,6 +1155,8 @@ export interface Webhook { latestStatus: number | null } export function initializeRustLogger(): void +export function nodeinfo_2_1(): Promise<any> +export function nodeinfo_2_0(): Promise<any> export function watchNote(watcherId: string, noteAuthorId: string, noteId: string): Promise<void> export function unwatchNote(watcherId: string, noteId: string): Promise<void> export function publishToChannelStream(channelId: string, userId: string): void diff --git a/packages/backend-rs/index.js b/packages/backend-rs/index.js index 1b1ae265b1..c518f59a4b 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 { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getImageSizeFromUrl, getNoteSummary, latestVersion, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initializeRustLogger, watchNote, unwatchNote, publishToChannelStream, ChatEvent, publishToChatStream, ChatIndexEvent, publishToChatIndexStream, publishToBroadcastStream, publishToGroupChatStream, publishToModerationStream, getTimestamp, genId, genIdAt, secureRndstr } = nativeBinding +const { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getImageSizeFromUrl, getNoteSummary, latestVersion, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initializeRustLogger, nodeinfo_2_1, nodeinfo_2_0, watchNote, unwatchNote, publishToChannelStream, ChatEvent, publishToChatStream, ChatIndexEvent, publishToChatIndexStream, publishToBroadcastStream, publishToGroupChatStream, publishToModerationStream, getTimestamp, genId, genIdAt, secureRndstr } = nativeBinding module.exports.SECOND = SECOND module.exports.MINUTE = MINUTE @@ -364,6 +364,8 @@ module.exports.UserEmojimodpermEnum = UserEmojimodpermEnum module.exports.UserProfileFfvisibilityEnum = UserProfileFfvisibilityEnum module.exports.UserProfileMutingnotificationtypesEnum = UserProfileMutingnotificationtypesEnum module.exports.initializeRustLogger = initializeRustLogger +module.exports.nodeinfo_2_1 = nodeinfo_2_1 +module.exports.nodeinfo_2_0 = nodeinfo_2_0 module.exports.watchNote = watchNote module.exports.unwatchNote = unwatchNote module.exports.publishToChannelStream = publishToChannelStream diff --git a/packages/backend-rs/src/service/mod.rs b/packages/backend-rs/src/service/mod.rs index 0a2644857f..f755f22b4b 100644 --- a/packages/backend-rs/src/service/mod.rs +++ b/packages/backend-rs/src/service/mod.rs @@ -1,3 +1,4 @@ pub mod log; +pub mod nodeinfo; pub mod note; pub mod stream; diff --git a/packages/backend-rs/src/service/nodeinfo.rs b/packages/backend-rs/src/service/nodeinfo.rs new file mode 100644 index 0000000000..5760d7162d --- /dev/null +++ b/packages/backend-rs/src/service/nodeinfo.rs @@ -0,0 +1,327 @@ +use crate::config::CONFIG; +use crate::database::cache; +use crate::database::db_conn; +use crate::misc::meta::fetch_meta; +use crate::model::entity::{note, user}; +use sea_orm::{ColumnTrait, DbErr, EntityTrait, PaginatorTrait, QueryFilter}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::collections::HashMap; + +// TODO: I want to use these macros but they don't work with rmp_serde +// - #[serde(skip_serializing_if = "Option::is_none")] (https://github.com/3Hren/msgpack-rust/issues/86) +// - #[serde(tag = "version", rename = "2.1")] (https://github.com/3Hren/msgpack-rust/issues/318) + +/// NodeInfo schema version 2.1. https://nodeinfo.diaspora.software/docson/index.html#/ns/schema/2.1 +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Nodeinfo21 { + /// The schema version, must be 2.1. + pub version: String, + /// Metadata about server software in use. + pub software: Software21, + /// The protocols supported on this server. + pub protocols: Vec<Protocol>, + /// The third party sites this server can connect to via their application API. + pub services: Services, + /// Whether this server allows open self-registration. + pub open_registrations: bool, + /// Usage statistics for this server. + pub usage: Usage, + /// Free form key value pairs for software specific values. Clients should not rely on any specific key present. + pub metadata: HashMap<String, serde_json::Value>, +} + +/// NodeInfo schema version 2.0. https://nodeinfo.diaspora.software/docson/index.html#/ns/schema/2.0 +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Nodeinfo20 { + /// The schema version, must be 2.0. + pub version: String, + /// Metadata about server software in use. + pub software: Software20, + /// The protocols supported on this server. + pub protocols: Vec<Protocol>, + /// The third party sites this server can connect to via their application API. + pub services: Services, + /// Whether this server allows open self-registration. + pub open_registrations: bool, + /// Usage statistics for this server. + pub usage: Usage, + /// Free form key value pairs for software specific values. Clients should not rely on any specific key present. + pub metadata: HashMap<String, serde_json::Value>, +} + +/// Metadata about server software in use (version 2.1). +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Software21 { + /// The canonical name of this server software. + pub name: String, + /// The version of this server software. + pub version: String, + /// The url of the source code repository of this server software. + pub repository: Option<String>, + /// The url of the homepage of this server software. + pub homepage: Option<String>, +} + +/// Metadata about server software in use (version 2.0). +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Software20 { + /// The canonical name of this server software. + pub name: String, + /// The version of this server software. + pub version: String, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum Protocol { + Activitypub, + Buddycloud, + Dfrn, + Diaspora, + Libertree, + Ostatus, + Pumpio, + Tent, + Xmpp, + Zot, +} + +/// The third party sites this server can connect to via their application API. +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Services { + /// The third party sites this server can retrieve messages from for combined display with regular traffic. + pub inbound: Vec<Inbound>, + /// The third party sites this server can publish messages to on the behalf of a user. + pub outbound: Vec<Outbound>, +} + +/// The third party sites this server can retrieve messages from for combined display with regular traffic. +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum Inbound { + #[serde(rename = "atom1.0")] + Atom1, + Gnusocial, + Imap, + Pnut, + #[serde(rename = "pop3")] + Pop3, + Pumpio, + #[serde(rename = "rss2.0")] + Rss2, + Twitter, +} + +/// The third party sites this server can publish messages to on the behalf of a user. +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum Outbound { + #[serde(rename = "atom1.0")] + Atom1, + Blogger, + Buddycloud, + Diaspora, + Dreamwidth, + Drupal, + Facebook, + Friendica, + Gnusocial, + Google, + Insanejournal, + Libertree, + Linkedin, + Livejournal, + Mediagoblin, + Myspace, + Pinterest, + Pnut, + Posterous, + Pumpio, + Redmatrix, + #[serde(rename = "rss2.0")] + Rss2, + Smtp, + Tent, + Tumblr, + Twitter, + Wordpress, + Xmpp, +} + +/// Usage statistics for this server. +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Usage { + pub users: Users, + pub local_posts: Option<u64>, + pub local_comments: Option<u64>, +} + +/// statistics about the users of this server. +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Users { + pub total: Option<u64>, + pub active_halfyear: Option<u64>, + pub active_month: Option<u64>, +} + +impl From<Software21> for Software20 { + fn from(software: Software21) -> Self { + Self { + name: software.name, + version: software.version, + } + } +} + +impl From<Nodeinfo21> for Nodeinfo20 { + fn from(nodeinfo: Nodeinfo21) -> Self { + Self { + version: "2.0".to_string(), + software: nodeinfo.software.into(), + protocols: nodeinfo.protocols, + services: nodeinfo.services, + open_registrations: nodeinfo.open_registrations, + usage: nodeinfo.usage, + metadata: nodeinfo.metadata, + } + } +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Database error: {0}")] + DbErr(#[from] DbErr), + #[error("Cache error: {0}")] + CacheErr(#[from] cache::Error), + #[error("Failed to serialize nodeinfo to JSON: {0}")] + JsonErr(#[from] serde_json::Error), +} + +async fn statistics() -> Result<(u64, u64, u64, u64), DbErr> { + let db = db_conn().await?; + + let now = chrono::Local::now().naive_local(); + const MONTH: chrono::TimeDelta = chrono::Duration::seconds(2592000000); + const HALF_YEAR: chrono::TimeDelta = chrono::Duration::seconds(15552000000); + + let local_users = user::Entity::find() + .filter(user::Column::Host.is_null()) + .count(db); + let local_active_halfyear = user::Entity::find() + .filter(user::Column::Host.is_null()) + .filter(user::Column::LastActiveDate.gt(now - HALF_YEAR)) + .count(db); + let local_active_month = user::Entity::find() + .filter(user::Column::Host.is_null()) + .filter(user::Column::LastActiveDate.gt(now - MONTH)) + .count(db); + let local_posts = note::Entity::find() + .filter(note::Column::UserHost.is_null()) + .count(db); + + tokio::try_join!( + local_users, + local_active_halfyear, + local_active_month, + local_posts + ) +} + +async fn get_new_nodeinfo_2_1() -> Result<Nodeinfo21, Error> { + let (local_users, local_active_halfyear, local_active_month, local_posts) = + statistics().await?; + let meta = fetch_meta(true).await?; + let metadata = HashMap::from([ + ( + "nodeName".to_string(), + json!(meta.name.unwrap_or(CONFIG.host.clone())), + ), + ("nodeDescription".to_string(), json!(meta.description)), + ("repositoryUrl".to_string(), json!(meta.repository_url)), + ( + "enableLocalTimeline".to_string(), + json!(!meta.disable_local_timeline), + ), + ( + "enableRecommendedTimeline".to_string(), + json!(!meta.disable_recommended_timeline), + ), + ( + "enableGlobalTimeline".to_string(), + json!(!meta.disable_global_timeline), + ), + ( + "enableGuestTimeline".to_string(), + json!(meta.enable_guest_timeline), + ), + ("maintainerName".to_string(), json!(meta.maintainer_name)), + ("maintainerEmail".to_string(), json!(meta.maintainer_email)), + ("proxyAccountName".to_string(), json!(meta.proxy_account_id)), + ( + "themeColor".to_string(), + json!(meta.theme_color.unwrap_or("#31748f".to_string())), + ), + ]); + + Ok(Nodeinfo21 { + version: "2.1".to_string(), + software: Software21 { + name: "firefish".to_string(), + version: CONFIG.version.clone(), + repository: Some(meta.repository_url), + homepage: Some("https://firefish.dev/firefish/firefish".to_string()), + }, + protocols: vec![Protocol::Activitypub], + services: Services { + inbound: vec![], + outbound: vec![Outbound::Atom1, Outbound::Rss2], + }, + open_registrations: !meta.disable_registration, + usage: Usage { + users: Users { + total: Some(local_users), + active_halfyear: Some(local_active_halfyear), + active_month: Some(local_active_month), + }, + local_posts: Some(local_posts), + local_comments: None, + }, + metadata, + }) +} + +pub async fn nodeinfo_2_1() -> Result<Nodeinfo21, Error> { + const NODEINFO_2_1_CACHE_KEY: &str = "nodeinfo_2_1"; + + let cached = cache::get::<Nodeinfo21>(NODEINFO_2_1_CACHE_KEY)?; + + if let Some(nodeinfo) = cached { + Ok(nodeinfo) + } else { + let nodeinfo = get_new_nodeinfo_2_1().await?; + cache::set(NODEINFO_2_1_CACHE_KEY, &nodeinfo, 60 * 60)?; + Ok(nodeinfo) + } +} + +pub async fn nodeinfo_2_0() -> Result<Nodeinfo20, Error> { + Ok(nodeinfo_2_1().await?.into()) +} + +#[crate::export(js_name = "nodeinfo_2_1")] +pub async fn nodeinfo_2_1_as_json() -> Result<serde_json::Value, Error> { + Ok(serde_json::to_value(nodeinfo_2_1().await?)?) +} + +#[crate::export(js_name = "nodeinfo_2_0")] +pub async fn nodeinfo_2_0_as_json() -> Result<serde_json::Value, Error> { + Ok(serde_json::to_value(nodeinfo_2_0().await?)?) +} diff --git a/packages/backend/src/prelude/undefined-to-null.ts b/packages/backend/src/prelude/undefined-to-null.ts index 013be3cf9e..b241f7a653 100644 --- a/packages/backend/src/prelude/undefined-to-null.ts +++ b/packages/backend/src/prelude/undefined-to-null.ts @@ -1,7 +1,7 @@ // https://gist.github.com/tkrotoff/a6baf96eb6b61b445a9142e5555511a0 import type { Primitive } from "type-fest"; -type NullToUndefined<T> = T extends null +export type NullToUndefined<T> = T extends null ? undefined : T extends Primitive | Function | Date | RegExp ? T @@ -15,7 +15,7 @@ type NullToUndefined<T> = T extends null ? { [K in keyof T]: NullToUndefined<T[K]> } : unknown; -type UndefinedToNull<T> = T extends undefined +export type UndefinedToNull<T> = T extends undefined ? null : T extends Primitive | Function | Date | RegExp ? T @@ -47,6 +47,16 @@ function _nullToUndefined<T>(obj: T): NullToUndefined<T> { return obj as any; } +/** + * Recursively converts all null values to undefined. + * + * @param obj object to convert + * @returns a copy of the object with all its null values converted to undefined + */ +export function fromRustObject<T>(obj: T) { + return _nullToUndefined(structuredClone(obj)); +} + function _undefinedToNull<T>(obj: T): UndefinedToNull<T> { if (obj === undefined) { return null as any; @@ -71,6 +81,6 @@ function _undefinedToNull<T>(obj: T): UndefinedToNull<T> { * @param obj object to convert * @returns a copy of the object with all its undefined values converted to null */ -export function undefinedToNull<T>(obj: T) { +export function toRustObject<T>(obj: T) { return _undefinedToNull(structuredClone(obj)); } diff --git a/packages/backend/src/server/nodeinfo.ts b/packages/backend/src/server/nodeinfo.ts index 73189b73d9..511d75e254 100644 --- a/packages/backend/src/server/nodeinfo.ts +++ b/packages/backend/src/server/nodeinfo.ts @@ -1,9 +1,7 @@ import Router from "@koa/router"; import { config } from "@/config.js"; -import { fetchMeta } from "backend-rs"; -import { Users, Notes } from "@/models/index.js"; -import { IsNull, MoreThan } from "typeorm"; -import { Cache } from "@/misc/cache.js"; +import { nodeinfo_2_0, nodeinfo_2_1 } from "backend-rs"; +import { fromRustObject } from "@/prelude/undefined-to-null.js"; const router = new Router(); @@ -22,101 +20,14 @@ export const links = [ }, ]; -const nodeinfo2 = async () => { - const now = Date.now(); - const [meta, total, activeHalfyear, activeMonth, localPosts] = - await Promise.all([ - fetchMeta(false), - Users.count({ where: { host: IsNull() } }), - Users.count({ - where: { - host: IsNull(), - lastActiveDate: MoreThan(new Date(now - 15552000000)), - }, - }), - Users.count({ - where: { - host: IsNull(), - lastActiveDate: MoreThan(new Date(now - 2592000000)), - }, - }), - Notes.count({ where: { userHost: IsNull() } }), - ]); - - const proxyAccount = meta.proxyAccountId - ? await Users.pack(meta.proxyAccountId).catch(() => null) - : null; - - return { - software: { - name: "firefish", - version: config.version, - repository: meta.repositoryUrl, - homepage: "https://firefish.dev/firefish/firefish", - }, - protocols: ["activitypub"], - services: { - inbound: [] as string[], - outbound: ["atom1.0", "rss2.0"], - }, - openRegistrations: !meta.disableRegistration, - usage: { - users: { total, activeHalfyear, activeMonth }, - localPosts, - localComments: 0, - }, - metadata: { - nodeName: meta.name, - nodeDescription: meta.description, - maintainer: { - name: meta.maintainerName, - email: meta.maintainerEmail, - }, - langs: meta.langs, - tosUrl: meta.tosUrl, - repositoryUrl: meta.repositoryUrl, - feedbackUrl: meta.feedbackUrl, - disableRegistration: meta.disableRegistration, - disableLocalTimeline: meta.disableLocalTimeline, - disableRecommendedTimeline: meta.disableRecommendedTimeline, - disableGlobalTimeline: meta.disableGlobalTimeline, - emailRequiredForSignup: meta.emailRequiredForSignup, - postEditing: true, - postImports: meta.experimentalFeatures?.postImports || false, - enableHcaptcha: meta.enableHcaptcha, - enableRecaptcha: meta.enableRecaptcha, - maxNoteTextLength: config.maxNoteLength, - maxCaptionTextLength: config.maxCaptionLength, - enableEmail: meta.enableEmail, - enableServiceWorker: meta.enableServiceWorker, - proxyAccountName: proxyAccount ? proxyAccount.username : null, - themeColor: meta.themeColor || "#31748f", - }, - }; -}; - -const cache = new Cache<Awaited<ReturnType<typeof nodeinfo2>>>( - "nodeinfo", - 60 * 10, -); - router.get(nodeinfo2_1path, async (ctx) => { - const base = await cache.fetch(null, () => nodeinfo2()); - - ctx.body = { version: "2.1", ...base }; - ctx.set("Cache-Control", "public, max-age=600"); + ctx.body = fromRustObject(await nodeinfo_2_1()); + ctx.set("Cache-Control", "public, max-age=3600"); }); router.get(nodeinfo2_0path, async (ctx) => { - const base = await cache.fetch(null, () => nodeinfo2()); - - // @ts-ignore - base.software.repository = undefined; - // @ts-ignore - base.software.homepage = undefined; - - ctx.body = { version: "2.0", ...base }; - ctx.set("Cache-Control", "public, max-age=600"); + ctx.body = fromRustObject(await nodeinfo_2_0()); + ctx.set("Cache-Control", "public, max-age=3600"); }); export default router; diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts index 679a2f886e..2096f8b1a2 100644 --- a/packages/backend/src/services/note/create.ts +++ b/packages/backend/src/services/note/create.ts @@ -66,7 +66,7 @@ import { Mutex } from "redis-semaphore"; import { langmap } from "@/misc/langmap.js"; import Logger from "@/services/logger.js"; import { inspect } from "node:util"; -import { undefinedToNull } from "@/prelude/undefined-to-null.js"; +import { toRustObject } from "@/prelude/undefined-to-null.js"; const logger = new Logger("create-note"); @@ -404,7 +404,7 @@ export default async ( checkHitAntenna(antenna, note, user).then((hit) => { if (hit) { // TODO: do this more sanely - addNoteToAntenna(antenna.id, undefinedToNull(note) as Note); + addNoteToAntenna(antenna.id, toRustObject(note)); } }); }