Added routes for login and bug reporting
This commit is contained in:
@@ -67,7 +67,6 @@ services:
|
|||||||
- "traefik.http.services.emly-api.loadbalancer.server.port=3000"
|
- "traefik.http.services.emly-api.loadbalancer.server.port=3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
- emly-mysql-db
|
- emly-mysql-db
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql-data:
|
mysql-data:
|
||||||
emly-api-logs:
|
emly-api-logs:
|
||||||
@@ -54,9 +54,3 @@ CREATE TABLE IF NOT EXISTS `session` (
|
|||||||
CONSTRAINT `fk_session_user` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE
|
CONSTRAINT `fk_session_user` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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;
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"elysia": "^1.2.0",
|
"elysia": "^1.2.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"mysql2": "^3.11.0"
|
"mysql2": "^3.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
13
src/index.ts
13
src/index.ts
@@ -4,6 +4,7 @@ import { runMigrations } from "./db/migrate";
|
|||||||
import { closePool } from "./db/connection";
|
import { closePool } from "./db/connection";
|
||||||
import { bugReportRoutes } from "./routes/bugReports";
|
import { bugReportRoutes } from "./routes/bugReports";
|
||||||
import { adminRoutes } from "./routes/admin";
|
import { adminRoutes } from "./routes/admin";
|
||||||
|
import { authRoutes } from "./routes/auth";
|
||||||
import { initLogger, Log } from "./logger";
|
import { initLogger, Log } from "./logger";
|
||||||
|
|
||||||
const INSTANCE_ID = process.env.HOSTNAME + "_" + Math.random().toString(16).slice(2, 6);
|
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);
|
const url = new URL(request.url);
|
||||||
Log("HTTP", `${request.method} ${url.pathname} -> ${set.status ?? 200}`);
|
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);
|
Log("ERROR", "Unhandled error:", error);
|
||||||
set.status = 500;
|
set.status = 500;
|
||||||
return { success: false, message: "Internal server error" };
|
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("/health", () => ({ status: "ok", instance: INSTANCE_ID, timestamp: new Date().toISOString() }))
|
||||||
.get("/", () => ({ status: "ok", message: "API is running" }))
|
.get("/", () => ({ status: "ok", message: "API is running" }))
|
||||||
.use(bugReportRoutes)
|
.use(bugReportRoutes)
|
||||||
|
.use(authRoutes)
|
||||||
.use(adminRoutes)
|
.use(adminRoutes)
|
||||||
.listen({
|
.listen({
|
||||||
port: config.port,
|
port: config.port,
|
||||||
|
|||||||
@@ -6,7 +6,17 @@ import {
|
|||||||
getFile,
|
getFile,
|
||||||
deleteBugReport,
|
deleteBugReport,
|
||||||
updateBugReportStatus,
|
updateBugReportStatus,
|
||||||
|
countNewReports,
|
||||||
|
generateReportZip,
|
||||||
} from "../services/bugReportService";
|
} from "../services/bugReportService";
|
||||||
|
import {
|
||||||
|
listUsers,
|
||||||
|
createUser,
|
||||||
|
updateUser,
|
||||||
|
resetPassword,
|
||||||
|
deleteUser,
|
||||||
|
getUserById,
|
||||||
|
} from "../services/userService";
|
||||||
import { Log } from "../logger";
|
import { Log } from "../logger";
|
||||||
import type { BugReportStatus } from "../types";
|
import type { BugReportStatus } from "../types";
|
||||||
|
|
||||||
@@ -18,9 +28,10 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
|||||||
const page = parseInt(query.page || "1");
|
const page = parseInt(query.page || "1");
|
||||||
const pageSize = Math.min(parseInt(query.pageSize || "20"), 100);
|
const pageSize = Math.min(parseInt(query.pageSize || "20"), 100);
|
||||||
const status = query.status as BugReportStatus | undefined;
|
const status = query.status as BugReportStatus | undefined;
|
||||||
|
const search = query.search || undefined;
|
||||||
|
|
||||||
Log("ADMIN", `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"}`);
|
Log("ADMIN", `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"} search=${search || ""}`);
|
||||||
return await listBugReports({ page, pageSize, status });
|
return await listBugReports({ page, pageSize, status, search });
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
query: t.Object({
|
query: t.Object({
|
||||||
@@ -34,10 +45,19 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
|||||||
t.Literal("closed"),
|
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(
|
.get(
|
||||||
"/bug-reports/:id",
|
"/bug-reports/:id",
|
||||||
async ({ params, error }) => {
|
async ({ params, error }) => {
|
||||||
@@ -93,6 +113,25 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
|||||||
detail: { summary: "Download a bug report file" },
|
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(
|
.delete(
|
||||||
"/bug-reports/:id",
|
"/bug-reports/:id",
|
||||||
async ({ params, error }) => {
|
async ({ params, error }) => {
|
||||||
@@ -106,4 +145,91 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
|||||||
params: t.Object({ id: t.String() }),
|
params: t.Object({ id: t.String() }),
|
||||||
detail: { summary: "Delete a bug report and its files" },
|
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)" },
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
55
src/routes/auth.ts
Normal file
55
src/routes/auth.ts
Normal file
@@ -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" },
|
||||||
|
}
|
||||||
|
);
|
||||||
124
src/services/authService.ts
Normal file
124
src/services/authService.ts
Normal file
@@ -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<RowDataPacket[]>(
|
||||||
|
"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<ResultSetHeader>(
|
||||||
|
"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<AuthUser | null> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
`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<void> {
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.execute("DELETE FROM session WHERE id = ?", [sessionId]);
|
||||||
|
Log("AUTH", `Session logged out: ${sessionId.slice(0, 8)}...`);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ResultSetHeader, RowDataPacket } from "mysql2";
|
import type { ResultSetHeader, RowDataPacket } from "mysql2";
|
||||||
|
import JSZip from "jszip";
|
||||||
import { getPool } from "../db/connection";
|
import { getPool } from "../db/connection";
|
||||||
import type {
|
import type {
|
||||||
BugReport,
|
BugReport,
|
||||||
@@ -65,19 +66,31 @@ export async function listBugReports(opts: {
|
|||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
status?: BugReportStatus;
|
status?: BugReportStatus;
|
||||||
|
search?: string;
|
||||||
}): Promise<PaginatedResponse<BugReportListItem>> {
|
}): Promise<PaginatedResponse<BugReportListItem>> {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const { page, pageSize, status } = opts;
|
const { page, pageSize, status, search } = opts;
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
let whereClause = "";
|
const conditions: string[] = [];
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
whereClause = "WHERE br.status = ?";
|
conditions.push("br.status = ?");
|
||||||
params.push(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<RowDataPacket[]>(
|
const [countRows] = await pool.execute<RowDataPacket[]>(
|
||||||
`SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`,
|
`SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`,
|
||||||
params
|
params
|
||||||
@@ -91,8 +104,8 @@ export async function listBugReports(opts: {
|
|||||||
${whereClause}
|
${whereClause}
|
||||||
GROUP BY br.id
|
GROUP BY br.id
|
||||||
ORDER BY br.created_at DESC
|
ORDER BY br.created_at DESC
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ${pageSize} OFFSET ${offset}`,
|
||||||
[...params, pageSize, offset]
|
params
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -104,6 +117,65 @@ export async function listBugReports(opts: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function countNewReports(): Promise<number> {
|
||||||
|
const pool = getPool();
|
||||||
|
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"SELECT COUNT(*) as count FROM bug_reports WHERE status = 'new'"
|
||||||
|
);
|
||||||
|
return (rows[0] as { count: number }).count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateReportZip(reportId: number): Promise<Buffer | null> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const [reportRows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"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<RowDataPacket[]>(
|
||||||
|
"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<Buffer>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getBugReport(
|
export async function getBugReport(
|
||||||
id: number
|
id: number
|
||||||
): Promise<{ report: BugReport; files: Omit<BugReportFile, "data">[] } | null> {
|
): Promise<{ report: BugReport; files: Omit<BugReportFile, "data">[] } | null> {
|
||||||
|
|||||||
120
src/services/userService.ts
Normal file
120
src/services/userService.ts
Normal file
@@ -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<User[]> {
|
||||||
|
const pool = getPool();
|
||||||
|
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"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<User> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// Check for duplicate username
|
||||||
|
const [existing] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"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<ResultSetHeader>(
|
||||||
|
"INSERT INTO `user` (id, username, displayname, password_hash, role) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[id, data.username, data.displayname, passwordHash, data.role]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"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<boolean> {
|
||||||
|
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<ResultSetHeader>(
|
||||||
|
`UPDATE \`user\` SET ${fields.join(", ")} WHERE id = ?`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPassword(
|
||||||
|
id: string,
|
||||||
|
newPassword: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const pool = getPool();
|
||||||
|
const passwordHash = await hash(newPassword, ARGON2_OPTIONS);
|
||||||
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
|
"UPDATE `user` SET password_hash = ? WHERE id = ?",
|
||||||
|
[passwordHash, id]
|
||||||
|
);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: string): Promise<boolean> {
|
||||||
|
const pool = getPool();
|
||||||
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
|
"DELETE FROM `user` WHERE id = ?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(id: string): Promise<User | null> {
|
||||||
|
const pool = getPool();
|
||||||
|
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"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];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user