feat: implement comprehensive item management system with admin UI, API, and asset handling utilities.
All checks were successful
Deploy to Production / test (push) Successful in 44s
All checks were successful
Deploy to Production / test (push) Successful in 44s
This commit is contained in:
@@ -358,6 +358,385 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================
|
||||
// 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 2MB)
|
||||
if (buffer.byteLength > 2 * 1024 * 1024) {
|
||||
await itemsService.deleteItem(item.id);
|
||||
return Response.json(
|
||||
{ error: "Image file too large. Maximum size is 2MB." },
|
||||
{ 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 2MB)
|
||||
if (buffer.byteLength > 2 * 1024 * 1024) {
|
||||
return Response.json(
|
||||
{ error: "Image file too large. Maximum size is 2MB." },
|
||||
{ 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 });
|
||||
}
|
||||
|
||||
// Static File Serving
|
||||
let pathName = url.pathname;
|
||||
if (pathName === "/") pathName = "/index.html";
|
||||
|
||||
Reference in New Issue
Block a user