- Remove build process spawning for frontend bundler - Remove SPA fallback and static file serving - Return 404 for unknown routes instead of serving index.html - Keep all REST API endpoints and WebSocket functionality
901 lines
40 KiB
TypeScript
901 lines
40 KiB
TypeScript
/**
|
|
* API server factory module.
|
|
* Exports a function to create and start the API server.
|
|
* This allows the server to be started in-process from the main application.
|
|
*/
|
|
|
|
import { serve } from "bun";
|
|
import { join, resolve, dirname } from "path";
|
|
import { logger } from "@shared/lib/logger";
|
|
|
|
export interface WebServerConfig {
|
|
port?: number;
|
|
hostname?: string;
|
|
}
|
|
|
|
export interface WebServerInstance {
|
|
server: ReturnType<typeof serve>;
|
|
stop: () => Promise<void>;
|
|
url: string;
|
|
}
|
|
|
|
/**
|
|
* Creates and starts the API server.
|
|
*/
|
|
export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> {
|
|
const { port = 3000, hostname = "localhost" } = config;
|
|
|
|
// Resolve directories for asset serving
|
|
const currentDir = dirname(new URL(import.meta.url).pathname);
|
|
|
|
// Configuration constants
|
|
const MAX_CONNECTIONS = 10;
|
|
const MAX_PAYLOAD_BYTES = 16384; // 16KB
|
|
const IDLE_TIMEOUT_SECONDS = 60;
|
|
|
|
// Interval for broadcasting stats to all connected WS clients
|
|
let statsBroadcastInterval: Timer | undefined;
|
|
|
|
// Cache for activity stats (heavy aggregation)
|
|
let activityPromise: Promise<import("@shared/modules/dashboard/dashboard.types").ActivityData[]> | null = null;
|
|
let lastActivityFetch: number = 0;
|
|
const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
|
|
const server = serve({
|
|
port,
|
|
hostname,
|
|
async fetch(req, server) {
|
|
const url = new URL(req.url);
|
|
|
|
// Upgrade to WebSocket
|
|
if (url.pathname === "/ws") {
|
|
// Security Check: limit concurrent connections
|
|
const currentConnections = server.pendingWebSockets;
|
|
if (currentConnections >= MAX_CONNECTIONS) {
|
|
logger.warn("web", `Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`);
|
|
return new Response("Connection limit reached", { status: 429 });
|
|
}
|
|
|
|
const success = server.upgrade(req);
|
|
if (success) return undefined;
|
|
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
}
|
|
|
|
// API routes
|
|
if (url.pathname === "/api/health") {
|
|
return Response.json({ status: "ok", timestamp: Date.now() });
|
|
}
|
|
|
|
if (url.pathname === "/api/stats") {
|
|
try {
|
|
const stats = await getFullDashboardStats();
|
|
return Response.json(stats);
|
|
} catch (error) {
|
|
logger.error("web", "Error fetching dashboard stats", error);
|
|
return Response.json(
|
|
{ error: "Failed to fetch dashboard statistics" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
if (url.pathname === "/api/stats/activity") {
|
|
try {
|
|
const now = Date.now();
|
|
|
|
// If we have a valid cache, return it
|
|
if (activityPromise && (now - lastActivityFetch < ACTIVITY_CACHE_TTL)) {
|
|
const data = await activityPromise;
|
|
return Response.json(data);
|
|
}
|
|
|
|
// Otherwise, trigger a new fetch (deduplicated by the promise)
|
|
if (!activityPromise || (now - lastActivityFetch >= ACTIVITY_CACHE_TTL)) {
|
|
activityPromise = (async () => {
|
|
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
|
return await dashboardService.getActivityAggregation();
|
|
})();
|
|
lastActivityFetch = now;
|
|
}
|
|
|
|
const activity = await activityPromise;
|
|
return Response.json(activity);
|
|
} catch (error) {
|
|
logger.error("web", "Error fetching activity stats", error);
|
|
return Response.json(
|
|
{ error: "Failed to fetch activity statistics" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Administrative Actions
|
|
if (url.pathname.startsWith("/api/actions/") && req.method === "POST") {
|
|
try {
|
|
const { actionService } = await import("@shared/modules/admin/action.service");
|
|
const { MaintenanceModeSchema } = await import("@shared/modules/dashboard/dashboard.types");
|
|
|
|
if (url.pathname === "/api/actions/reload-commands") {
|
|
const result = await actionService.reloadCommands();
|
|
return Response.json(result);
|
|
}
|
|
|
|
if (url.pathname === "/api/actions/clear-cache") {
|
|
const result = await actionService.clearCache();
|
|
return Response.json(result);
|
|
}
|
|
|
|
if (url.pathname === "/api/actions/maintenance-mode") {
|
|
const rawBody = await req.json();
|
|
const parsed = MaintenanceModeSchema.safeParse(rawBody);
|
|
|
|
if (!parsed.success) {
|
|
return Response.json({ error: "Invalid payload", issues: parsed.error.issues }, { status: 400 });
|
|
}
|
|
|
|
const result = await actionService.toggleMaintenanceMode(parsed.data.enabled, parsed.data.reason);
|
|
return Response.json(result);
|
|
}
|
|
} catch (error) {
|
|
logger.error("web", "Error executing administrative action", error);
|
|
return Response.json(
|
|
{ error: "Failed to execute administrative action" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Quest Management
|
|
if (url.pathname === "/api/quests" && req.method === "POST") {
|
|
try {
|
|
const { questService } = await import("@shared/modules/quest/quest.service");
|
|
const data = await req.json();
|
|
|
|
// Basic validation could be added here or rely on service/DB
|
|
const result = await questService.createQuest({
|
|
name: data.name,
|
|
description: data.description || "",
|
|
triggerEvent: data.triggerEvent,
|
|
requirements: { target: Number(data.target) || 1 },
|
|
rewards: {
|
|
xp: Number(data.xpReward) || 0,
|
|
balance: Number(data.balanceReward) || 0
|
|
}
|
|
});
|
|
|
|
return Response.json({ success: true, quest: result[0] });
|
|
} catch (error) {
|
|
logger.error("web", "Error creating quest", error);
|
|
return Response.json(
|
|
{ error: "Failed to create quest", details: error instanceof Error ? error.message : String(error) },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
if (url.pathname === "/api/quests" && req.method === "GET") {
|
|
try {
|
|
const { questService } = await import("@shared/modules/quest/quest.service");
|
|
const quests = await questService.getAllQuests();
|
|
|
|
return Response.json({
|
|
success: true,
|
|
data: quests.map(q => ({
|
|
id: q.id,
|
|
name: q.name,
|
|
description: q.description,
|
|
triggerEvent: q.triggerEvent,
|
|
requirements: q.requirements,
|
|
rewards: q.rewards,
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
logger.error("web", "Error fetching quests", error);
|
|
return Response.json(
|
|
{ error: "Failed to fetch quests", details: error instanceof Error ? error.message : String(error) },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
if (url.pathname.startsWith("/api/quests/") && req.method === "DELETE") {
|
|
const id = parseInt(url.pathname.split("/").pop() || "0", 10);
|
|
|
|
if (!id) {
|
|
return Response.json({ error: "Invalid quest ID" }, { status: 400 });
|
|
}
|
|
|
|
try {
|
|
const { questService } = await import("@shared/modules/quest/quest.service");
|
|
const result = await questService.deleteQuest(id);
|
|
|
|
if (result.length === 0) {
|
|
return Response.json({ error: "Quest not found" }, { status: 404 });
|
|
}
|
|
|
|
return Response.json({ success: true, deleted: result[0].id });
|
|
} catch (error) {
|
|
logger.error("web", "Error deleting quest", error);
|
|
return Response.json(
|
|
{ error: "Failed to delete quest", details: error instanceof Error ? error.message : String(error) },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
if (url.pathname.startsWith("/api/quests/") && req.method === "PUT") {
|
|
const id = parseInt(url.pathname.split("/").pop() || "0", 10);
|
|
|
|
if (!id) {
|
|
return Response.json({ error: "Invalid quest ID" }, { status: 400 });
|
|
}
|
|
|
|
try {
|
|
const { questService } = await import("@shared/modules/quest/quest.service");
|
|
const data = await req.json();
|
|
|
|
const result = await questService.updateQuest(id, {
|
|
name: data.name,
|
|
description: data.description,
|
|
triggerEvent: data.triggerEvent,
|
|
requirements: { target: Number(data.target) || 1 },
|
|
rewards: {
|
|
xp: Number(data.xpReward) || 0,
|
|
balance: Number(data.balanceReward) || 0
|
|
}
|
|
});
|
|
|
|
if (result.length === 0) {
|
|
return Response.json({ error: "Quest not found" }, { status: 404 });
|
|
}
|
|
|
|
return Response.json({ success: true, quest: result[0] });
|
|
} catch (error) {
|
|
logger.error("web", "Error updating quest", error);
|
|
return Response.json(
|
|
{ error: "Failed to update quest", details: error instanceof Error ? error.message : String(error) },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Settings Management
|
|
if (url.pathname === "/api/settings") {
|
|
try {
|
|
if (req.method === "GET") {
|
|
const { config } = await import("@shared/lib/config");
|
|
const { jsonReplacer } = await import("@shared/lib/utils");
|
|
return new Response(JSON.stringify(config, jsonReplacer), {
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
if (req.method === "POST") {
|
|
const partialConfig = await req.json();
|
|
const { saveConfig, config: currentConfig } = await import("@shared/lib/config");
|
|
const { deepMerge } = await import("@shared/lib/utils");
|
|
|
|
// Merge partial update into current config
|
|
const mergedConfig = deepMerge(currentConfig, partialConfig);
|
|
|
|
|
|
// saveConfig throws if validation fails
|
|
saveConfig(mergedConfig);
|
|
|
|
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
|
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
|
|
|
|
return Response.json({ success: true });
|
|
}
|
|
} catch (error) {
|
|
logger.error("web", "Settings error", error);
|
|
return Response.json(
|
|
{ error: "Failed to process settings request", details: error instanceof Error ? error.message : String(error) },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
}
|
|
|
|
if (url.pathname === "/api/settings/meta") {
|
|
try {
|
|
const { AuroraClient } = await import("../../bot/lib/BotClient");
|
|
const { env } = await import("@shared/lib/env");
|
|
|
|
if (!env.DISCORD_GUILD_ID) {
|
|
return Response.json({ roles: [], channels: [] });
|
|
}
|
|
|
|
const guild = AuroraClient.guilds.cache.get(env.DISCORD_GUILD_ID);
|
|
if (!guild) {
|
|
return Response.json({ roles: [], channels: [] });
|
|
}
|
|
|
|
// Map roles and channels to a simplified format
|
|
const roles = guild.roles.cache
|
|
.sort((a, b) => b.position - a.position)
|
|
.map(r => ({ id: r.id, name: r.name, color: r.hexColor }));
|
|
|
|
const channels = guild.channels.cache
|
|
.map(c => ({ id: c.id, name: c.name, type: c.type }));
|
|
|
|
const commands = Array.from(AuroraClient.knownCommands.entries())
|
|
.map(([name, category]) => ({ name, category }))
|
|
.sort((a, b) => {
|
|
if (a.category !== b.category) return a.category.localeCompare(b.category);
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
return Response.json({ roles, channels, commands });
|
|
} catch (error) {
|
|
logger.error("web", "Error fetching settings meta", error);
|
|
return Response.json(
|
|
{ error: "Failed to fetch metadata" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// =====================================
|
|
// Items Management API
|
|
// =====================================
|
|
|
|
// GET /api/items - List all items with filtering
|
|
if (url.pathname === "/api/items" && req.method === "GET") {
|
|
try {
|
|
const { itemsService } = await import("@shared/modules/items/items.service");
|
|
|
|
const filters = {
|
|
search: url.searchParams.get("search") || undefined,
|
|
type: url.searchParams.get("type") || undefined,
|
|
rarity: url.searchParams.get("rarity") || undefined,
|
|
limit: url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 100,
|
|
offset: url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0,
|
|
};
|
|
|
|
const result = await itemsService.getAllItems(filters);
|
|
const { jsonReplacer } = await import("@shared/lib/utils");
|
|
|
|
return new Response(JSON.stringify(result, jsonReplacer), {
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
} catch (error) {
|
|
logger.error("web", "Error fetching items", error);
|
|
return Response.json(
|
|
{ error: "Failed to fetch items", details: error instanceof Error ? error.message : String(error) },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// POST /api/items - Create new item (JSON or multipart with image)
|
|
if (url.pathname === "/api/items" && req.method === "POST") {
|
|
try {
|
|
const { itemsService } = await import("@shared/modules/items/items.service");
|
|
const contentType = req.headers.get("content-type") || "";
|
|
|
|
let itemData: any;
|
|
let imageFile: File | null = null;
|
|
|
|
if (contentType.includes("multipart/form-data")) {
|
|
// Handle multipart form with optional image
|
|
const formData = await req.formData();
|
|
const jsonData = formData.get("data");
|
|
imageFile = formData.get("image") as File | null;
|
|
|
|
if (typeof jsonData === "string") {
|
|
itemData = JSON.parse(jsonData);
|
|
} else {
|
|
return Response.json({ error: "Missing item data" }, { status: 400 });
|
|
}
|
|
} else {
|
|
// JSON-only request
|
|
itemData = await req.json();
|
|
}
|
|
|
|
// Validate required fields
|
|
if (!itemData.name || !itemData.type) {
|
|
return Response.json(
|
|
{ error: "Missing required fields: name and type are required" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Check for duplicate name
|
|
if (await itemsService.isNameTaken(itemData.name)) {
|
|
return Response.json(
|
|
{ error: "An item with this name already exists" },
|
|
{ status: 409 }
|
|
);
|
|
}
|
|
|
|
// Set placeholder URLs if image will be uploaded
|
|
const placeholderUrl = "/assets/items/placeholder.png";
|
|
const createData = {
|
|
name: itemData.name,
|
|
description: itemData.description || null,
|
|
rarity: itemData.rarity || "Common",
|
|
type: itemData.type,
|
|
price: itemData.price ? BigInt(itemData.price) : null,
|
|
iconUrl: itemData.iconUrl || placeholderUrl,
|
|
imageUrl: itemData.imageUrl || placeholderUrl,
|
|
usageData: itemData.usageData || null,
|
|
};
|
|
|
|
// Create the item
|
|
const item = await itemsService.createItem(createData);
|
|
|
|
// If image was provided, save it and update the item
|
|
if (imageFile && item) {
|
|
const assetsDir = resolve(currentDir, "../../bot/assets/graphics/items");
|
|
const fileName = `${item.id}.png`;
|
|
const filePath = join(assetsDir, fileName);
|
|
|
|
// Validate file type (check magic bytes for PNG/JPEG/WebP/GIF)
|
|
const buffer = await imageFile.arrayBuffer();
|
|
const bytes = new Uint8Array(buffer);
|
|
|
|
const isPNG = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47;
|
|
const isJPEG = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF;
|
|
const isWebP = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
|
|
const isGIF = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46;
|
|
|
|
if (!isPNG && !isJPEG && !isWebP && !isGIF) {
|
|
// Rollback: delete the created item
|
|
await itemsService.deleteItem(item.id);
|
|
return Response.json(
|
|
{ error: "Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed." },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Check file size (max 15MB)
|
|
if (buffer.byteLength > 15 * 1024 * 1024) {
|
|
await itemsService.deleteItem(item.id);
|
|
return Response.json(
|
|
{ error: "Image file too large. Maximum size is 15MB." },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Save the file
|
|
await Bun.write(filePath, buffer);
|
|
|
|
// Update item with actual asset URL
|
|
const assetUrl = `/assets/items/${fileName}`;
|
|
await itemsService.updateItem(item.id, {
|
|
iconUrl: assetUrl,
|
|
imageUrl: assetUrl,
|
|
});
|
|
|
|
// Return item with updated URLs
|
|
const updatedItem = await itemsService.getItemById(item.id);
|
|
const { jsonReplacer } = await import("@shared/lib/utils");
|
|
return new Response(JSON.stringify({ success: true, item: updatedItem }, jsonReplacer), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
|
|
const { jsonReplacer } = await import("@shared/lib/utils");
|
|
return new Response(JSON.stringify({ success: true, item }, jsonReplacer), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
} catch (error) {
|
|
logger.error("web", "Error creating item", error);
|
|
return Response.json(
|
|
{ error: "Failed to create item", details: error instanceof Error ? error.message : String(error) },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// GET /api/items/:id - Get single item
|
|
if (url.pathname.match(/^\/api\/items\/\d+$/) && req.method === "GET") {
|
|
const id = parseInt(url.pathname.split("/").pop()!);
|
|
|
|
try {
|
|
const { itemsService } = await import("@shared/modules/items/items.service");
|
|
const item = await itemsService.getItemById(id);
|
|
|
|
if (!item) {
|
|
return Response.json({ error: "Item not found" }, { status: 404 });
|
|
}
|
|
|
|
const { jsonReplacer } = await import("@shared/lib/utils");
|
|
return new Response(JSON.stringify(item, jsonReplacer), {
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
} catch (error) {
|
|
logger.error("web", "Error fetching item", error);
|
|
return Response.json(
|
|
{ error: "Failed to fetch item", details: error instanceof Error ? error.message : String(error) },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// PUT /api/items/:id - Update item
|
|
if (url.pathname.match(/^\/api\/items\/\d+$/) && req.method === "PUT") {
|
|
const id = parseInt(url.pathname.split("/").pop()!);
|
|
|
|
try {
|
|
const { itemsService } = await import("@shared/modules/items/items.service");
|
|
const data = await req.json() as Record<string, any>;
|
|
|
|
// Check if item exists
|
|
const existing = await itemsService.getItemById(id);
|
|
if (!existing) {
|
|
return Response.json({ error: "Item not found" }, { status: 404 });
|
|
}
|
|
|
|
// Check for duplicate name (if name is being changed)
|
|
if (data.name && data.name !== existing.name) {
|
|
if (await itemsService.isNameTaken(data.name, id)) {
|
|
return Response.json(
|
|
{ error: "An item with this name already exists" },
|
|
{ status: 409 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Build update data
|
|
const updateData: any = {};
|
|
if (data.name !== undefined) updateData.name = data.name;
|
|
if (data.description !== undefined) updateData.description = data.description;
|
|
if (data.rarity !== undefined) updateData.rarity = data.rarity;
|
|
if (data.type !== undefined) updateData.type = data.type;
|
|
if (data.price !== undefined) updateData.price = data.price ? BigInt(data.price) : null;
|
|
if (data.iconUrl !== undefined) updateData.iconUrl = data.iconUrl;
|
|
if (data.imageUrl !== undefined) updateData.imageUrl = data.imageUrl;
|
|
if (data.usageData !== undefined) updateData.usageData = data.usageData;
|
|
|
|
const updatedItem = await itemsService.updateItem(id, updateData);
|
|
|
|
const { jsonReplacer } = await import("@shared/lib/utils");
|
|
return new Response(JSON.stringify({ success: true, item: updatedItem }, jsonReplacer), {
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
} catch (error) {
|
|
logger.error("web", "Error updating item", error);
|
|
return Response.json(
|
|
{ error: "Failed to update item", details: error instanceof Error ? error.message : String(error) },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// DELETE /api/items/:id - Delete item
|
|
if (url.pathname.match(/^\/api\/items\/\d+$/) && req.method === "DELETE") {
|
|
const id = parseInt(url.pathname.split("/").pop()!);
|
|
|
|
try {
|
|
const { itemsService } = await import("@shared/modules/items/items.service");
|
|
|
|
const existing = await itemsService.getItemById(id);
|
|
if (!existing) {
|
|
return Response.json({ error: "Item not found" }, { status: 404 });
|
|
}
|
|
|
|
// Delete the item
|
|
await itemsService.deleteItem(id);
|
|
|
|
// Try to delete associated asset file
|
|
const assetsDir = resolve(currentDir, "../../bot/assets/graphics/items");
|
|
const assetPath = join(assetsDir, `${id}.png`);
|
|
try {
|
|
const assetFile = Bun.file(assetPath);
|
|
if (await assetFile.exists()) {
|
|
await Bun.write(assetPath, ""); // Clear file
|
|
// Note: Bun doesn't have a direct delete, but we can use unlink via node:fs
|
|
const { unlink } = await import("node:fs/promises");
|
|
await unlink(assetPath);
|
|
}
|
|
} catch (e) {
|
|
// Non-critical: log but don't fail
|
|
logger.warn("web", `Could not delete asset file for item ${id}`, e);
|
|
}
|
|
|
|
return new Response(null, { status: 204 });
|
|
} catch (error) {
|
|
logger.error("web", "Error deleting item", error);
|
|
return Response.json(
|
|
{ error: "Failed to delete item", details: error instanceof Error ? error.message : String(error) },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// POST /api/items/:id/icon - Upload/update item icon
|
|
if (url.pathname.match(/^\/api\/items\/\d+\/icon$/) && req.method === "POST") {
|
|
const id = parseInt(url.pathname.split("/")[3] || "0");
|
|
|
|
try {
|
|
const { itemsService } = await import("@shared/modules/items/items.service");
|
|
|
|
// Check if item exists
|
|
const existing = await itemsService.getItemById(id);
|
|
if (!existing) {
|
|
return Response.json({ error: "Item not found" }, { status: 404 });
|
|
}
|
|
|
|
// Parse multipart form
|
|
const formData = await req.formData();
|
|
const imageFile = formData.get("image") as File | null;
|
|
|
|
if (!imageFile) {
|
|
return Response.json({ error: "No image file provided" }, { status: 400 });
|
|
}
|
|
|
|
// Validate file type
|
|
const buffer = await imageFile.arrayBuffer();
|
|
const bytes = new Uint8Array(buffer);
|
|
|
|
const isPNG = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47;
|
|
const isJPEG = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF;
|
|
const isWebP = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
|
|
const isGIF = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46;
|
|
|
|
if (!isPNG && !isJPEG && !isWebP && !isGIF) {
|
|
return Response.json(
|
|
{ error: "Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed." },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Check file size (max 15MB)
|
|
if (buffer.byteLength > 15 * 1024 * 1024) {
|
|
return Response.json(
|
|
{ error: "Image file too large. Maximum size is 15MB." },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Save the file
|
|
const assetsDir = resolve(currentDir, "../../bot/assets/graphics/items");
|
|
const fileName = `${id}.png`;
|
|
const filePath = join(assetsDir, fileName);
|
|
await Bun.write(filePath, buffer);
|
|
|
|
// Update item with new icon URL
|
|
const assetUrl = `/assets/items/${fileName}`;
|
|
const updatedItem = await itemsService.updateItem(id, {
|
|
iconUrl: assetUrl,
|
|
imageUrl: assetUrl,
|
|
});
|
|
|
|
const { jsonReplacer } = await import("@shared/lib/utils");
|
|
return new Response(JSON.stringify({ success: true, item: updatedItem }, jsonReplacer), {
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
} catch (error) {
|
|
logger.error("web", "Error uploading item icon", error);
|
|
return Response.json(
|
|
{ error: "Failed to upload icon", details: error instanceof Error ? error.message : String(error) },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// =====================================
|
|
// Static Asset Serving (/assets/*)
|
|
// =====================================
|
|
if (url.pathname.startsWith("/assets/")) {
|
|
const assetsRoot = resolve(currentDir, "../../bot/assets/graphics");
|
|
const assetPath = url.pathname.replace("/assets/", "");
|
|
|
|
// Security: prevent path traversal
|
|
const safePath = join(assetsRoot, assetPath);
|
|
if (!safePath.startsWith(assetsRoot)) {
|
|
return new Response("Forbidden", { status: 403 });
|
|
}
|
|
|
|
const file = Bun.file(safePath);
|
|
if (await file.exists()) {
|
|
// Determine MIME type based on extension
|
|
const ext = safePath.split(".").pop()?.toLowerCase();
|
|
const mimeTypes: Record<string, string> = {
|
|
"png": "image/png",
|
|
"jpg": "image/jpeg",
|
|
"jpeg": "image/jpeg",
|
|
"webp": "image/webp",
|
|
"gif": "image/gif",
|
|
};
|
|
const contentType = mimeTypes[ext || ""] || "application/octet-stream";
|
|
|
|
return new Response(file, {
|
|
headers: {
|
|
"Content-Type": contentType,
|
|
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
|
|
}
|
|
});
|
|
}
|
|
|
|
return new Response("Not found", { status: 404 });
|
|
}
|
|
|
|
// No frontend - return 404 for unknown routes
|
|
return new Response("Not Found", { status: 404 });
|
|
},
|
|
|
|
websocket: {
|
|
open(ws) {
|
|
ws.subscribe("dashboard");
|
|
logger.debug("web", `Client connected. Total: ${server.pendingWebSockets}`);
|
|
|
|
// Send initial stats
|
|
getFullDashboardStats().then(stats => {
|
|
ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
|
});
|
|
|
|
// Start broadcast interval if this is the first client
|
|
if (!statsBroadcastInterval) {
|
|
statsBroadcastInterval = setInterval(async () => {
|
|
try {
|
|
const stats = await getFullDashboardStats();
|
|
server.publish("dashboard", JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
|
} catch (error) {
|
|
logger.error("web", "Error in stats broadcast", error);
|
|
}
|
|
}, 5000);
|
|
}
|
|
},
|
|
async message(ws, message) {
|
|
try {
|
|
const messageStr = message.toString();
|
|
|
|
// Defense-in-depth: redundant length check before parsing
|
|
if (messageStr.length > MAX_PAYLOAD_BYTES) {
|
|
logger.error("web", "Payload exceeded maximum limit");
|
|
return;
|
|
}
|
|
|
|
const rawData = JSON.parse(messageStr);
|
|
const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types");
|
|
const parsed = WsMessageSchema.safeParse(rawData);
|
|
|
|
if (!parsed.success) {
|
|
logger.error("web", "Invalid message format", parsed.error.issues);
|
|
return;
|
|
}
|
|
|
|
if (parsed.data.type === "PING") {
|
|
ws.send(JSON.stringify({ type: "PONG" }));
|
|
}
|
|
} catch (e) {
|
|
logger.error("web", "Failed to handle message", e);
|
|
}
|
|
},
|
|
close(ws) {
|
|
ws.unsubscribe("dashboard");
|
|
logger.debug("web", `Client disconnected. Total remaining: ${server.pendingWebSockets}`);
|
|
|
|
// Stop broadcast interval if no clients left
|
|
if (server.pendingWebSockets === 0 && statsBroadcastInterval) {
|
|
clearInterval(statsBroadcastInterval);
|
|
statsBroadcastInterval = undefined;
|
|
}
|
|
},
|
|
maxPayloadLength: MAX_PAYLOAD_BYTES,
|
|
idleTimeout: IDLE_TIMEOUT_SECONDS,
|
|
},
|
|
|
|
});
|
|
|
|
/**
|
|
* Helper to fetch full dashboard stats object.
|
|
* Unified for both HTTP API and WebSocket broadcasts.
|
|
*/
|
|
async function getFullDashboardStats() {
|
|
// Import services (dynamic to avoid circular deps)
|
|
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
|
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
|
const { getClientStats } = await import("../../bot/lib/clientStats");
|
|
|
|
// Fetch all data in parallel with error isolation
|
|
const results = await Promise.allSettled([
|
|
Promise.resolve(getClientStats()),
|
|
dashboardService.getActiveUserCount(),
|
|
dashboardService.getTotalUserCount(),
|
|
dashboardService.getEconomyStats(),
|
|
dashboardService.getRecentEvents(10),
|
|
dashboardService.getTotalItems(),
|
|
dashboardService.getActiveLootdrops(),
|
|
dashboardService.getLeaderboards(),
|
|
Promise.resolve(lootdropService.getLootdropState()),
|
|
]);
|
|
|
|
// Helper to unwrap result or return default
|
|
const unwrap = <T>(result: PromiseSettledResult<T>, defaultValue: T, name: string): T => {
|
|
if (result.status === 'fulfilled') return result.value;
|
|
logger.error("web", `Failed to fetch ${name}`, result.reason);
|
|
return defaultValue;
|
|
};
|
|
|
|
const clientStats = unwrap(results[0], {
|
|
bot: { name: 'Aurora', avatarUrl: null, status: null },
|
|
guilds: 0,
|
|
commandsRegistered: 0,
|
|
commandsKnown: 0,
|
|
cachedUsers: 0,
|
|
ping: 0,
|
|
uptime: 0,
|
|
lastCommandTimestamp: null
|
|
}, 'clientStats');
|
|
|
|
const activeUsers = unwrap(results[1], 0, 'activeUsers');
|
|
const totalUsers = unwrap(results[2], 0, 'totalUsers');
|
|
const economyStats = unwrap(results[3], { totalWealth: 0n, avgLevel: 0, topStreak: 0 }, 'economyStats');
|
|
const recentEvents = unwrap(results[4], [], 'recentEvents');
|
|
const totalItems = unwrap(results[5], 0, 'totalItems');
|
|
const activeLootdrops = unwrap(results[6], [], 'activeLootdrops');
|
|
const leaderboards = unwrap(results[7], { topLevels: [], topWealth: [], topNetWorth: [] }, 'leaderboards');
|
|
const lootdropState = unwrap(results[8], undefined, 'lootdropState');
|
|
|
|
return {
|
|
bot: clientStats.bot,
|
|
guilds: { count: clientStats.guilds },
|
|
users: { active: activeUsers, total: totalUsers },
|
|
commands: {
|
|
total: clientStats.commandsKnown,
|
|
active: clientStats.commandsRegistered,
|
|
disabled: clientStats.commandsKnown - clientStats.commandsRegistered
|
|
},
|
|
ping: { avg: clientStats.ping },
|
|
economy: {
|
|
totalWealth: economyStats.totalWealth.toString(),
|
|
avgLevel: economyStats.avgLevel,
|
|
topStreak: economyStats.topStreak,
|
|
totalItems,
|
|
},
|
|
recentEvents: recentEvents.map(event => ({
|
|
...event,
|
|
timestamp: event.timestamp instanceof Date ? event.timestamp.toISOString() : event.timestamp,
|
|
})),
|
|
activeLootdrops: activeLootdrops.map(drop => ({
|
|
rewardAmount: drop.rewardAmount,
|
|
currency: drop.currency,
|
|
createdAt: drop.createdAt.toISOString(),
|
|
expiresAt: drop.expiresAt ? drop.expiresAt.toISOString() : null,
|
|
// Explicitly excluding channelId/messageId to prevent sniping
|
|
})),
|
|
lootdropState,
|
|
leaderboards,
|
|
uptime: clientStats.uptime,
|
|
lastCommandTimestamp: clientStats.lastCommandTimestamp,
|
|
maintenanceMode: (await import("../../bot/lib/BotClient")).AuroraClient.maintenanceMode,
|
|
};
|
|
}
|
|
|
|
// Listen for real-time events from the system bus
|
|
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
|
systemEvents.on(EVENTS.DASHBOARD.NEW_EVENT, (event) => {
|
|
server.publish("dashboard", JSON.stringify({ type: "NEW_EVENT", data: event }));
|
|
});
|
|
|
|
const url = `http://${hostname}:${port}`;
|
|
|
|
return {
|
|
server,
|
|
url,
|
|
stop: async () => {
|
|
if (statsBroadcastInterval) {
|
|
clearInterval(statsBroadcastInterval);
|
|
}
|
|
server.stop(true);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Starts the web server from the main application root.
|
|
* Kept for backward compatibility, but assumes webProjectPath is handled internally or ignored
|
|
* in favor of relative path resolution from this file.
|
|
*/
|
|
export async function startWebServerFromRoot(
|
|
webProjectPath: string,
|
|
config: WebServerConfig = {}
|
|
): Promise<WebServerInstance> {
|
|
// Current implementation doesn't need CWD switching thanks to absolute path resolution
|
|
return createWebServer(config);
|
|
}
|