This commit is contained in:
Jason Ross
2026-03-30 21:08:20 -05:00
parent 29fea31800
commit 0f3c951fc1
71 changed files with 8453 additions and 48 deletions
+13
View File
@@ -0,0 +1,13 @@
import { sqlite } from "./sqlite.js";
const insertAuditLog = sqlite.prepare<[string, string, string | null]>(
"INSERT INTO audit_log (action, target, details) VALUES (?, ?, ?)"
);
export function logAction(
action: string,
target: string,
details?: Record<string, unknown>
): void {
insertAuditLog.run(action, target, details ? JSON.stringify(details) : null);
}
+80
View File
@@ -0,0 +1,80 @@
import { MongoClient, type Collection, type Db } from "mongodb";
import { env } from "../lib/env.js";
import type {
AccountDocument,
InviteDocument,
SessionDocument,
StrikeDocument,
UserDocument
} from "./types.js";
let client: MongoClient | null = null;
let db: Db | null = null;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export async function connectMongo(): Promise<Db> {
if (db) {
return db;
}
let attempt = 0;
for (;;) {
try {
client = new MongoClient(env.MONGODB);
await client.connect();
db = client.db("revolt");
return db;
} catch (error) {
const waitMs = Math.min(1000 * 2 ** attempt, 30_000);
attempt += 1;
console.error(
`MongoDB connection failed. Retrying in ${waitMs}ms.`,
error
);
await delay(waitMs);
}
}
}
export function getDb(): Db {
if (!db) {
throw new Error("MongoDB has not been connected yet");
}
return db;
}
export function accounts(): Collection<AccountDocument> {
return getDb().collection<AccountDocument>("accounts");
}
export function users(): Collection<UserDocument> {
return getDb().collection<UserDocument>("users");
}
export function sessions(): Collection<SessionDocument> {
return getDb().collection<SessionDocument>("sessions");
}
export function invites(): Collection<InviteDocument> {
return getDb().collection<InviteDocument>("invites");
}
export function safetyStrikes(): Collection<StrikeDocument> {
return getDb().collection<StrikeDocument>("safety_strikes");
}
export async function closeMongo(): Promise<void> {
if (client) {
await client.close();
client = null;
db = null;
}
}
+103
View File
@@ -0,0 +1,103 @@
import { existsSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import Database from "better-sqlite3";
import type { AdminUserRecord, InviteRecord } from "./types.js";
function resolveSqlitePath(): string {
if (existsSync("/data")) {
return "/data/admin.db";
}
return resolve(process.cwd(), "data", "admin.db");
}
export const sqlitePath = resolveSqlitePath();
mkdirSync(dirname(sqlitePath), { recursive: true });
export const sqlite = new Database(sqlitePath);
sqlite.pragma("journal_mode = WAL");
sqlite.exec(`
CREATE TABLE IF NOT EXISTS admin_user (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS invite_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT,
accepted_at TEXT,
resend_message_id TEXT
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
target TEXT NOT NULL,
details TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
export const statements = {
getAdminUserByUsername: sqlite.prepare<[string], AdminUserRecord>(
"SELECT id, username, password_hash FROM admin_user WHERE username = ?"
),
getFirstAdminUser: sqlite.prepare<[], AdminUserRecord>(
"SELECT id, username, password_hash FROM admin_user ORDER BY id ASC LIMIT 1"
),
insertAdminUser: sqlite.prepare<[string, string]>(
"INSERT INTO admin_user (id, username, password_hash) VALUES (1, ?, ?)"
),
updateAdminPasswordByUsername: sqlite.prepare<[string, string]>(
"UPDATE admin_user SET password_hash = ? WHERE username = ?"
),
listInviteRecords: sqlite.prepare<[], InviteRecord>(
`SELECT id, code, email, status, created_at, expires_at, accepted_at, resend_message_id
FROM invite_records
ORDER BY created_at DESC`
),
countInviteRecords: sqlite.prepare<[], { count: number }>(
"SELECT COUNT(*) AS count FROM invite_records"
),
countPendingInvites: sqlite.prepare<[], { count: number }>(
"SELECT COUNT(*) AS count FROM invite_records WHERE status = 'pending'"
),
insertInviteRecord: sqlite.prepare<[string, string, string | null]>(
"INSERT INTO invite_records (code, email, status, expires_at) VALUES (?, ?, 'pending', ?)"
),
getInviteRecordByCode: sqlite.prepare<[string], InviteRecord>(
`SELECT id, code, email, status, created_at, expires_at, accepted_at, resend_message_id
FROM invite_records
WHERE code = ?`
),
updateInviteResendMessage: sqlite.prepare<[string | null, string]>(
"UPDATE invite_records SET resend_message_id = ? WHERE code = ?"
),
markInviteRevoked: sqlite.prepare<[string]>(
"UPDATE invite_records SET status = 'revoked' WHERE code = ?"
),
selectPendingInvites: sqlite.prepare<[], InviteRecord>(
`SELECT id, code, email, status, created_at, expires_at, accepted_at, resend_message_id
FROM invite_records
WHERE status = 'pending'
ORDER BY created_at DESC`
),
markInviteAccepted: sqlite.prepare<[string]>(
"UPDATE invite_records SET status = 'accepted', accepted_at = datetime('now') WHERE code = ?"
),
markInviteExpired: sqlite.prepare<[string]>(
"UPDATE invite_records SET status = 'expired' WHERE code = ?"
),
countRecentBans: sqlite.prepare<[], { count: number }>(
"SELECT COUNT(*) AS count FROM audit_log WHERE action = 'user_banned' AND created_at > datetime('now', '-30 days')"
)
};
+70
View File
@@ -0,0 +1,70 @@
export interface AccountDocument {
_id: string;
email: string;
email_normalised?: string;
disabled: boolean;
spam?: boolean;
verification?: {
status: "Verified" | "Pending" | "Moving";
};
deletion?: {
status: "Scheduled" | "WaitingForVerification" | "Deleted";
after?: string;
};
lockout?: {
attempts: number;
expiry: string;
};
}
export interface UserDocument {
_id: string;
username: string;
discriminator: string;
flags?: number;
avatar?: unknown;
}
export interface SessionDocument {
_id: string;
user_id: string;
}
export interface InviteDocument {
_id: string;
}
export interface StrikeDocument {
_id: string;
user_id: string;
reason: string;
type?: "strike" | "suspension" | "ban";
case_id?: string;
}
export type InviteRecordStatus = "pending" | "accepted" | "revoked" | "expired";
export interface InviteRecord {
id: number;
code: string;
email: string;
status: InviteRecordStatus;
created_at: string;
expires_at: string | null;
accepted_at: string | null;
resend_message_id: string | null;
}
export interface AuditLogRecord {
id: number;
action: string;
target: string;
details: string | null;
created_at: string;
}
export interface AdminUserRecord {
id: number;
username: string;
password_hash: string;
}