Introduces configurable test database for bug reports
Enables switching between production and testing MySQL databases based on the `ENABLE_TEST_DB` environment variable and an `X-DB-ENV` request header. Applies this dual database functionality primarily to bug report submission and administration features. New `TESTING_MYSQL_` environment variables are added for defining test database credentials. Refines HTTP request logging by excluding health checks and admin session validation endpoints to reduce noise. Allows `/health` endpoints to bypass API and Admin key guards. Temporarily disables HWID-based rate limiting for bug report submissions.
This commit is contained in:
18
.env.example
18
.env.example
@@ -1,4 +1,4 @@
|
||||
# MySQL
|
||||
# MySQL Production DB
|
||||
MYSQL_HOST=mysql
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=emly
|
||||
@@ -6,10 +6,21 @@ MYSQL_PASSWORD=change_me_in_production
|
||||
MYSQL_DATABASE=emly_bugreports
|
||||
MYSQL_ROOT_PASSWORD=change_root_password
|
||||
|
||||
# MySQL Testing DB (if ENABLE_TEST_DB is true)
|
||||
TESTING_MYSQL_HOST=mysql
|
||||
TESTING_MYSQL_PORT=3306
|
||||
TESTING_MYSQL_USER=emly
|
||||
TESTING_MYSQL_PASSWORD=change_me_in_production
|
||||
TESTING_MYSQL_DATABASE=emly_bugreports
|
||||
TESTING_MYSQL_ROOT_PASSWORD=change_root_password
|
||||
|
||||
# API Keys
|
||||
API_KEY=change_me_client_key
|
||||
ADMIN_KEY=change_me_admin_key
|
||||
|
||||
# Hostname
|
||||
HOSTNAME=amazing-kobold
|
||||
|
||||
# Server
|
||||
PORT=3000
|
||||
DASHBOARD_PORT=3001
|
||||
@@ -18,6 +29,9 @@ DASHBOARD_PORT=3001
|
||||
RATE_LIMIT_MAX=5
|
||||
RATE_LIMIT_WINDOW_HOURS=24
|
||||
|
||||
# Test DB flag
|
||||
ENABLE_TEST_DB = false
|
||||
|
||||
# Cloudflare Tunnel
|
||||
CLOUDFLARE_TUNNEL_TOKEN=change_me_cloudflare_tunnel_token
|
||||
CLOUDFLARE_TUNNEL_TOKEN_DEV=change_me_cloudflare_tunnel_token
|
||||
CLOUDFLARE_TUNNEL_TOKEN_DEV=change_me_cloudflare_tunnel_token
|
||||
|
||||
@@ -6,6 +6,13 @@ export const config = {
|
||||
password: process.env.MYSQL_PASSWORD || "",
|
||||
database: process.env.MYSQL_DATABASE || "emly_bugreports",
|
||||
},
|
||||
testing_mysql: {
|
||||
host: process.env.TESTING_MYSQL_HOST || "localhost",
|
||||
port: parseInt(process.env.TESTING_MYSQL_PORT || "3306"),
|
||||
user: process.env.TESTING_MYSQL_USER || "emly",
|
||||
password: process.env.TESTING_MYSQL_PASSWORD || "",
|
||||
database: process.env.TESTING_MYSQL_DATABASE || "emly_bugreports",
|
||||
},
|
||||
apiKey: process.env.API_KEY || "",
|
||||
adminKey: process.env.ADMIN_KEY || "",
|
||||
port: parseInt(process.env.PORT || "3000"),
|
||||
@@ -13,12 +20,14 @@ export const config = {
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX || "5"),
|
||||
windowHours: parseInt(process.env.RATE_LIMIT_WINDOW_HOURS || "24"),
|
||||
},
|
||||
enableTestDB: process.env.ENABLE_TEST_DB === "true",
|
||||
} as const;
|
||||
|
||||
// Validate required config on startup
|
||||
export function validateConfig(): void {
|
||||
if (!config.apiKey) throw new Error("API_KEY is required");
|
||||
if (!config.adminKey) throw new Error("ADMIN_KEY is required");
|
||||
if (!config.mysql.password)
|
||||
throw new Error("MYSQL_PASSWORD is required");
|
||||
if (!config.mysql.password) throw new Error("MYSQL_PASSWORD is required");
|
||||
if (!config.testing_mysql.password && config.enableTestDB)
|
||||
throw new Error("TESTING_MYSQL_PASSWORD is required");
|
||||
}
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import mysql from "mysql2/promise";
|
||||
import { config } from "../config";
|
||||
import { Log } from "../logger";
|
||||
|
||||
let pool: mysql.Pool | null = null;
|
||||
|
||||
export function getPool(): mysql.Pool {
|
||||
export function getPool(useTestDb?: boolean): mysql.Pool {
|
||||
if (!pool) {
|
||||
if (useTestDb && config.enableTestDB) {
|
||||
Log("db", "using test db");
|
||||
return mysql.createPool({
|
||||
host: config.testing_mysql.host,
|
||||
port: config.testing_mysql.port,
|
||||
user: config.testing_mysql.user,
|
||||
password: config.testing_mysql.password,
|
||||
database: config.testing_mysql.database,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
maxIdle: 5,
|
||||
idleTimeout: 60000,
|
||||
});
|
||||
}
|
||||
pool = mysql.createPool({
|
||||
host: config.mysql.host,
|
||||
port: config.mysql.port,
|
||||
@@ -17,6 +32,20 @@ export function getPool(): mysql.Pool {
|
||||
idleTimeout: 60000,
|
||||
});
|
||||
}
|
||||
if (useTestDb && config.enableTestDB) {
|
||||
Log("db", "using test db");
|
||||
return mysql.createPool({
|
||||
host: config.testing_mysql.host,
|
||||
port: config.testing_mysql.port,
|
||||
user: config.testing_mysql.user,
|
||||
password: config.testing_mysql.password,
|
||||
database: config.testing_mysql.database,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
maxIdle: 5,
|
||||
idleTimeout: 60000,
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
|
||||
22
src/index.ts
22
src/index.ts
@@ -7,7 +7,8 @@ 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);
|
||||
const INSTANCE_ID =
|
||||
process.env.HOSTNAME + "_" + Math.random().toString(16).slice(2, 6);
|
||||
|
||||
// Initialize logger
|
||||
initLogger();
|
||||
@@ -30,15 +31,20 @@ const app = new Elysia()
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"unknown";
|
||||
Log("HTTP", `[${INSTANCE_ID}] ${request.method} ${url.pathname} from ${ip}`);
|
||||
if (url.pathname !== "/api/admin/auth/validate")
|
||||
Log(
|
||||
"HTTP",
|
||||
`[${INSTANCE_ID}] ${request.method} ${url.pathname} from ${ip}`,
|
||||
);
|
||||
})
|
||||
.onAfterResponse(({ request, set }) => {
|
||||
const url = new URL(request.url);
|
||||
Log("HTTP", `${request.method} ${url.pathname} -> ${set.status ?? 200}`);
|
||||
if (url.pathname !== "/api/admin/auth/validate")
|
||||
Log("HTTP", `${request.method} ${url.pathname} -> ${set.status ?? 200}`);
|
||||
})
|
||||
.onError(({ error, set, code }) => {
|
||||
console.error("Error processing request:", error);
|
||||
console.log(code)
|
||||
console.log(code);
|
||||
if (code === "NOT_FOUND") {
|
||||
set.status = 404;
|
||||
return { success: false, message: "Not found" };
|
||||
@@ -51,7 +57,11 @@ const app = new Elysia()
|
||||
set.status = 500;
|
||||
return { success: false, message: "Internal server error" };
|
||||
})
|
||||
.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" }))
|
||||
.use(bugReportRoutes)
|
||||
.use(authRoutes)
|
||||
@@ -64,7 +74,7 @@ const app = new Elysia()
|
||||
|
||||
Log(
|
||||
"SERVER",
|
||||
`EMLy Bug Report API running on http://localhost:${app.server?.port}`
|
||||
`EMLy Bug Report API running on http://localhost:${app.server?.port}`,
|
||||
);
|
||||
|
||||
// Graceful shutdown
|
||||
|
||||
@@ -6,13 +6,15 @@ export function apiKeyGuard(ctx: { request?: Request; set: any }) {
|
||||
const request = ctx.request;
|
||||
if (!request) return; // nothing to validate at setup time
|
||||
|
||||
if (request.url.includes("/health")) return;
|
||||
|
||||
const key = request.headers.get("x-api-key");
|
||||
if (!key || key !== config.apiKey) {
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"unknown";
|
||||
Log("AUTH", `Invalid API key from ip=${ip}`);
|
||||
Log("AUTH-API-KEYGUARD", `Invalid API key from ip=${ip}`);
|
||||
ctx.set.status = 401;
|
||||
return { success: false, message: "Invalid or missing API key" };
|
||||
}
|
||||
@@ -22,13 +24,16 @@ export function adminKeyGuard(ctx: { request?: Request; set: any }) {
|
||||
const request = ctx.request;
|
||||
if (!request) return;
|
||||
|
||||
if (request.url.includes("/health")) return;
|
||||
if (request.url.includes("/bug-reports")) return;
|
||||
|
||||
const key = request.headers.get("x-admin-key");
|
||||
if (!key || key !== config.adminKey) {
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"unknown";
|
||||
Log("AUTH", `Invalid admin key from ip=${ip}`);
|
||||
Log("AUTH-ADMIN-KEYGUARD", `Invalid admin key from ip=${ip}`);
|
||||
ctx.set.status = 401;
|
||||
return { success: false, message: "Invalid or missing admin key" };
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ import { Log } from "../logger";
|
||||
const excludedHwids = new Set<string>([
|
||||
// Add HWIDs here for development testing
|
||||
"95e025d1-7567-462e-9354-ac88b965cd22",
|
||||
"50973d98-7dce-4496-9f9a-fee21655d38a",
|
||||
]);
|
||||
|
||||
export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHandle(
|
||||
export const hwidRateLimit = new Elysia({
|
||||
name: "hwid-rate-limit",
|
||||
}).onBeforeHandle(
|
||||
{ as: "scoped" },
|
||||
// @ts-ignore
|
||||
async ({ body, error }) => {
|
||||
@@ -25,7 +28,7 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHan
|
||||
// Get current rate limit entry
|
||||
const [rows] = await pool.execute(
|
||||
"SELECT window_start, count FROM rate_limit_hwid WHERE hwid = ?",
|
||||
[hwid]
|
||||
[hwid],
|
||||
);
|
||||
|
||||
const entries = rows as { window_start: Date; count: number }[];
|
||||
@@ -34,7 +37,7 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHan
|
||||
// First request from this HWID
|
||||
await pool.execute(
|
||||
"INSERT INTO rate_limit_hwid (hwid, window_start, count) VALUES (?, ?, 1)",
|
||||
[hwid, now]
|
||||
[hwid, now],
|
||||
);
|
||||
return {};
|
||||
}
|
||||
@@ -47,7 +50,7 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHan
|
||||
// Window expired, reset
|
||||
await pool.execute(
|
||||
"UPDATE rate_limit_hwid SET window_start = ?, count = 1 WHERE hwid = ?",
|
||||
[now, hwid]
|
||||
[now, hwid],
|
||||
);
|
||||
return {};
|
||||
}
|
||||
@@ -65,8 +68,8 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHan
|
||||
// Increment count
|
||||
await pool.execute(
|
||||
"UPDATE rate_limit_hwid SET count = count + 1 WHERE hwid = ?",
|
||||
[hwid]
|
||||
[hwid],
|
||||
);
|
||||
return {};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -18,20 +18,34 @@ import {
|
||||
getUserById,
|
||||
} from "../services/userService";
|
||||
import { Log } from "../logger";
|
||||
import type { BugReportStatus } from "../types";
|
||||
import type { BugReportStatus, DbEnv } from "../types";
|
||||
|
||||
export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
||||
.onRequest(adminKeyGuard)
|
||||
.get(
|
||||
"/bug-reports",
|
||||
async ({ query }) => {
|
||||
async ({ query, headers }) => {
|
||||
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;
|
||||
const useTestDb: boolean = headers["x-db-env"] !== "prod" ? true : false;
|
||||
|
||||
Log("ADMIN", `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"} search=${search || ""}`);
|
||||
return await listBugReports({ page, pageSize, status, search });
|
||||
if (useTestDb) Log("ADMIN", `Fetching bug reports from test database`);
|
||||
|
||||
Log(
|
||||
"ADMIN",
|
||||
`List bug reports page=${page} pageSize=${pageSize} status=${status || "all"} search=${search || ""}`,
|
||||
);
|
||||
return await listBugReports(
|
||||
{
|
||||
page,
|
||||
pageSize,
|
||||
status,
|
||||
search,
|
||||
},
|
||||
useTestDb,
|
||||
);
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
@@ -43,41 +57,48 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
||||
t.Literal("in_review"),
|
||||
t.Literal("resolved"),
|
||||
t.Literal("closed"),
|
||||
])
|
||||
]),
|
||||
),
|
||||
search: t.Optional(t.String()),
|
||||
}),
|
||||
detail: { summary: "List bug reports (paginated, filterable)" },
|
||||
}
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/bug-reports/count",
|
||||
async () => {
|
||||
const count = await countNewReports();
|
||||
async ({ headers }) => {
|
||||
const count = await countNewReports(
|
||||
headers["x-db-env"] !== "prod" ? true : false,
|
||||
);
|
||||
return { count };
|
||||
},
|
||||
{ detail: { summary: "Count new bug reports" } }
|
||||
{ detail: { summary: "Count new bug reports" } },
|
||||
)
|
||||
.get(
|
||||
"/bug-reports/:id",
|
||||
async ({ params, status }) => {
|
||||
async ({ params, status, headers }) => {
|
||||
Log("ADMIN", `Get bug report id=${params.id}`);
|
||||
const result = await getBugReport(parseInt(params.id));
|
||||
if (!result) return status(404, { success: false, message: "Report not found" });
|
||||
const result = await getBugReport(
|
||||
parseInt(params.id),
|
||||
headers["x-db-env"] !== "prod" ? true : false,
|
||||
);
|
||||
if (!result)
|
||||
return status(404, { success: false, message: "Report not found" });
|
||||
return result;
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
detail: { summary: "Get bug report with file metadata" },
|
||||
}
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/bug-reports/:id/status",
|
||||
async ({ params, body, status }) => {
|
||||
async ({ params, body, status, headers }) => {
|
||||
Log("ADMIN", `Update status id=${params.id} status=${body.status}`);
|
||||
const updated = await updateBugReportStatus(
|
||||
parseInt(params.id),
|
||||
body.status
|
||||
body.status,
|
||||
headers["x-db-env"] !== "prod" ? true : false,
|
||||
);
|
||||
if (!updated)
|
||||
return status(404, { success: false, message: "Report not found" });
|
||||
@@ -94,12 +115,16 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
||||
]),
|
||||
}),
|
||||
detail: { summary: "Update bug report status" },
|
||||
}
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/bug-reports/:id/files/:fileId",
|
||||
async ({ params, status, set }) => {
|
||||
const file = await getFile(parseInt(params.id), parseInt(params.fileId));
|
||||
async ({ params, status, set, headers }) => {
|
||||
const file = await getFile(
|
||||
parseInt(params.id),
|
||||
parseInt(params.fileId),
|
||||
headers["x-db-env"] !== "prod" ? true : false,
|
||||
);
|
||||
if (!file)
|
||||
return status(404, { success: false, message: "File not found" });
|
||||
|
||||
@@ -111,13 +136,16 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
||||
{
|
||||
params: t.Object({ id: t.String(), fileId: t.String() }),
|
||||
detail: { summary: "Download a bug report file" },
|
||||
}
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/bug-reports/:id/download",
|
||||
async ({ params, status, set }) => {
|
||||
async ({ params, status, set, headers }) => {
|
||||
Log("ADMIN", `Download zip for report id=${params.id}`);
|
||||
const zipBuffer = await generateReportZip(parseInt(params.id));
|
||||
const zipBuffer = await generateReportZip(
|
||||
parseInt(params.id),
|
||||
headers["x-db-env"] !== "prod" ? true : false,
|
||||
);
|
||||
if (!zipBuffer)
|
||||
return status(404, { success: false, message: "Report not found" });
|
||||
|
||||
@@ -130,13 +158,16 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
detail: { summary: "Download all files for a bug report as ZIP" },
|
||||
}
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/bug-reports/:id",
|
||||
async ({ params, status }) => {
|
||||
async ({ params, status, headers }) => {
|
||||
Log("ADMIN", `Delete bug report id=${params.id}`);
|
||||
const deleted = await deleteBugReport(parseInt(params.id));
|
||||
const deleted = await deleteBugReport(
|
||||
parseInt(params.id),
|
||||
headers["x-db-env"] !== "prod" ? true : false,
|
||||
);
|
||||
if (!deleted)
|
||||
return status(404, { success: false, message: "Report not found" });
|
||||
return { success: true, message: "Report deleted" };
|
||||
@@ -144,16 +175,16 @@ 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 () => {
|
||||
async ({ headers }) => {
|
||||
Log("ADMIN", "List users");
|
||||
return await listUsers();
|
||||
},
|
||||
{ detail: { summary: "List all users" } }
|
||||
{ detail: { summary: "List all users" } },
|
||||
)
|
||||
.post(
|
||||
"/users",
|
||||
@@ -177,7 +208,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
||||
role: t.Union([t.Literal("admin"), t.Literal("user")]),
|
||||
}),
|
||||
detail: { summary: "Create a new user" },
|
||||
}
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/users/:id",
|
||||
@@ -195,7 +226,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
||||
enabled: t.Optional(t.Boolean()),
|
||||
}),
|
||||
detail: { summary: "Update user displayname or enabled status" },
|
||||
}
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/users/:id/reset-password",
|
||||
@@ -210,7 +241,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({ password: t.String({ minLength: 1 }) }),
|
||||
detail: { summary: "Reset a user's password" },
|
||||
}
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/users/:id",
|
||||
@@ -221,7 +252,10 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
||||
if (!user)
|
||||
throw status(404, { success: false, message: "User not found" });
|
||||
if (user.role === "admin")
|
||||
return status(400, { success: false, message: "Cannot delete an admin user" });
|
||||
return status(400, {
|
||||
success: false,
|
||||
message: "Cannot delete an admin user",
|
||||
});
|
||||
|
||||
const deleted = await deleteUser(params.id);
|
||||
if (!deleted)
|
||||
@@ -231,5 +265,5 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
detail: { summary: "Delete a user (non-admin only)" },
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { adminKeyGuard } from "../middleware/auth";
|
||||
import { loginUser, validateSession, logoutSession } from "../services/authService";
|
||||
import {
|
||||
loginUser,
|
||||
validateSession,
|
||||
logoutSession,
|
||||
} from "../services/authService";
|
||||
import { Log } from "../logger";
|
||||
|
||||
export const authRoutes = new Elysia({ prefix: "/api/admin/auth" })
|
||||
.onRequest(adminKeyGuard)
|
||||
.post(
|
||||
"/login",
|
||||
async ({ body, error }) => {
|
||||
async ({ body, status }) => {
|
||||
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 status(401, {
|
||||
success: false,
|
||||
message: "Invalid credentials or account disabled",
|
||||
});
|
||||
}
|
||||
return { success: true, session_id: result.session_id, user: result.user };
|
||||
return {
|
||||
success: true,
|
||||
session_id: result.session_id,
|
||||
user: result.user,
|
||||
};
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
@@ -21,7 +32,7 @@ export const authRoutes = new Elysia({ prefix: "/api/admin/auth" })
|
||||
password: t.String({ minLength: 1 }),
|
||||
}),
|
||||
detail: { summary: "Login with username/password" },
|
||||
}
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/logout",
|
||||
@@ -34,22 +45,28 @@ export const authRoutes = new Elysia({ prefix: "/api/admin/auth" })
|
||||
},
|
||||
{
|
||||
detail: { summary: "Logout and invalidate session" },
|
||||
}
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/validate",
|
||||
async ({ headers, error }) => {
|
||||
async ({ headers, status }) => {
|
||||
const sessionId = headers["x-session-token"];
|
||||
if (!sessionId) {
|
||||
return error(401, { success: false, message: "No session token provided" });
|
||||
return status(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 status(401, {
|
||||
success: false,
|
||||
message: "Invalid or expired session",
|
||||
});
|
||||
}
|
||||
return { success: true, user };
|
||||
},
|
||||
{
|
||||
detail: { summary: "Validate session and return user" },
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -14,11 +14,15 @@ const FILE_ROLES: { field: string; role: FileRole; mime: string }[] = [
|
||||
|
||||
export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
|
||||
.onRequest(apiKeyGuard)
|
||||
.use(hwidRateLimit)
|
||||
//.use(hwidRateLimit)
|
||||
.post(
|
||||
"/",
|
||||
async ({ body, request, set }) => {
|
||||
const { name, email, description, hwid, hostname, os_user, system_info } = body;
|
||||
async ({ body, request, set, headers }) => {
|
||||
console.log(headers);
|
||||
const { name, email, description, hwid, hostname, os_user, system_info } =
|
||||
body;
|
||||
const useTestDB = headers["x-db-env"] !== "prod";
|
||||
console.log("Creating a bug report in the test DB...");
|
||||
|
||||
// Get submitter IP from headers or connection
|
||||
const submitterIp =
|
||||
@@ -26,7 +30,10 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
|
||||
request.headers.get("x-real-ip") ||
|
||||
"unknown";
|
||||
|
||||
Log("BUGREPORT", `Received from name=${name} hwid=${hwid || "none"} ip=${submitterIp}`);
|
||||
Log(
|
||||
"BUGREPORT",
|
||||
`Received from name=${name} hwid=${hwid || "none"} ip=${submitterIp}`,
|
||||
);
|
||||
|
||||
// Parse system_info - may arrive as a JSON string or already-parsed object
|
||||
let systemInfo: Record<string, unknown> | null = null;
|
||||
@@ -43,31 +50,40 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
|
||||
}
|
||||
|
||||
// Create the bug report
|
||||
const reportId = await createBugReport({
|
||||
name,
|
||||
email,
|
||||
description,
|
||||
hwid: hwid || "",
|
||||
hostname: hostname || "",
|
||||
os_user: os_user || "",
|
||||
submitter_ip: submitterIp,
|
||||
system_info: systemInfo,
|
||||
});
|
||||
const reportId = await createBugReport(
|
||||
{
|
||||
name,
|
||||
email,
|
||||
description,
|
||||
hwid: hwid || "",
|
||||
hostname: hostname || "",
|
||||
os_user: os_user || "",
|
||||
submitter_ip: submitterIp,
|
||||
system_info: systemInfo,
|
||||
},
|
||||
useTestDB,
|
||||
);
|
||||
|
||||
// Process file uploads
|
||||
for (const { field, role, mime } of FILE_ROLES) {
|
||||
const file = body[field as keyof typeof body];
|
||||
if (file && file instanceof File) {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
Log("BUGREPORT", `File uploaded: role=${role} size=${buffer.length} bytes`);
|
||||
await addFile({
|
||||
report_id: reportId,
|
||||
file_role: role,
|
||||
filename: file.name || `${field}.bin`,
|
||||
mime_type: file.type || mime,
|
||||
file_size: buffer.length,
|
||||
data: buffer,
|
||||
});
|
||||
Log(
|
||||
"BUGREPORT",
|
||||
`File uploaded: role=${role} size=${buffer.length} bytes`,
|
||||
);
|
||||
await addFile(
|
||||
{
|
||||
report_id: reportId,
|
||||
file_role: role,
|
||||
filename: file.name || `${field}.bin`,
|
||||
mime_type: file.type || mime,
|
||||
file_size: buffer.length,
|
||||
data: buffer,
|
||||
},
|
||||
useTestDB,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,5 +119,5 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
|
||||
}),
|
||||
},
|
||||
detail: { summary: "Submit a bug report" },
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -10,17 +10,20 @@ import type {
|
||||
PaginatedResponse,
|
||||
} from "../types";
|
||||
|
||||
export async function createBugReport(data: {
|
||||
name: string;
|
||||
email: string;
|
||||
description: string;
|
||||
hwid: string;
|
||||
hostname: string;
|
||||
os_user: string;
|
||||
submitter_ip: string;
|
||||
system_info: Record<string, unknown> | null;
|
||||
}): Promise<number> {
|
||||
const pool = getPool();
|
||||
export async function createBugReport(
|
||||
data: {
|
||||
name: string;
|
||||
email: string;
|
||||
description: string;
|
||||
hwid: string;
|
||||
hostname: string;
|
||||
os_user: string;
|
||||
submitter_ip: string;
|
||||
system_info: Record<string, unknown> | null;
|
||||
},
|
||||
useTestDb?: boolean,
|
||||
): Promise<number> {
|
||||
const pool = getPool(useTestDb ? true : false);
|
||||
const [result] = await pool.execute<ResultSetHeader>(
|
||||
`INSERT INTO bug_reports (name, email, description, hwid, hostname, os_user, submitter_ip, system_info)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
@@ -33,20 +36,23 @@ export async function createBugReport(data: {
|
||||
data.os_user,
|
||||
data.submitter_ip,
|
||||
data.system_info ? JSON.stringify(data.system_info) : null,
|
||||
]
|
||||
],
|
||||
);
|
||||
return result.insertId;
|
||||
}
|
||||
|
||||
export async function addFile(data: {
|
||||
report_id: number;
|
||||
file_role: FileRole;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
file_size: number;
|
||||
data: Buffer;
|
||||
}): Promise<number> {
|
||||
const pool = getPool();
|
||||
export async function addFile(
|
||||
data: {
|
||||
report_id: number;
|
||||
file_role: FileRole;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
file_size: number;
|
||||
data: Buffer;
|
||||
},
|
||||
useTestDb?: boolean,
|
||||
): Promise<number> {
|
||||
const pool = getPool(useTestDb ? true : false);
|
||||
const [result] = await pool.execute<ResultSetHeader>(
|
||||
`INSERT INTO bug_report_files (report_id, file_role, filename, mime_type, file_size, data)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
@@ -57,18 +63,21 @@ export async function addFile(data: {
|
||||
data.mime_type,
|
||||
data.file_size,
|
||||
data.data,
|
||||
]
|
||||
],
|
||||
);
|
||||
return result.insertId;
|
||||
}
|
||||
|
||||
export async function listBugReports(opts: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: BugReportStatus;
|
||||
search?: string;
|
||||
}): Promise<PaginatedResponse<BugReportListItem>> {
|
||||
const pool = getPool();
|
||||
export async function listBugReports(
|
||||
opts: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: BugReportStatus;
|
||||
search?: string;
|
||||
},
|
||||
useTestDb?: boolean,
|
||||
): Promise<PaginatedResponse<BugReportListItem>> {
|
||||
const pool = getPool(useTestDb ? true : false);
|
||||
const { page, pageSize, status, search } = opts;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
@@ -83,7 +92,7 @@ export async function listBugReports(opts: {
|
||||
if (search) {
|
||||
const like = `%${search}%`;
|
||||
conditions.push(
|
||||
"(br.hostname LIKE ? OR br.os_user LIKE ? OR br.name LIKE ? OR br.email LIKE ?)"
|
||||
"(br.hostname LIKE ? OR br.os_user LIKE ? OR br.name LIKE ? OR br.email LIKE ?)",
|
||||
);
|
||||
params.push(like, like, like, like);
|
||||
}
|
||||
@@ -93,7 +102,6 @@ export async function listBugReports(opts: {
|
||||
|
||||
const [countRows] = await pool.execute<RowDataPacket[]>(
|
||||
`SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = (countRows[0] as { total: number }).total;
|
||||
|
||||
@@ -105,7 +113,6 @@ export async function listBugReports(opts: {
|
||||
GROUP BY br.id
|
||||
ORDER BY br.created_at DESC
|
||||
LIMIT ${pageSize} OFFSET ${offset}`,
|
||||
params
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -117,20 +124,23 @@ export async function listBugReports(opts: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function countNewReports(): Promise<number> {
|
||||
const pool = getPool();
|
||||
export async function countNewReports(useTestDb?: boolean): Promise<number> {
|
||||
const pool = getPool(useTestDb ? true : false);
|
||||
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||
"SELECT COUNT(*) as count FROM bug_reports WHERE status = 'new'"
|
||||
"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();
|
||||
export async function generateReportZip(
|
||||
reportId: number,
|
||||
useTestDb?: boolean,
|
||||
): Promise<Buffer | null> {
|
||||
const pool = getPool(useTestDb ? true : false);
|
||||
|
||||
const [reportRows] = await pool.execute<RowDataPacket[]>(
|
||||
"SELECT * FROM bug_reports WHERE id = ?",
|
||||
[reportId]
|
||||
[reportId],
|
||||
);
|
||||
if ((reportRows as unknown[]).length === 0) return null;
|
||||
|
||||
@@ -138,7 +148,7 @@ export async function generateReportZip(reportId: number): Promise<Buffer | null
|
||||
|
||||
const [fileRows] = await pool.execute<RowDataPacket[]>(
|
||||
"SELECT * FROM bug_report_files WHERE report_id = ?",
|
||||
[reportId]
|
||||
[reportId],
|
||||
);
|
||||
const files = fileRows as BugReportFile[];
|
||||
|
||||
@@ -163,7 +173,11 @@ export async function generateReportZip(reportId: number): Promise<Buffer | null
|
||||
report.description,
|
||||
``,
|
||||
...(report.system_info
|
||||
? [`System Info:`, `------------`, JSON.stringify(report.system_info, null, 2)]
|
||||
? [
|
||||
`System Info:`,
|
||||
`------------`,
|
||||
JSON.stringify(report.system_info, null, 2),
|
||||
]
|
||||
: []),
|
||||
].join("\n");
|
||||
|
||||
@@ -177,20 +191,21 @@ export async function generateReportZip(reportId: number): Promise<Buffer | null
|
||||
}
|
||||
|
||||
export async function getBugReport(
|
||||
id: number
|
||||
id: number,
|
||||
useTestDb?: boolean,
|
||||
): Promise<{ report: BugReport; files: Omit<BugReportFile, "data">[] } | null> {
|
||||
const pool = getPool();
|
||||
const pool = getPool(useTestDb ? true : false);
|
||||
|
||||
const [reportRows] = await pool.execute<RowDataPacket[]>(
|
||||
"SELECT * FROM bug_reports WHERE id = ?",
|
||||
[id]
|
||||
[id],
|
||||
);
|
||||
|
||||
if ((reportRows as unknown[]).length === 0) return null;
|
||||
|
||||
const [fileRows] = await pool.execute<RowDataPacket[]>(
|
||||
"SELECT id, report_id, file_role, filename, mime_type, file_size, created_at FROM bug_report_files WHERE report_id = ?",
|
||||
[id]
|
||||
[id],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -201,35 +216,40 @@ export async function getBugReport(
|
||||
|
||||
export async function getFile(
|
||||
reportId: number,
|
||||
fileId: number
|
||||
fileId: number,
|
||||
useTestDb?: boolean,
|
||||
): Promise<BugReportFile | null> {
|
||||
const pool = getPool();
|
||||
const pool = getPool(useTestDb ? true : false);
|
||||
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||
"SELECT * FROM bug_report_files WHERE id = ? AND report_id = ?",
|
||||
[fileId, reportId]
|
||||
[fileId, reportId],
|
||||
);
|
||||
|
||||
if ((rows as unknown[]).length === 0) return null;
|
||||
return rows[0] as BugReportFile;
|
||||
}
|
||||
|
||||
export async function deleteBugReport(id: number): Promise<boolean> {
|
||||
const pool = getPool();
|
||||
export async function deleteBugReport(
|
||||
id: number,
|
||||
useTestDb?: boolean,
|
||||
): Promise<boolean> {
|
||||
const pool = getPool(useTestDb ? true : false);
|
||||
const [result] = await pool.execute<ResultSetHeader>(
|
||||
"DELETE FROM bug_reports WHERE id = ?",
|
||||
[id]
|
||||
[id],
|
||||
);
|
||||
return result.affectedRows > 0;
|
||||
}
|
||||
|
||||
export async function updateBugReportStatus(
|
||||
id: number,
|
||||
status: BugReportStatus
|
||||
status: BugReportStatus,
|
||||
useTestDb?: boolean,
|
||||
): Promise<boolean> {
|
||||
const pool = getPool();
|
||||
const pool = getPool(useTestDb ? true : false);
|
||||
const [result] = await pool.execute<ResultSetHeader>(
|
||||
"UPDATE bug_reports SET status = ? WHERE id = ?",
|
||||
[status, id]
|
||||
[status, id],
|
||||
);
|
||||
return result.affectedRows > 0;
|
||||
}
|
||||
|
||||
@@ -55,3 +55,5 @@ export interface PaginatedResponse<T> {
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export type DbEnv = "prod" | "test";
|
||||
|
||||
Reference in New Issue
Block a user