wip
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')"
|
||||
)
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user