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;
}
+65
View File
@@ -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);
});
+23
View File
@@ -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 });
}
}
}
+13
View File
@@ -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);
};
}
+29
View File
@@ -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;
+10
View File
@@ -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;
}
+38
View File
@@ -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();
};
+10
View File
@@ -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" });
};
+70
View File
@@ -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 });
})
);
+34
View File
@@ -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
});
})
);
+116
View File
@@ -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 });
})
);
+241
View File
@@ -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 });
})
);
+96
View File
@@ -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
View File
@@ -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;
}
+10
View File
@@ -0,0 +1,10 @@
import "express-session";
declare module "express-session" {
interface SessionData {
userId?: number;
username?: string;
}
}
export {};