fix: add admin
This commit is contained in:
28
server/api/admin/login.post.ts
Normal file
28
server/api/admin/login.post.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { signToken } from "../../middleware/adminAuth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{ password?: string }>(event);
|
||||
const adminPassword = useRuntimeConfig().adminPassword;
|
||||
|
||||
if (!adminPassword) {
|
||||
throw createError({ statusCode: 500, statusMessage: "Admin password not configured" });
|
||||
}
|
||||
|
||||
if (!body?.password || body.password !== adminPassword) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Invalid password" });
|
||||
}
|
||||
|
||||
const payload = Date.now().toString(36);
|
||||
const signature = signToken(payload);
|
||||
const token = `${payload}.${signature}`;
|
||||
|
||||
setCookie(event, "admin-session", token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "strict",
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
31
server/api/admin/requests.get.ts
Normal file
31
server/api/admin/requests.get.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event);
|
||||
const page = Math.max(1, parseInt(query.page as string) || 1);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(query.limit as string) || 50));
|
||||
const ip = (query.ip as string) || undefined;
|
||||
|
||||
const db = await getDb();
|
||||
const collection = db.collection("requests");
|
||||
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (ip) filter.ip = ip;
|
||||
|
||||
const [requests, total] = await Promise.all([
|
||||
collection
|
||||
.find(filter)
|
||||
.sort({ timestamp: -1 })
|
||||
.skip((page - 1) * limit)
|
||||
.limit(limit)
|
||||
.project({ _id: 0 })
|
||||
.toArray(),
|
||||
collection.countDocuments(filter),
|
||||
]);
|
||||
|
||||
return {
|
||||
requests,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
});
|
||||
54
server/api/admin/stats.get.ts
Normal file
54
server/api/admin/stats.get.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export default defineEventHandler(async () => {
|
||||
const db = await getDb();
|
||||
const collection = db.collection("requests");
|
||||
|
||||
const now = new Date();
|
||||
const last24h = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const last7d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const last30d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [totalRequests24h, totalRequests7d, totalRequests30d, uniqueIPs, topIPs, requestsByHour] =
|
||||
await Promise.all([
|
||||
collection.countDocuments({ timestamp: { $gte: last24h.toISOString() } }),
|
||||
collection.countDocuments({ timestamp: { $gte: last7d.toISOString() } }),
|
||||
collection.countDocuments({ timestamp: { $gte: last30d.toISOString() } }),
|
||||
collection
|
||||
.distinct("ip", { timestamp: { $gte: last24h.toISOString() } })
|
||||
.then((ips) => ips.length),
|
||||
collection
|
||||
.aggregate([
|
||||
{ $match: { timestamp: { $gte: last24h.toISOString() } } },
|
||||
{
|
||||
$group: {
|
||||
_id: "$ip",
|
||||
count: { $sum: 1 },
|
||||
lastSeen: { $max: "$timestamp" },
|
||||
},
|
||||
},
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 20 },
|
||||
{ $project: { _id: 0, ip: "$_id", count: 1, lastSeen: 1 } },
|
||||
])
|
||||
.toArray(),
|
||||
collection
|
||||
.aggregate([
|
||||
{ $match: { timestamp: { $gte: last24h.toISOString() } } },
|
||||
{
|
||||
$group: {
|
||||
_id: { $substr: ["$timestamp", 11, 2] },
|
||||
count: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{ $sort: { _id: 1 } },
|
||||
{ $project: { _id: 0, hour: "$_id", count: 1 } },
|
||||
])
|
||||
.toArray(),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalRequests: { last24h: totalRequests24h, last7d: totalRequests7d, last30d: totalRequests30d },
|
||||
uniqueIPs,
|
||||
topIPs,
|
||||
requestsByHour,
|
||||
};
|
||||
});
|
||||
33
server/middleware/adminAuth.ts
Normal file
33
server/middleware/adminAuth.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
function getSecret(): string {
|
||||
return useRuntimeConfig().adminPassword || "changeme";
|
||||
}
|
||||
|
||||
export function signToken(payload: string): string {
|
||||
return createHmac("sha256", getSecret()).update(payload).digest("hex");
|
||||
}
|
||||
|
||||
export function verifyToken(payload: string, signature: string): boolean {
|
||||
const expected = signToken(payload);
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const path = getRequestURL(event).pathname;
|
||||
if (!path.startsWith("/api/admin/") || path === "/api/admin/login") return;
|
||||
|
||||
const cookie = getCookie(event, "admin-session");
|
||||
if (!cookie) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||
}
|
||||
|
||||
const [payload, signature] = cookie.split(".");
|
||||
if (!payload || !signature || !verifyToken(payload, signature)) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||
}
|
||||
});
|
||||
19
server/middleware/requestLogger.ts
Normal file
19
server/middleware/requestLogger.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export default defineEventHandler((event) => {
|
||||
const path = getRequestURL(event).pathname;
|
||||
if (!path.startsWith("/api/v1/rates")) return;
|
||||
|
||||
const ip = getRequestIP(event, { xForwardedFor: true }) ?? "127.0.0.1";
|
||||
const method = event.method;
|
||||
const userAgent = getRequestHeader(event, "user-agent") ?? "";
|
||||
|
||||
event.node.res.on("finish", () => {
|
||||
logRequest({
|
||||
ip,
|
||||
path,
|
||||
method,
|
||||
statusCode: event.node.res.statusCode,
|
||||
userAgent,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
});
|
||||
66
server/plugins/logWorker.ts
Normal file
66
server/plugins/logWorker.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { createRedisClient, closeRedis } from "../utils/redis";
|
||||
import { getDb, closeDb } from "../utils/mongodb";
|
||||
|
||||
export default defineNitroPlugin((nitro) => {
|
||||
let running = true;
|
||||
let workerRedis: ReturnType<typeof createRedisClient> | null = null;
|
||||
|
||||
const worker = async () => {
|
||||
try {
|
||||
workerRedis = createRedisClient();
|
||||
const db = await getDb();
|
||||
const collection = db.collection("requests");
|
||||
|
||||
while (running) {
|
||||
const batch: Record<string, unknown>[] = [];
|
||||
const deadline = Date.now() + 2000;
|
||||
|
||||
// Blocking pop — waits up to 5s for an item
|
||||
const item = await workerRedis.brpop("request-logs", 5);
|
||||
if (item) {
|
||||
try {
|
||||
batch.push(JSON.parse(item[1]));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Drain up to 49 more items non-blocking
|
||||
while (batch.length < 50) {
|
||||
const next = await workerRedis.rpop("request-logs");
|
||||
if (!next) break;
|
||||
try {
|
||||
batch.push(JSON.parse(next));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (batch.length > 0) {
|
||||
try {
|
||||
await collection.insertMany(batch);
|
||||
} catch (err) {
|
||||
console.error("[logWorker] MongoDB insert failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// If we haven't hit the deadline and batch was small, wait a bit
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining > 0 && batch.length < 10) {
|
||||
await new Promise((r) => setTimeout(r, remaining));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[logWorker] Worker error:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Start worker in background
|
||||
worker();
|
||||
|
||||
nitro.hooks.hook("close", async () => {
|
||||
running = false;
|
||||
if (workerRedis) {
|
||||
await workerRedis.quit();
|
||||
workerRedis = null;
|
||||
}
|
||||
await closeRedis();
|
||||
await closeDb();
|
||||
});
|
||||
});
|
||||
30
server/utils/mongodb.ts
Normal file
30
server/utils/mongodb.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { MongoClient, type Db } from "mongodb";
|
||||
|
||||
let client: MongoClient | null = null;
|
||||
let db: Db | null = null;
|
||||
|
||||
export async function getDb(): Promise<Db> {
|
||||
if (db) return db;
|
||||
|
||||
const uri = useRuntimeConfig().db;
|
||||
if (!uri) throw new Error("NUXT_DB is not configured");
|
||||
|
||||
client = new MongoClient(uri);
|
||||
await client.connect();
|
||||
db = client.db("vat-api");
|
||||
|
||||
// Create indexes for the requests collection
|
||||
const requests = db.collection("requests");
|
||||
await requests.createIndex({ timestamp: -1 });
|
||||
await requests.createIndex({ ip: 1, timestamp: -1 });
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (client) {
|
||||
await client.close();
|
||||
client = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
24
server/utils/redis.ts
Normal file
24
server/utils/redis.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import Redis from "ioredis";
|
||||
|
||||
let redis: Redis | null = null;
|
||||
|
||||
function getRedisUrl(): string {
|
||||
return useRuntimeConfig().redisUrl || "redis://localhost:6379";
|
||||
}
|
||||
|
||||
export function getRedis(): Redis {
|
||||
if (redis) return redis;
|
||||
redis = new Redis(getRedisUrl(), { maxRetriesPerRequest: null });
|
||||
return redis;
|
||||
}
|
||||
|
||||
export function createRedisClient(): Redis {
|
||||
return new Redis(getRedisUrl(), { maxRetriesPerRequest: null });
|
||||
}
|
||||
|
||||
export async function closeRedis(): Promise<void> {
|
||||
if (redis) {
|
||||
await redis.quit();
|
||||
redis = null;
|
||||
}
|
||||
}
|
||||
17
server/utils/requestLogger.ts
Normal file
17
server/utils/requestLogger.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface RequestLogEntry {
|
||||
ip: string;
|
||||
path: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
userAgent: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export function logRequest(data: RequestLogEntry): void {
|
||||
try {
|
||||
const redis = getRedis();
|
||||
redis.lpush("request-logs", JSON.stringify(data)).catch(() => {});
|
||||
} catch {
|
||||
// Never let logging break API responses
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user