From edea72913e09ffbfb88a985dd3dc04b8351f39ef Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Wed, 10 Jul 2024 23:34:46 +0900
Subject: [PATCH] fix (backend): count correct local users

---
 packages/backend-rs/index.d.ts                 |  2 ++
 packages/backend-rs/index.js                   |  1 +
 .../src/federation/nodeinfo/generate.rs        |  5 ++---
 packages/backend-rs/src/misc/mod.rs            |  1 +
 packages/backend-rs/src/misc/user/count.rs     | 18 ++++++++++++++++++
 packages/backend-rs/src/misc/user/mod.rs       |  1 +
 .../backend/src/server/api/common/signup.ts    | 18 +++++++++---------
 .../api/endpoints/admin/accounts/create.ts     |  9 +++------
 .../backend/src/server/api/endpoints/meta.ts   |  8 ++------
 .../backend/src/server/api/endpoints/stats.ts  |  7 ++-----
 .../src/server/api/mastodon/helpers/misc.ts    |  6 +++---
 11 files changed, 44 insertions(+), 32 deletions(-)
 create mode 100644 packages/backend-rs/src/misc/user/count.rs
 create mode 100644 packages/backend-rs/src/misc/user/mod.rs

diff --git a/packages/backend-rs/index.d.ts b/packages/backend-rs/index.d.ts
index 3b6d5a7a72..90bc2cfcc1 100644
--- a/packages/backend-rs/index.d.ts
+++ b/packages/backend-rs/index.d.ts
@@ -257,6 +257,8 @@ export interface Config {
   userAgent: string
 }
 
+export declare function countLocalUsers(): Promise<number>
+
 export declare function countReactions(reactions: Record<string, number>): Record<string, number>
 
 export interface Cpu {
diff --git a/packages/backend-rs/index.js b/packages/backend-rs/index.js
index 6e0a162fc5..e45060a513 100644
--- a/packages/backend-rs/index.js
+++ b/packages/backend-rs/index.js
@@ -366,6 +366,7 @@ module.exports.AntennaSrc = nativeBinding.AntennaSrc
 module.exports.ChatEvent = nativeBinding.ChatEvent
 module.exports.ChatIndexEvent = nativeBinding.ChatIndexEvent
 module.exports.checkWordMute = nativeBinding.checkWordMute
+module.exports.countLocalUsers = nativeBinding.countLocalUsers
 module.exports.countReactions = nativeBinding.countReactions
 module.exports.cpuInfo = nativeBinding.cpuInfo
 module.exports.cpuUsage = nativeBinding.cpuUsage
diff --git a/packages/backend-rs/src/federation/nodeinfo/generate.rs b/packages/backend-rs/src/federation/nodeinfo/generate.rs
index 7b1c858095..86dadfdc58 100644
--- a/packages/backend-rs/src/federation/nodeinfo/generate.rs
+++ b/packages/backend-rs/src/federation/nodeinfo/generate.rs
@@ -4,6 +4,7 @@ use crate::{
     config::{local_server_info, CONFIG},
     database::db_conn,
     federation::nodeinfo::schema::*,
+    misc,
     model::entity::{note, user},
 };
 use sea_orm::prelude::*;
@@ -33,9 +34,7 @@ async fn statistics() -> Result<(u64, u64, u64, u64), DbErr> {
     const MONTH: chrono::TimeDelta = chrono::Duration::days(30);
     const HALF_YEAR: chrono::TimeDelta = chrono::Duration::days(183);
 
-    let local_users = user::Entity::find()
-        .filter(user::Column::Host.is_null())
-        .count(db);
+    let local_users = misc::user::count::local_total();
     let local_active_halfyear = user::Entity::find()
         .filter(user::Column::Host.is_null())
         .filter(user::Column::LastActiveDate.gt(now - HALF_YEAR))
diff --git a/packages/backend-rs/src/misc/mod.rs b/packages/backend-rs/src/misc/mod.rs
index 37cdbee45a..afa3b1396e 100644
--- a/packages/backend-rs/src/misc/mod.rs
+++ b/packages/backend-rs/src/misc/mod.rs
@@ -17,3 +17,4 @@ pub mod reaction;
 pub mod remove_old_attestation_challenges;
 pub mod should_nyaify;
 pub mod system_info;
+pub mod user;
diff --git a/packages/backend-rs/src/misc/user/count.rs b/packages/backend-rs/src/misc/user/count.rs
new file mode 100644
index 0000000000..bcea6bad9d
--- /dev/null
+++ b/packages/backend-rs/src/misc/user/count.rs
@@ -0,0 +1,18 @@
+use crate::{database::db_conn, model::entity::user};
+use sea_orm::prelude::*;
+
+// @instance.actor and @relay.actor are not real users
+const NUMBER_OF_SYSTEM_ACTORS: u64 = 2;
+
+pub async fn local_total() -> Result<u64, DbErr> {
+    user::Entity::find()
+        .filter(user::Column::Host.is_null())
+        .count(db_conn().await?)
+        .await
+        .map(|count| count - NUMBER_OF_SYSTEM_ACTORS)
+}
+
+#[macros::ts_export(js_name = "countLocalUsers")]
+pub async fn local_total_js() -> Result<u32, DbErr> {
+    local_total().await.map(|count| count as u32)
+}
diff --git a/packages/backend-rs/src/misc/user/mod.rs b/packages/backend-rs/src/misc/user/mod.rs
new file mode 100644
index 0000000000..16ee43aeca
--- /dev/null
+++ b/packages/backend-rs/src/misc/user/mod.rs
@@ -0,0 +1 @@
+pub mod count;
diff --git a/packages/backend/src/server/api/common/signup.ts b/packages/backend/src/server/api/common/signup.ts
index 05e92bcbea..687a22223e 100644
--- a/packages/backend/src/server/api/common/signup.ts
+++ b/packages/backend/src/server/api/common/signup.ts
@@ -3,7 +3,13 @@ import { User } from "@/models/entities/user.js";
 import { Users, UsedUsernames } from "@/models/index.js";
 import { UserProfile } from "@/models/entities/user-profile.js";
 import { IsNull } from "typeorm";
-import { genIdAt, generateUserToken, hashPassword, toPuny } from "backend-rs";
+import {
+	countLocalUsers,
+	genIdAt,
+	generateUserToken,
+	hashPassword,
+	toPuny,
+} from "backend-rs";
 import { UserKeypair } from "@/models/entities/user-keypair.js";
 import { UsedUsername } from "@/models/entities/used-username.js";
 import { db } from "@/db/postgre.js";
@@ -18,9 +24,7 @@ export async function signup(opts: {
 	const { username, password, passwordHash, host } = opts;
 	let hash = passwordHash;
 
-	const userCount = await Users.countBy({
-		host: IsNull(),
-	});
+	const userCount = await countLocalUsers();
 
 	if (config.maxUserSignups != null && userCount > config.maxUserSignups) {
 		throw new Error("MAX_USERS_REACHED");
@@ -103,11 +107,7 @@ export async function signup(opts: {
 				usernameLower: username.toLowerCase(),
 				host: host == null ? null : toPuny(host),
 				token: secret,
-				isAdmin:
-					(await Users.countBy({
-						host: IsNull(),
-						isAdmin: true,
-					})) === 0,
+				isAdmin: (await countLocalUsers()) === 0,
 			}),
 		);
 
diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts
index c5a990c941..db70d1e62b 100644
--- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts
+++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts
@@ -1,7 +1,7 @@
 import define from "@/server/api/define.js";
 import { Users } from "@/models/index.js";
 import { signup } from "@/server/api/common/signup.js";
-import { IsNull } from "typeorm";
+import { countLocalUsers } from "backend-rs";
 
 export const meta = {
 	tags: ["admin"],
@@ -32,11 +32,8 @@ export const paramDef = {
 
 export default define(meta, paramDef, async (ps, _me, token) => {
 	const me = _me ? await Users.findOneByOrFail({ id: _me.id }) : null;
-	const noUsers =
-		(await Users.countBy({
-			host: IsNull(),
-		})) === 0;
-	if (!noUsers && !me?.isAdmin) throw new Error("access denied");
+	if (!me?.isAdmin && (await countLocalUsers()) !== 0)
+		throw new Error("access denied");
 	if (token) throw new Error("access denied");
 
 	const { account, secret } = await signup({
diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts
index a9622c2ed3..5c74bb32df 100644
--- a/packages/backend/src/server/api/endpoints/meta.ts
+++ b/packages/backend/src/server/api/endpoints/meta.ts
@@ -1,7 +1,7 @@
 import JSON5 from "json5";
 import { IsNull, MoreThan } from "typeorm";
 import { config } from "@/config.js";
-import { fetchMeta } from "backend-rs";
+import { countLocalUsers, fetchMeta } from "backend-rs";
 import { Ads, Emojis, Users } from "@/models/index.js";
 import define from "@/server/api/define.js";
 
@@ -501,11 +501,7 @@ export default define(meta, paramDef, async (ps, me) => {
 						instance.privateMode && !me ? [] : instance.pinnedClipId,
 					cacheRemoteFiles: instance.cacheRemoteFiles,
 					markLocalFilesNsfwByDefault: instance.markLocalFilesNsfwByDefault,
-					requireSetup:
-						(await Users.countBy({
-							host: IsNull(),
-							isAdmin: true,
-						})) === 0,
+					requireSetup: (await countLocalUsers()) === 0,
 				}
 			: {}),
 	};
diff --git a/packages/backend/src/server/api/endpoints/stats.ts b/packages/backend/src/server/api/endpoints/stats.ts
index e0e4b4ec8a..794eef8f4e 100644
--- a/packages/backend/src/server/api/endpoints/stats.ts
+++ b/packages/backend/src/server/api/endpoints/stats.ts
@@ -1,6 +1,7 @@
 import { Instances, Users, Notes } from "@/models/index.js";
 import define from "@/server/api/define.js";
 import { IsNull } from "typeorm";
+import { countLocalUsers } from "backend-rs";
 
 export const meta = {
 	requireCredential: false,
@@ -69,11 +70,7 @@ export default define(meta, paramDef, async () => {
 		// usersCount
 		Users.count(),
 		// originalUsersCount
-		Users.count({
-			where: {
-				host: IsNull(),
-			},
-		}),
+		countLocalUsers(),
 		// instances
 		Instances.count(),
 	]);
diff --git a/packages/backend/src/server/api/mastodon/helpers/misc.ts b/packages/backend/src/server/api/mastodon/helpers/misc.ts
index 3af8b78dce..96d6251252 100644
--- a/packages/backend/src/server/api/mastodon/helpers/misc.ts
+++ b/packages/backend/src/server/api/mastodon/helpers/misc.ts
@@ -1,6 +1,6 @@
 import { config } from "@/config.js";
 import { FILE_TYPE_BROWSERSAFE } from "backend-rs";
-import { fetchMeta } from "backend-rs";
+import { countLocalUsers, fetchMeta } from "backend-rs";
 import {
 	AnnouncementReads,
 	Announcements,
@@ -31,7 +31,7 @@ export class MiscHelpers {
 	public static async getInstance(
 		ctx: MastoContext,
 	): Promise<MastodonEntity.Instance> {
-		const userCount = Users.count({ where: { host: IsNull() } });
+		const userCount = countLocalUsers();
 		const noteCount = Notes.count({ where: { userHost: IsNull() } });
 		const instanceCount = Instances.count({ cache: 3600000 });
 		const contact = await Users.findOne({
@@ -109,7 +109,7 @@ export class MiscHelpers {
 	public static async getInstanceV2(
 		ctx: MastoContext,
 	): Promise<MastodonEntity.InstanceV2> {
-		const userCount = await Users.count({ where: { host: IsNull() } });
+		const userCount = await countLocalUsers();
 		const contact = await Users.findOne({
 			where: {
 				host: IsNull(),