refactor (backend): port nodeinfo generator to backend-rs

This commit is contained in:
naskya 2024-05-06 02:20:39 +09:00
parent 359fef0a42
commit 49825853c1
No known key found for this signature in database
GPG key ID: 712D413B3A9FED5C
7 changed files with 354 additions and 101 deletions

View file

@ -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

View file

@ -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

View file

@ -1,3 +1,4 @@
pub mod log;
pub mod nodeinfo;
pub mod note;
pub mod stream;

View file

@ -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?)?)
}

View file

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

View file

@ -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;

View file

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