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