From 5761cbaa55d06a1fef93b5221430a9dd3b3113f8 Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Thu, 26 Feb 2026 08:53:50 +0100 Subject: [PATCH] Added routes for login and bug reporting --- compose.yml | 1 - init/init.sql | 6 -- package.json | 1 + src/index.ts | 13 ++- src/routes/admin.ts | 132 ++++++++++++++++++++++++++++++- src/routes/auth.ts | 55 +++++++++++++ src/services/authService.ts | 124 +++++++++++++++++++++++++++++ src/services/bugReportService.ts | 82 +++++++++++++++++-- src/services/userService.ts | 120 ++++++++++++++++++++++++++++ 9 files changed, 518 insertions(+), 16 deletions(-) create mode 100644 src/routes/auth.ts create mode 100644 src/services/authService.ts create mode 100644 src/services/userService.ts diff --git a/compose.yml b/compose.yml index 4f96000..51690a2 100644 --- a/compose.yml +++ b/compose.yml @@ -67,7 +67,6 @@ services: - "traefik.http.services.emly-api.loadbalancer.server.port=3000" depends_on: - emly-mysql-db - volumes: mysql-data: emly-api-logs: \ No newline at end of file diff --git a/init/init.sql b/init/init.sql index 6c6e218..044146c 100644 --- a/init/init.sql +++ b/init/init.sql @@ -54,9 +54,3 @@ CREATE TABLE IF NOT EXISTS `session` ( CONSTRAINT `fk_session_user` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER hwid; -ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS os_user VARCHAR(255) NOT NULL DEFAULT '' AFTER hostname; -ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_hostname (hostname); -ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_os_user (os_user); -ALTER TABLE user ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE AFTER role; - diff --git a/package.json b/package.json index ff1aae9..c6d939b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dependencies": { "@node-rs/argon2": "^2.0.2", "elysia": "^1.2.0", + "jszip": "^3.10.1", "mysql2": "^3.11.0" }, "devDependencies": { diff --git a/src/index.ts b/src/index.ts index 5587c11..e16fcee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { runMigrations } from "./db/migrate"; import { closePool } from "./db/connection"; import { bugReportRoutes } from "./routes/bugReports"; import { adminRoutes } from "./routes/admin"; +import { authRoutes } from "./routes/auth"; import { initLogger, Log } from "./logger"; const INSTANCE_ID = process.env.HOSTNAME + "_" + Math.random().toString(16).slice(2, 6); @@ -35,7 +36,16 @@ const app = new Elysia() const url = new URL(request.url); Log("HTTP", `${request.method} ${url.pathname} -> ${set.status ?? 200}`); }) - .onError(({ error, set }) => { + .onError(({ error, set, code }) => { + console.error("Error processing request:", error); + if (code === "NOT_FOUND") { + set.status = 404; + return { success: false, message: "Not found" }; + } + if (code === "VALIDATION") { + set.status = 422; + return { success: false, message: "Validation error" }; + } Log("ERROR", "Unhandled error:", error); set.status = 500; return { success: false, message: "Internal server error" }; @@ -43,6 +53,7 @@ const app = new Elysia() .get("/health", () => ({ status: "ok", instance: INSTANCE_ID, timestamp: new Date().toISOString() })) .get("/", () => ({ status: "ok", message: "API is running" })) .use(bugReportRoutes) + .use(authRoutes) .use(adminRoutes) .listen({ port: config.port, diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 3bc3e72..94b6a99 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -6,7 +6,17 @@ import { getFile, deleteBugReport, updateBugReportStatus, + countNewReports, + generateReportZip, } from "../services/bugReportService"; +import { + listUsers, + createUser, + updateUser, + resetPassword, + deleteUser, + getUserById, +} from "../services/userService"; import { Log } from "../logger"; import type { BugReportStatus } from "../types"; @@ -18,9 +28,10 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) const page = parseInt(query.page || "1"); const pageSize = Math.min(parseInt(query.pageSize || "20"), 100); const status = query.status as BugReportStatus | undefined; + const search = query.search || undefined; - Log("ADMIN", `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"}`); - return await listBugReports({ page, pageSize, status }); + Log("ADMIN", `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"} search=${search || ""}`); + return await listBugReports({ page, pageSize, status, search }); }, { query: t.Object({ @@ -34,10 +45,19 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) t.Literal("closed"), ]) ), + search: t.Optional(t.String()), }), - detail: { summary: "List bug reports (paginated)" }, + detail: { summary: "List bug reports (paginated, filterable)" }, } ) + .get( + "/bug-reports/count", + async () => { + const count = await countNewReports(); + return { count }; + }, + { detail: { summary: "Count new bug reports" } } + ) .get( "/bug-reports/:id", async ({ params, error }) => { @@ -93,6 +113,25 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) detail: { summary: "Download a bug report file" }, } ) + .get( + "/bug-reports/:id/download", + async ({ params, error, set }) => { + Log("ADMIN", `Download zip for report id=${params.id}`); + const zipBuffer = await generateReportZip(parseInt(params.id)); + if (!zipBuffer) + return error(404, { success: false, message: "Report not found" }); + + set.headers["content-type"] = "application/zip"; + set.headers["content-disposition"] = + `attachment; filename="report-${params.id}.zip"`; + set.headers["content-length"] = String(zipBuffer.length); + return new Response(new Uint8Array(zipBuffer)); + }, + { + params: t.Object({ id: t.String() }), + detail: { summary: "Download all files for a bug report as ZIP" }, + } + ) .delete( "/bug-reports/:id", async ({ params, error }) => { @@ -106,4 +145,91 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) params: t.Object({ id: t.String() }), detail: { summary: "Delete a bug report and its files" }, } + ) + // User management + .get( + "/users", + async () => { + Log("ADMIN", "List users"); + return await listUsers(); + }, + { detail: { summary: "List all users" } } + ) + .post( + "/users", + async ({ body, error }) => { + Log("ADMIN", `Create user username=${body.username}`); + try { + const user = await createUser(body); + return { success: true, user }; + } catch (err) { + if (err instanceof Error && err.message === "Username already exists") { + return error(409, { success: false, message: err.message }); + } + throw err; + } + }, + { + body: t.Object({ + username: t.String({ minLength: 3, maxLength: 255 }), + displayname: t.String({ default: "" }), + password: t.String({ minLength: 1 }), + role: t.Union([t.Literal("admin"), t.Literal("user")]), + }), + detail: { summary: "Create a new user" }, + } + ) + .patch( + "/users/:id", + async ({ params, body, error }) => { + Log("ADMIN", `Update user id=${params.id}`); + const updated = await updateUser(params.id, body); + if (!updated) + return error(404, { success: false, message: "User not found" }); + return { success: true, message: "User updated" }; + }, + { + params: t.Object({ id: t.String() }), + body: t.Object({ + displayname: t.Optional(t.String()), + enabled: t.Optional(t.Boolean()), + }), + detail: { summary: "Update user displayname or enabled status" }, + } + ) + .post( + "/users/:id/reset-password", + async ({ params, body, error }) => { + Log("ADMIN", `Reset password for user id=${params.id}`); + const updated = await resetPassword(params.id, body.password); + if (!updated) + return error(404, { success: false, message: "User not found" }); + return { success: true, message: "Password reset" }; + }, + { + params: t.Object({ id: t.String() }), + body: t.Object({ password: t.String({ minLength: 1 }) }), + detail: { summary: "Reset a user's password" }, + } + ) + .delete( + "/users/:id", + async ({ params, error }) => { + Log("ADMIN", `Delete user id=${params.id}`); + + const user = await getUserById(params.id); + if (!user) + return error(404, { success: false, message: "User not found" }); + if (user.role === "admin") + return error(400, { success: false, message: "Cannot delete an admin user" }); + + const deleted = await deleteUser(params.id); + if (!deleted) + return error(404, { success: false, message: "User not found" }); + return { success: true, message: "User deleted" }; + }, + { + params: t.Object({ id: t.String() }), + detail: { summary: "Delete a user (non-admin only)" }, + } ); diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..6ce68a7 --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,55 @@ +import { Elysia, t } from "elysia"; +import { adminKeyGuard } from "../middleware/auth"; +import { loginUser, validateSession, logoutSession } from "../services/authService"; +import { Log } from "../logger"; + +export const authRoutes = new Elysia({ prefix: "/api/admin/auth" }) + .use(adminKeyGuard) + .post( + "/login", + async ({ body, error }) => { + const result = await loginUser(body.username, body.password); + if (!result) { + Log("AUTH", `Login failed for username=${body.username}`); + return error(401, { success: false, message: "Invalid credentials or account disabled" }); + } + return { success: true, session_id: result.session_id, user: result.user }; + }, + { + body: t.Object({ + username: t.String({ minLength: 1 }), + password: t.String({ minLength: 1 }), + }), + detail: { summary: "Login with username/password" }, + } + ) + .post( + "/logout", + async ({ headers }) => { + const sessionId = headers["x-session-token"]; + if (sessionId) { + await logoutSession(sessionId); + } + return { success: true, message: "Logged out" }; + }, + { + detail: { summary: "Logout and invalidate session" }, + } + ) + .get( + "/validate", + async ({ headers, error }) => { + const sessionId = headers["x-session-token"]; + if (!sessionId) { + return error(401, { success: false, message: "No session token provided" }); + } + const user = await validateSession(sessionId); + if (!user) { + return error(401, { success: false, message: "Invalid or expired session" }); + } + return { success: true, user }; + }, + { + detail: { summary: "Validate session and return user" }, + } + ); diff --git a/src/services/authService.ts b/src/services/authService.ts new file mode 100644 index 0000000..bf0d5de --- /dev/null +++ b/src/services/authService.ts @@ -0,0 +1,124 @@ +import { hash, verify } from "@node-rs/argon2"; +import type { ResultSetHeader, RowDataPacket } from "mysql2"; +import { getPool } from "../db/connection"; +import { Log } from "../logger"; + +const ARGON2_OPTIONS = { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, +} as const; + +const SESSION_EXPIRY_DAYS = 30; + +export interface AuthUser { + id: string; + username: string; + displayname: string; + role: "admin" | "user"; + enabled: boolean; +} + +function generateSessionId(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export async function loginUser( + username: string, + password: string +): Promise<{ session_id: string; user: AuthUser } | null> { + const pool = getPool(); + + const [rows] = await pool.execute( + "SELECT id, username, displayname, password_hash, role, enabled FROM `user` WHERE username = ? LIMIT 1", + [username] + ); + + if ((rows as unknown[]).length === 0) return null; + + const row = rows[0] as { + id: string; + username: string; + displayname: string; + password_hash: string; + role: "admin" | "user"; + enabled: boolean; + }; + + const valid = await verify(row.password_hash, password, ARGON2_OPTIONS); + if (!valid) return null; + + if (!row.enabled) return null; + + const sessionId = generateSessionId(); + const expiresAt = new Date(Date.now() + SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000); + + await pool.execute( + "INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?)", + [sessionId, row.id, expiresAt] + ); + + Log("AUTH", `User logged in: username=${username} session=${sessionId.slice(0, 8)}...`); + + return { + session_id: sessionId, + user: { + id: row.id, + username: row.username, + displayname: row.displayname, + role: row.role, + enabled: row.enabled, + }, + }; +} + +export async function validateSession( + sessionId: string +): Promise { + const pool = getPool(); + + const [rows] = await pool.execute( + `SELECT u.id, u.username, u.displayname, u.role, u.enabled, s.expires_at + FROM session s + JOIN \`user\` u ON u.id = s.user_id + WHERE s.id = ? LIMIT 1`, + [sessionId] + ); + + if ((rows as unknown[]).length === 0) return null; + + const row = rows[0] as { + id: string; + username: string; + displayname: string; + role: "admin" | "user"; + enabled: boolean; + expires_at: Date; + }; + + if (new Date() > new Date(row.expires_at)) { + await pool.execute("DELETE FROM session WHERE id = ?", [sessionId]); + return null; + } + + if (!row.enabled) return null; + + return { + id: row.id, + username: row.username, + displayname: row.displayname, + role: row.role, + enabled: row.enabled, + }; +} + +export async function logoutSession(sessionId: string): Promise { + const pool = getPool(); + await pool.execute("DELETE FROM session WHERE id = ?", [sessionId]); + Log("AUTH", `Session logged out: ${sessionId.slice(0, 8)}...`); +} diff --git a/src/services/bugReportService.ts b/src/services/bugReportService.ts index 6ab6056..13b0fbc 100644 --- a/src/services/bugReportService.ts +++ b/src/services/bugReportService.ts @@ -1,4 +1,5 @@ import type { ResultSetHeader, RowDataPacket } from "mysql2"; +import JSZip from "jszip"; import { getPool } from "../db/connection"; import type { BugReport, @@ -65,19 +66,31 @@ export async function listBugReports(opts: { page: number; pageSize: number; status?: BugReportStatus; + search?: string; }): Promise> { const pool = getPool(); - const { page, pageSize, status } = opts; + const { page, pageSize, status, search } = opts; const offset = (page - 1) * pageSize; - let whereClause = ""; + const conditions: string[] = []; const params: unknown[] = []; if (status) { - whereClause = "WHERE br.status = ?"; + conditions.push("br.status = ?"); params.push(status); } + if (search) { + const like = `%${search}%`; + conditions.push( + "(br.hostname LIKE ? OR br.os_user LIKE ? OR br.name LIKE ? OR br.email LIKE ?)" + ); + params.push(like, like, like, like); + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const [countRows] = await pool.execute( `SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`, params @@ -91,8 +104,8 @@ export async function listBugReports(opts: { ${whereClause} GROUP BY br.id ORDER BY br.created_at DESC - LIMIT ? OFFSET ?`, - [...params, pageSize, offset] + LIMIT ${pageSize} OFFSET ${offset}`, + params ); return { @@ -104,6 +117,65 @@ export async function listBugReports(opts: { }; } +export async function countNewReports(): Promise { + const pool = getPool(); + const [rows] = await pool.execute( + "SELECT COUNT(*) as count FROM bug_reports WHERE status = 'new'" + ); + return (rows[0] as { count: number }).count; +} + +export async function generateReportZip(reportId: number): Promise { + const pool = getPool(); + + const [reportRows] = await pool.execute( + "SELECT * FROM bug_reports WHERE id = ?", + [reportId] + ); + if ((reportRows as unknown[]).length === 0) return null; + + const report = reportRows[0] as BugReport; + + const [fileRows] = await pool.execute( + "SELECT * FROM bug_report_files WHERE report_id = ?", + [reportId] + ); + const files = fileRows as BugReportFile[]; + + const zip = new JSZip(); + + const reportText = [ + `Bug Report #${report.id}`, + `========================`, + ``, + `Name: ${report.name}`, + `Email: ${report.email}`, + `Hostname: ${report.hostname}`, + `OS User: ${report.os_user}`, + `HWID: ${report.hwid}`, + `IP: ${report.submitter_ip}`, + `Status: ${report.status}`, + `Created: ${report.created_at.toISOString()}`, + `Updated: ${report.updated_at.toISOString()}`, + ``, + `Description:`, + `------------`, + report.description, + ``, + ...(report.system_info + ? [`System Info:`, `------------`, JSON.stringify(report.system_info, null, 2)] + : []), + ].join("\n"); + + zip.file("report.txt", reportText); + + for (const file of files) { + zip.file(`${file.file_role}/${file.filename}`, file.data as Buffer); + } + + return zip.generateAsync({ type: "nodebuffer" }) as Promise; +} + export async function getBugReport( id: number ): Promise<{ report: BugReport; files: Omit[] } | null> { diff --git a/src/services/userService.ts b/src/services/userService.ts new file mode 100644 index 0000000..fe4972e --- /dev/null +++ b/src/services/userService.ts @@ -0,0 +1,120 @@ +import { hash } from "@node-rs/argon2"; +import type { ResultSetHeader, RowDataPacket } from "mysql2"; +import { randomUUID } from "crypto"; +import { getPool } from "../db/connection"; + +const ARGON2_OPTIONS = { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, +} as const; + +export interface User { + id: string; + username: string; + displayname: string; + role: "admin" | "user"; + enabled: boolean; + created_at: Date; +} + +export async function listUsers(): Promise { + const pool = getPool(); + const [rows] = await pool.execute( + "SELECT id, username, displayname, role, enabled, created_at FROM `user` ORDER BY created_at ASC" + ); + return rows as User[]; +} + +export async function createUser(data: { + username: string; + displayname: string; + password: string; + role: "admin" | "user"; +}): Promise { + const pool = getPool(); + + // Check for duplicate username + const [existing] = await pool.execute( + "SELECT id FROM `user` WHERE username = ? LIMIT 1", + [data.username] + ); + if ((existing as unknown[]).length > 0) { + throw new Error("Username already exists"); + } + + const passwordHash = await hash(data.password, ARGON2_OPTIONS); + const id = randomUUID(); + + await pool.execute( + "INSERT INTO `user` (id, username, displayname, password_hash, role) VALUES (?, ?, ?, ?, ?)", + [id, data.username, data.displayname, passwordHash, data.role] + ); + + const [rows] = await pool.execute( + "SELECT id, username, displayname, role, enabled, created_at FROM `user` WHERE id = ?", + [id] + ); + return (rows as User[])[0]; +} + +export async function updateUser( + id: string, + data: { displayname?: string; enabled?: boolean } +): Promise { + const pool = getPool(); + + const fields: string[] = []; + const params: unknown[] = []; + + if (data.displayname !== undefined) { + fields.push("displayname = ?"); + params.push(data.displayname); + } + if (data.enabled !== undefined) { + fields.push("enabled = ?"); + params.push(data.enabled); + } + + if (fields.length === 0) return false; + + params.push(id); + const [result] = await pool.execute( + `UPDATE \`user\` SET ${fields.join(", ")} WHERE id = ?`, + params + ); + return result.affectedRows > 0; +} + +export async function resetPassword( + id: string, + newPassword: string +): Promise { + const pool = getPool(); + const passwordHash = await hash(newPassword, ARGON2_OPTIONS); + const [result] = await pool.execute( + "UPDATE `user` SET password_hash = ? WHERE id = ?", + [passwordHash, id] + ); + return result.affectedRows > 0; +} + +export async function deleteUser(id: string): Promise { + const pool = getPool(); + const [result] = await pool.execute( + "DELETE FROM `user` WHERE id = ?", + [id] + ); + return result.affectedRows > 0; +} + +export async function getUserById(id: string): Promise { + const pool = getPool(); + const [rows] = await pool.execute( + "SELECT id, username, displayname, role, enabled, created_at FROM `user` WHERE id = ? LIMIT 1", + [id] + ); + if ((rows as unknown[]).length === 0) return null; + return (rows as User[])[0]; +}