Add the Vite frontend, Express API server, and supporting configuration files so the app can run locally on a complete development stack. Made-with: Cursor
250 lines
7.8 KiB
TypeScript
250 lines
7.8 KiB
TypeScript
import type { Express, Request, Response } from "express";
|
|
import type { RowDataPacket } from "mysql2";
|
|
import type mysql from "mysql2/promise";
|
|
import argon2 from "argon2";
|
|
import bcrypt from "bcryptjs";
|
|
|
|
const IDENTITY_CANDIDATES = [
|
|
"email",
|
|
"unique_name",
|
|
"phone",
|
|
"mobile",
|
|
"username",
|
|
"user_name",
|
|
"login_name",
|
|
"account",
|
|
"name",
|
|
"user_unique_id",
|
|
];
|
|
|
|
const PASSWORD_CANDIDATES = [
|
|
"password",
|
|
"passwd",
|
|
"pwd",
|
|
"user_password",
|
|
"password_hash",
|
|
"user_pwd",
|
|
"enc_password",
|
|
];
|
|
|
|
function safeIdent(raw: string): string {
|
|
if (!/^[a-zA-Z0-9_]+$/.test(raw)) {
|
|
throw new Error("Invalid SQL identifier");
|
|
}
|
|
return `\`${raw}\``;
|
|
}
|
|
|
|
/** Logical name from env; defaults to MySQL table `user` in database DB_NAME / MYSQL_DATABASE. */
|
|
function authTable(): string {
|
|
return process.env.AUTH_USER_TABLE?.trim() || process.env.DB_USER_TABLE?.trim() || "user";
|
|
}
|
|
|
|
/** Match INFORMATION_SCHEMA even when MySQL stores the name as User / USER. */
|
|
async function resolveActualTableName(
|
|
pool: mysql.Pool,
|
|
schema: string,
|
|
configured: string,
|
|
): Promise<string> {
|
|
const [exact] = await pool.query<RowDataPacket[]>(
|
|
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? LIMIT 1",
|
|
[schema, configured],
|
|
);
|
|
if (exact.length > 0) return String(exact[0].TABLE_NAME);
|
|
|
|
const [folded] = await pool.query<RowDataPacket[]>(
|
|
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND LOWER(TABLE_NAME) = LOWER(?) LIMIT 1",
|
|
[schema, configured],
|
|
);
|
|
if (folded.length > 0) {
|
|
const actual = String(folded[0].TABLE_NAME);
|
|
if (actual !== configured) {
|
|
console.log(`[auth] resolved table name "${configured}" -> "${actual}"`);
|
|
}
|
|
return actual;
|
|
}
|
|
|
|
throw new Error(`No table matching "${configured}" in database "${schema}"`);
|
|
}
|
|
|
|
async function resolveColumns(
|
|
pool: mysql.Pool,
|
|
schema: string,
|
|
table: string,
|
|
): Promise<{ identityCols: string[]; passwordCol: string | null }> {
|
|
const [rows] = await pool.query<RowDataPacket[]>(
|
|
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?",
|
|
[schema, table],
|
|
);
|
|
const cols = new Set(rows.map((r) => String(r.COLUMN_NAME)));
|
|
|
|
const envLogin = process.env.AUTH_LOGIN_COLUMNS?.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
const envPass = process.env.AUTH_PASSWORD_COLUMN?.trim();
|
|
|
|
const identityCols = envLogin?.length
|
|
? envLogin.filter((c) => cols.has(c))
|
|
: IDENTITY_CANDIDATES.filter((c) => cols.has(c));
|
|
|
|
const passwordCol =
|
|
envPass && cols.has(envPass)
|
|
? envPass
|
|
: PASSWORD_CANDIDATES.find((c) => cols.has(c)) ?? null;
|
|
|
|
return { identityCols, passwordCol };
|
|
}
|
|
|
|
let cachedColumns: { identityCols: string[]; passwordCol: string | null } | null = null;
|
|
let cachedTable = "";
|
|
|
|
async function getAuthColumns(pool: mysql.Pool, schema: string, table: string) {
|
|
if (cachedColumns && cachedTable === `${schema}.${table}`) return cachedColumns;
|
|
cachedColumns = await resolveColumns(pool, schema, table);
|
|
cachedTable = `${schema}.${table}`;
|
|
console.log(
|
|
`[auth] table=${table} identity=${cachedColumns.identityCols.join(",") || "(none)"} password=${cachedColumns.passwordCol ?? "(none)"}`,
|
|
);
|
|
return cachedColumns;
|
|
}
|
|
|
|
async function verifyPassword(plain: string, stored: string | null | undefined): Promise<boolean> {
|
|
if (stored == null) return false;
|
|
const s = String(stored);
|
|
if (s.startsWith("$argon2")) {
|
|
try {
|
|
return await argon2.verify(s, plain);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
if (s.length >= 59 && /^\$2[aby]\$\d{2}\$/.test(s)) {
|
|
try {
|
|
return await bcrypt.compare(plain, s);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
return plain === s;
|
|
}
|
|
|
|
function pickDisplayName(row: RowDataPacket, login: string): string {
|
|
const nick =
|
|
row.nickname ??
|
|
row.nick_name ??
|
|
row.name ??
|
|
row.user_name ??
|
|
row.username ??
|
|
row.unique_name;
|
|
if (nick != null && String(nick).trim() !== "") return String(nick);
|
|
return login;
|
|
}
|
|
|
|
function pickUserId(row: RowDataPacket): string | number | null {
|
|
const v = row.id ?? row.user_id ?? row.uid;
|
|
if (v != null && v !== "") return v as string | number;
|
|
return null;
|
|
}
|
|
|
|
export function registerAuthRoutes(app: Express, getPool: () => mysql.Pool): void {
|
|
app.get("/api/auth/status", async (_req: Request, res: Response) => {
|
|
try {
|
|
const pool = getPool();
|
|
const schema = process.env.MYSQL_DATABASE || process.env.DB_NAME || "";
|
|
const configured = authTable();
|
|
const resolved = await resolveActualTableName(pool, schema, configured);
|
|
const { identityCols, passwordCol } = await getAuthColumns(pool, schema, resolved);
|
|
res.json({
|
|
ok: true,
|
|
database: schema,
|
|
userTable: { configured, resolved },
|
|
loginColumns: identityCols,
|
|
passwordColumn: passwordCol,
|
|
});
|
|
} catch (err) {
|
|
console.error("[auth/status]", err);
|
|
res.status(503).json({
|
|
ok: false,
|
|
error: err instanceof Error ? err.message : "Auth status unavailable",
|
|
});
|
|
}
|
|
});
|
|
|
|
app.post("/api/auth/login", async (req: Request, res: Response) => {
|
|
const login = typeof req.body?.login === "string" ? req.body.login.trim() : "";
|
|
const password = typeof req.body?.password === "string" ? req.body.password : "";
|
|
if (!login || !password) {
|
|
res.status(400).json({ ok: false, error: "Missing login or password" });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const pool = getPool();
|
|
const schema = process.env.MYSQL_DATABASE || process.env.DB_NAME || "";
|
|
const configuredTable = authTable();
|
|
const table = await resolveActualTableName(pool, schema, configuredTable);
|
|
const { identityCols, passwordCol } = await getAuthColumns(pool, schema, table);
|
|
|
|
if (!passwordCol) {
|
|
console.error(
|
|
`[auth/login] Table "${table}" has no known password column; set AUTH_PASSWORD_COLUMN in .env`,
|
|
);
|
|
res.status(503).json({ ok: false, error: "Server auth is not configured" });
|
|
return;
|
|
}
|
|
if (identityCols.length === 0) {
|
|
console.error(
|
|
`[auth/login] Table "${table}" has no known login columns; set AUTH_LOGIN_COLUMNS in .env`,
|
|
);
|
|
res.status(503).json({ ok: false, error: "Server auth is not configured" });
|
|
return;
|
|
}
|
|
|
|
const t = safeIdent(table);
|
|
const orClause = identityCols.map((c) => `${safeIdent(c)} = ?`).join(" OR ");
|
|
const params = identityCols.map(() => login);
|
|
const sql = `SELECT * FROM ${t} WHERE ${orClause} LIMIT 1`;
|
|
|
|
const [found] = await pool.query<RowDataPacket[]>(sql, params);
|
|
const row = found[0];
|
|
if (!row) {
|
|
res.status(401).json({ ok: false, error: "Invalid credentials" });
|
|
return;
|
|
}
|
|
|
|
const storedHash = row[passwordCol] as string | null | undefined;
|
|
const ok = await verifyPassword(password, storedHash);
|
|
if (!ok) {
|
|
res.status(401).json({ ok: false, error: "Invalid credentials" });
|
|
return;
|
|
}
|
|
|
|
res.json({
|
|
ok: true,
|
|
user: {
|
|
id: pickUserId(row),
|
|
displayName: pickDisplayName(row, login),
|
|
login,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error("[auth/login]", err);
|
|
res.status(503).json({ ok: false, error: "Login temporarily unavailable" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/auth/admin", async (req: Request, res: Response) => {
|
|
const password = typeof req.body?.password === "string" ? req.body.password : "";
|
|
const expected = process.env.ADMIN_PASSWORD ?? "admin123";
|
|
if (!password) {
|
|
res.status(400).json({ ok: false, error: "Missing password" });
|
|
return;
|
|
}
|
|
const match = await verifyPassword(password, expected);
|
|
if (!match) {
|
|
res.status(401).json({ ok: false, error: "Invalid admin password" });
|
|
return;
|
|
}
|
|
res.json({ ok: true });
|
|
});
|
|
}
|