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;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import "dotenv/config";
|
||||
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import helmet from "helmet";
|
||||
import cron from "node-cron";
|
||||
|
||||
import "./db/sqlite.js";
|
||||
import { connectMongo } from "./db/mongo.js";
|
||||
import { syncInviteStatuses } from "./jobs/inviteSync.js";
|
||||
import { env } from "./lib/env.js";
|
||||
import { errorHandler, notFound } from "./middleware/errors.js";
|
||||
import { requireAuth, sessionMiddleware } from "./middleware/auth.js";
|
||||
import { authRouter } from "./routes/auth.js";
|
||||
import { dashboardRouter } from "./routes/dashboard.js";
|
||||
import { invitesRouter } from "./routes/invites.js";
|
||||
import { usersRouter } from "./routes/users.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await connectMongo();
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
crossOriginResourcePolicy: false
|
||||
})
|
||||
);
|
||||
app.use(
|
||||
cors({
|
||||
origin: env.ADMIN_WEB_ORIGIN,
|
||||
credentials: true
|
||||
})
|
||||
);
|
||||
app.use(express.json());
|
||||
app.use(sessionMiddleware);
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.status(200).json({ ok: true });
|
||||
});
|
||||
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api/invites", requireAuth, invitesRouter);
|
||||
app.use("/api/users", requireAuth, usersRouter);
|
||||
app.use("/api/dashboard", requireAuth, dashboardRouter);
|
||||
|
||||
app.use(notFound);
|
||||
app.use(errorHandler);
|
||||
|
||||
await syncInviteStatuses();
|
||||
cron.schedule("*/5 * * * *", () => {
|
||||
void syncInviteStatuses().catch((error) => {
|
||||
console.error("Invite sync failed", error);
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(env.ADMIN_API_PORT, () => {
|
||||
console.log(`admin-api listening on :${env.ADMIN_API_PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error("Failed to start admin-api", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { invites } from "../db/mongo.js";
|
||||
import { statements } from "../db/sqlite.js";
|
||||
|
||||
export async function syncInviteStatuses(): Promise<void> {
|
||||
const pendingInvites = statements.selectPendingInvites.all();
|
||||
|
||||
for (const record of pendingInvites) {
|
||||
const inviteExists = await invites().findOne({ _id: record.code });
|
||||
|
||||
if (!inviteExists) {
|
||||
statements.markInviteAccepted.run(record.code);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
record.expires_at &&
|
||||
new Date(record.expires_at).getTime() < Date.now()
|
||||
) {
|
||||
statements.markInviteExpired.run(record.code);
|
||||
await invites().deleteOne({ _id: record.code });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NextFunction, Request, RequestHandler, Response } from "express";
|
||||
|
||||
type AsyncRouteHandler = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => Promise<void>;
|
||||
|
||||
export function asyncHandler(handler: AsyncRouteHandler): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
void handler(req, res, next).catch(next);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
MONGODB: z.string().min(1, "MONGODB is required"),
|
||||
RESEND_API_KEY: z.string().min(1, "RESEND_API_KEY is required"),
|
||||
RESEND_FROM_EMAIL: z
|
||||
.string()
|
||||
.email("RESEND_FROM_EMAIL must be a valid email"),
|
||||
SESSION_SECRET: z
|
||||
.string()
|
||||
.min(32, "SESSION_SECRET must be at least 32 characters"),
|
||||
INSTANCE_URL: z.string().url("INSTANCE_URL must be a valid URL"),
|
||||
INSTANCE_NAME: z.string().min(1, "INSTANCE_NAME is required"),
|
||||
ADMIN_API_PORT: z.coerce.number().int().positive().default(5181),
|
||||
ADMIN_WEB_ORIGIN: z.string().url("ADMIN_WEB_ORIGIN must be a valid URL")
|
||||
});
|
||||
|
||||
const parsedEnv = envSchema.safeParse(process.env);
|
||||
|
||||
if (!parsedEnv.success) {
|
||||
console.error("Invalid environment configuration.");
|
||||
console.error(JSON.stringify(parsedEnv.error.flatten().fieldErrors, null, 2));
|
||||
throw new Error("Environment validation failed");
|
||||
}
|
||||
|
||||
export const env = parsedEnv.data;
|
||||
@@ -0,0 +1,10 @@
|
||||
export const USER_FLAG_SUSPENDED = 1;
|
||||
export const USER_FLAG_DELETED = 2;
|
||||
export const USER_FLAG_BANNED = 4;
|
||||
|
||||
export function hasFlag(
|
||||
flags: number | null | undefined,
|
||||
mask: number
|
||||
): boolean {
|
||||
return ((flags ?? 0) & mask) === mask;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import session from "express-session";
|
||||
import connectSqlite3 from "better-sqlite3-session-store";
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
import { env } from "../lib/env.js";
|
||||
import { sqlite } from "../db/sqlite.js";
|
||||
|
||||
const SQLiteStore = connectSqlite3(session);
|
||||
export const SESSION_COOKIE_NAME = "stoat-admin.sid";
|
||||
|
||||
export const sessionMiddleware = session({
|
||||
name: SESSION_COOKIE_NAME,
|
||||
secret: env.SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: new SQLiteStore({
|
||||
client: sqlite,
|
||||
expired: {
|
||||
clear: true,
|
||||
intervalMs: 15 * 60 * 1000
|
||||
}
|
||||
}),
|
||||
cookie: {
|
||||
maxAge: 2 * 60 * 60 * 1000,
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: false
|
||||
}
|
||||
});
|
||||
|
||||
export const requireAuth: RequestHandler = (req, res, next) => {
|
||||
if (!req.session.userId) {
|
||||
res.status(401).json({ error: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ErrorRequestHandler, RequestHandler } from "express";
|
||||
|
||||
export const notFound: RequestHandler = (_req, res) => {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
};
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import argon2 from "argon2";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { statements } from "../db/sqlite.js";
|
||||
import { asyncHandler } from "../lib/async-handler.js";
|
||||
import { requireAuth, SESSION_COOKIE_NAME } from "../middleware/auth.js";
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1)
|
||||
});
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
authRouter.post(
|
||||
"/login",
|
||||
asyncHandler(async (req, res) => {
|
||||
const credentials = loginSchema.parse(req.body);
|
||||
const user = statements.getAdminUserByUsername.get(credentials.username);
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({ error: "Invalid username or password" });
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await argon2.verify(
|
||||
user.password_hash,
|
||||
credentials.password
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
res.status(401).json({ error: "Invalid username or password" });
|
||||
return;
|
||||
}
|
||||
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
|
||||
res.status(200).json({ username: user.username });
|
||||
})
|
||||
);
|
||||
|
||||
authRouter.post(
|
||||
"/logout",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
req.session.destroy((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
res.clearCookie(SESSION_COOKIE_NAME);
|
||||
res.status(200).json({ success: true });
|
||||
})
|
||||
);
|
||||
|
||||
authRouter.get(
|
||||
"/me",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
res.status(200).json({ username: req.session.username });
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import { users } from "../db/mongo.js";
|
||||
import { statements } from "../db/sqlite.js";
|
||||
import { asyncHandler } from "../lib/async-handler.js";
|
||||
import { USER_FLAG_BANNED } from "../lib/flags.js";
|
||||
|
||||
export const dashboardRouter = Router();
|
||||
|
||||
dashboardRouter.get(
|
||||
"/stats",
|
||||
asyncHandler(async (_req, res) => {
|
||||
const [totalUsers, bannedUserAggregate] = await Promise.all([
|
||||
users().countDocuments({}),
|
||||
users()
|
||||
.aggregate([
|
||||
{ $match: { flags: { $bitsAllSet: USER_FLAG_BANNED } } },
|
||||
{ $count: "count" }
|
||||
])
|
||||
.toArray()
|
||||
]);
|
||||
|
||||
const pendingInvites = statements.countPendingInvites.get()?.count ?? 0;
|
||||
const recentBans = statements.countRecentBans.get()?.count ?? 0;
|
||||
const bannedUsers = bannedUserAggregate[0]?.count ?? 0;
|
||||
|
||||
res.status(200).json({
|
||||
totalUsers,
|
||||
bannedUsers,
|
||||
pendingInvites,
|
||||
recentBans
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Router } from "express";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { Resend } from "resend";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAction } from "../db/audit.js";
|
||||
import { invites } from "../db/mongo.js";
|
||||
import { statements } from "../db/sqlite.js";
|
||||
import type { InviteRecord } from "../db/types.js";
|
||||
import { asyncHandler } from "../lib/async-handler.js";
|
||||
import { env } from "../lib/env.js";
|
||||
|
||||
const inviteAlphabet =
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
const generateCode = customAlphabet(inviteAlphabet, 12);
|
||||
const resend = new Resend(env.RESEND_API_KEY);
|
||||
|
||||
const createInviteSchema = z.object({
|
||||
email: z.string().email(),
|
||||
expiresInHours: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(24 * 365)
|
||||
.optional()
|
||||
});
|
||||
|
||||
function getInviteRecordOrThrow(code: string): InviteRecord {
|
||||
const record = statements.getInviteRecordByCode.get(code);
|
||||
if (!record) {
|
||||
throw new Error(`Invite record ${code} not found after insert`);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
export const invitesRouter = Router();
|
||||
|
||||
invitesRouter.get(
|
||||
"/",
|
||||
asyncHandler(async (_req, res) => {
|
||||
const inviteRecords = statements.listInviteRecords.all();
|
||||
const count =
|
||||
statements.countInviteRecords.get()?.count ?? inviteRecords.length;
|
||||
|
||||
res.status(200).json({ invites: inviteRecords, count });
|
||||
})
|
||||
);
|
||||
|
||||
invitesRouter.post(
|
||||
"/",
|
||||
asyncHandler(async (req, res) => {
|
||||
const payload = createInviteSchema.parse(req.body);
|
||||
const code = generateCode();
|
||||
const expiresAt = payload.expiresInHours
|
||||
? new Date(
|
||||
Date.now() + payload.expiresInHours * 60 * 60 * 1000
|
||||
).toISOString()
|
||||
: null;
|
||||
|
||||
await invites().insertOne({ _id: code });
|
||||
statements.insertInviteRecord.run(code, payload.email, expiresAt);
|
||||
|
||||
let warning: string | undefined;
|
||||
|
||||
try {
|
||||
const response = await resend.emails.send({
|
||||
from: env.RESEND_FROM_EMAIL,
|
||||
to: payload.email,
|
||||
subject: `You've been invited to ${env.INSTANCE_NAME}`,
|
||||
text: `You've been invited to ${env.INSTANCE_NAME}.\n\nUse this invite link to register:\n${env.INSTANCE_URL}?invite=${code}`
|
||||
});
|
||||
|
||||
const messageId = response.data?.id ?? null;
|
||||
statements.updateInviteResendMessage.run(messageId, code);
|
||||
} catch (error) {
|
||||
console.error("Invite email delivery failed", error);
|
||||
warning = "Invite created but email delivery failed";
|
||||
}
|
||||
|
||||
logAction("invite_created", payload.email, {
|
||||
code,
|
||||
expires_at: expiresAt
|
||||
});
|
||||
|
||||
const record = getInviteRecordOrThrow(code);
|
||||
res.status(201).json({
|
||||
invite: record,
|
||||
...(warning ? { warning } : {})
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
invitesRouter.delete(
|
||||
"/:code",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { code } = z.object({ code: z.string().min(1) }).parse(req.params);
|
||||
const record = statements.getInviteRecordByCode.get(code);
|
||||
|
||||
if (!record) {
|
||||
res.status(404).json({ error: "Invite not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.status !== "pending") {
|
||||
res.status(400).json({ error: "Only pending invites can be revoked" });
|
||||
return;
|
||||
}
|
||||
|
||||
await invites().deleteOne({ _id: code });
|
||||
statements.markInviteRevoked.run(code);
|
||||
logAction("invite_revoked", record.email, { code });
|
||||
|
||||
res.status(200).json({ success: true });
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,241 @@
|
||||
import { Router } from "express";
|
||||
import { ulid } from "ulid";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAction } from "../db/audit.js";
|
||||
import { accounts, safetyStrikes, sessions, users } from "../db/mongo.js";
|
||||
import { asyncHandler } from "../lib/async-handler.js";
|
||||
import { USER_FLAG_BANNED, USER_FLAG_DELETED } from "../lib/flags.js";
|
||||
|
||||
const listUsersSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
search: z.string().trim().optional()
|
||||
});
|
||||
|
||||
const userIdParamsSchema = z.object({
|
||||
id: z.string().min(1)
|
||||
});
|
||||
|
||||
const banSchema = z.object({
|
||||
reason: z.string().trim().min(1)
|
||||
});
|
||||
|
||||
const deleteSchema = z.object({
|
||||
reason: z.string().trim().optional()
|
||||
});
|
||||
|
||||
export const usersRouter = Router();
|
||||
|
||||
usersRouter.get(
|
||||
"/",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { page, limit, search } = listUsersSchema.parse(req.query);
|
||||
|
||||
const basePipeline = [
|
||||
{
|
||||
$lookup: {
|
||||
from: "accounts",
|
||||
localField: "_id",
|
||||
foreignField: "_id",
|
||||
as: "account",
|
||||
pipeline: [
|
||||
{
|
||||
$project: {
|
||||
email: 1,
|
||||
disabled: 1,
|
||||
verification: 1,
|
||||
deletion: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
$unwind: {
|
||||
path: "$account",
|
||||
preserveNullAndEmptyArrays: true
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const searchStage = search
|
||||
? [
|
||||
{
|
||||
$match: {
|
||||
"account.email": {
|
||||
$regex: search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||
$options: "i"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
const recordsPipeline = [
|
||||
...basePipeline,
|
||||
...searchStage,
|
||||
{ $sort: { username: 1, discriminator: 1, _id: 1 } },
|
||||
{ $skip: (page - 1) * limit },
|
||||
{ $limit: limit },
|
||||
{
|
||||
$project: {
|
||||
_id: 1,
|
||||
username: 1,
|
||||
discriminator: 1,
|
||||
flags: 1,
|
||||
avatar: 1,
|
||||
account: 1
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const totalPipeline = [
|
||||
...basePipeline,
|
||||
...searchStage,
|
||||
{ $count: "total" }
|
||||
];
|
||||
|
||||
const [userRecords, totalResult] = await Promise.all([
|
||||
users().aggregate(recordsPipeline).toArray(),
|
||||
users().aggregate(totalPipeline).toArray()
|
||||
]);
|
||||
|
||||
res.status(200).json({
|
||||
users: userRecords,
|
||||
total: totalResult[0]?.total ?? 0,
|
||||
page,
|
||||
limit
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
usersRouter.get(
|
||||
"/:id",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { id } = userIdParamsSchema.parse(req.params);
|
||||
|
||||
const [user, account, strikes] = await Promise.all([
|
||||
users().findOne({ _id: id }),
|
||||
accounts().findOne({ _id: id }),
|
||||
safetyStrikes().find({ user_id: id }).sort({ _id: -1 }).toArray()
|
||||
]);
|
||||
|
||||
if (!user && !account) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ user, account, strikes });
|
||||
})
|
||||
);
|
||||
|
||||
usersRouter.post(
|
||||
"/:id/ban",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { id } = userIdParamsSchema.parse(req.params);
|
||||
const { reason } = banSchema.parse(req.body);
|
||||
|
||||
const [user, account] = await Promise.all([
|
||||
users().findOne({ _id: id }),
|
||||
accounts().findOne({ _id: id })
|
||||
]);
|
||||
|
||||
if (!user || !account) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (account.disabled) {
|
||||
res.status(400).json({ error: "User is already banned" });
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
accounts().updateOne({ _id: id }, { $set: { disabled: true } }),
|
||||
users().updateOne(
|
||||
{ _id: id },
|
||||
{ $set: { flags: (user.flags ?? 0) | USER_FLAG_BANNED } }
|
||||
),
|
||||
sessions().deleteMany({ user_id: id }),
|
||||
safetyStrikes().insertOne({
|
||||
_id: ulid(),
|
||||
user_id: id,
|
||||
reason,
|
||||
type: "ban"
|
||||
})
|
||||
]);
|
||||
|
||||
logAction("user_banned", id, { reason });
|
||||
res.status(200).json({ success: true });
|
||||
})
|
||||
);
|
||||
|
||||
usersRouter.post(
|
||||
"/:id/unban",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { id } = userIdParamsSchema.parse(req.params);
|
||||
|
||||
const [user, account] = await Promise.all([
|
||||
users().findOne({ _id: id }),
|
||||
accounts().findOne({ _id: id })
|
||||
]);
|
||||
|
||||
if (!account || !user) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!account.disabled) {
|
||||
res.status(400).json({ error: "User is not banned" });
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
accounts().updateOne({ _id: id }, { $set: { disabled: false } }),
|
||||
users().updateOne(
|
||||
{ _id: id },
|
||||
{ $set: { flags: (user.flags ?? 0) & ~USER_FLAG_BANNED } }
|
||||
)
|
||||
]);
|
||||
|
||||
logAction("user_unbanned", id);
|
||||
res.status(200).json({ success: true });
|
||||
})
|
||||
);
|
||||
|
||||
usersRouter.delete(
|
||||
"/:id",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { id } = userIdParamsSchema.parse(req.params);
|
||||
const { reason } = deleteSchema.parse(req.body ?? {});
|
||||
const user = await users().findOne({ _id: id });
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
accounts().updateOne(
|
||||
{ _id: id },
|
||||
{
|
||||
$set: {
|
||||
deletion: {
|
||||
status: "Scheduled",
|
||||
after: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
users().updateOne(
|
||||
{ _id: id },
|
||||
{ $set: { flags: (user.flags ?? 0) | USER_FLAG_DELETED } }
|
||||
),
|
||||
sessions().deleteMany({ user_id: id })
|
||||
]);
|
||||
|
||||
logAction("user_deleted", id, reason ? { reason } : undefined);
|
||||
res.status(200).json({ success: true });
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,96 @@
|
||||
import "dotenv/config";
|
||||
|
||||
import argon2 from "argon2";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { statements } from "./db/sqlite.js";
|
||||
|
||||
type SeedArgs = {
|
||||
username?: string;
|
||||
password?: string;
|
||||
"reset-password"?: boolean;
|
||||
};
|
||||
|
||||
async function promptForMissing(
|
||||
args: SeedArgs
|
||||
): Promise<{ username: string; password: string }> {
|
||||
const readline = createInterface({ input, output });
|
||||
|
||||
try {
|
||||
const username = args.username ?? (await readline.question("Username: "));
|
||||
const password = args.password ?? (await readline.question("Password: "));
|
||||
|
||||
return {
|
||||
username: username.trim(),
|
||||
password: password.trim()
|
||||
};
|
||||
} finally {
|
||||
readline.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const parsed = parseArgs({
|
||||
options: {
|
||||
username: {
|
||||
type: "string"
|
||||
},
|
||||
password: {
|
||||
type: "string"
|
||||
},
|
||||
"reset-password": {
|
||||
type: "boolean",
|
||||
default: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const args = parsed.values as SeedArgs;
|
||||
const existingUser = statements.getFirstAdminUser.get();
|
||||
|
||||
if (existingUser && !args["reset-password"]) {
|
||||
console.error(
|
||||
"An admin user already exists. Use --reset-password to update it."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { username, password } = await promptForMissing({
|
||||
username: args.username ?? existingUser?.username,
|
||||
password: args.password,
|
||||
"reset-password": args["reset-password"]
|
||||
});
|
||||
|
||||
if (!username || !password) {
|
||||
console.error("Username and password are required.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(password, {
|
||||
type: argon2.argon2id
|
||||
});
|
||||
|
||||
if (args["reset-password"]) {
|
||||
if (!existingUser) {
|
||||
console.error(
|
||||
"No admin user exists yet. Run the seed script without --reset-password first."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targetUsername = existingUser?.username ?? username;
|
||||
statements.updateAdminPasswordByUsername.run(passwordHash, targetUsername);
|
||||
console.log(`Password updated for ${targetUsername}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
statements.insertAdminUser.run(username, passwordHash);
|
||||
console.log(`Admin user ${username} created.`);
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
declare module "better-sqlite3-session-store" {
|
||||
import type session from "express-session";
|
||||
|
||||
interface SqliteStoreOptions {
|
||||
client: unknown;
|
||||
expired?: {
|
||||
clear?: boolean;
|
||||
intervalMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface SqliteStoreConstructor {
|
||||
new (options: SqliteStoreOptions): session.Store;
|
||||
}
|
||||
|
||||
export default function connectSqlite3(
|
||||
sessionModule: typeof session
|
||||
): SqliteStoreConstructor;
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
import "express-session";
|
||||
|
||||
declare module "express-session" {
|
||||
interface SessionData {
|
||||
userId?: number;
|
||||
username?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user