/** * @fileoverview Items management endpoints for Aurora API. * Provides CRUD operations for game items with image upload support. */ import { join, resolve, dirname } from "path"; import type { RouteContext, RouteModule } from "./types"; import type { CreateItemDTO, UpdateItemDTO } from "@shared/modules/items/items.service"; import { jsonResponse, errorResponse, parseBody, parseIdFromPath, parseQuery, withErrorHandling } from "./utils"; import { CreateItemSchema, UpdateItemSchema, ItemQuerySchema } from "./schemas"; // Resolve assets directory path const currentDir = dirname(new URL(import.meta.url).pathname); const assetsDir = resolve(currentDir, "../../../bot/assets/graphics/items"); /** * Validates image file by checking magic bytes. * Supports PNG, JPEG, WebP, and GIF formats. */ function validateImageFormat(bytes: Uint8Array): boolean { 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; return isPNG || isJPEG || isWebP || isGIF; } /** Maximum image file size: 15MB */ const MAX_IMAGE_SIZE = 15 * 1024 * 1024; /** * Items routes handler. * * Endpoints: * - GET /api/items - List items with filters * - POST /api/items - Create item (JSON or multipart with image) * - GET /api/items/:id - Get single item * - PUT /api/items/:id - Update item * - DELETE /api/items/:id - Delete item and asset * - POST /api/items/:id/icon - Upload/replace item icon */ async function handler(ctx: RouteContext): Promise { const { pathname, method, req, url } = ctx; // Only handle requests to /api/items* if (!pathname.startsWith("/api/items")) { return null; } const { itemsService } = await import("@shared/modules/items/items.service"); /** * @route GET /api/items * @description Returns a paginated list of items with optional filtering. * * @query search - Filter by name/description (partial match) * @query type - Filter by item type (CONSUMABLE, EQUIPMENT, etc.) * @query rarity - Filter by rarity (C, R, SR, SSR) * @query limit - Max results per page (default: 100, max: 100) * @query offset - Pagination offset (default: 0) * * @response 200 - `{ items: Item[], total: number }` * @response 500 - Error fetching items * * @example * // Request * GET /api/items?type=CONSUMABLE&rarity=R&limit=10 * * // Response * { * "items": [{ "id": 1, "name": "Health Potion", ... }], * "total": 25 * } */ if (pathname === "/api/items" && method === "GET") { return withErrorHandling(async () => { 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); return jsonResponse(result); }, "fetch items"); } /** * @route POST /api/items * @description Creates a new item. Supports JSON or multipart/form-data with image. * * @body (JSON) { * name: string (required), * type: string (required), * description?: string, * rarity?: "C" | "R" | "SR" | "SSR", * price?: string | number, * usageData?: object * } * * @body (Multipart) { * data: JSON string with item fields, * image?: File (PNG, JPEG, WebP, GIF - max 15MB) * } * * @response 201 - `{ success: true, item: Item }` * @response 400 - Missing required fields or invalid image * @response 409 - Item name already exists * @response 500 - Error creating item */ if (pathname === "/api/items" && method === "POST") { return withErrorHandling(async () => { const contentType = req.headers.get("content-type") || ""; let itemData: CreateItemDTO | null = null; let imageFile: File | null = null; if (contentType.includes("multipart/form-data")) { 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) as CreateItemDTO; } else { return errorResponse("Missing item data", 400); } } else { itemData = await req.json() as CreateItemDTO; } if (!itemData) { return errorResponse("Missing item data", 400); } // Validate required fields if (!itemData.name || !itemData.type) { return errorResponse("Missing required fields: name and type are required", 400); } // Check for duplicate name if (await itemsService.isNameTaken(itemData.name)) { return errorResponse("An item with this name already exists", 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 || "C", 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 buffer = await imageFile.arrayBuffer(); const bytes = new Uint8Array(buffer); if (!validateImageFormat(bytes)) { await itemsService.deleteItem(item.id); return errorResponse("Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed.", 400); } if (buffer.byteLength > MAX_IMAGE_SIZE) { await itemsService.deleteItem(item.id); return errorResponse("Image file too large. Maximum size is 15MB.", 400); } const fileName = `${item.id}.png`; const filePath = join(assetsDir, fileName); await Bun.write(filePath, buffer); const assetUrl = `/assets/items/${fileName}`; await itemsService.updateItem(item.id, { iconUrl: assetUrl, imageUrl: assetUrl, }); const updatedItem = await itemsService.getItemById(item.id); return jsonResponse({ success: true, item: updatedItem }, 201); } return jsonResponse({ success: true, item }, 201); }, "create item"); } /** * @route GET /api/items/:id * @description Returns a single item by ID. * * @param id - Item ID (numeric) * @response 200 - Full item object * @response 404 - Item not found * @response 500 - Error fetching item */ if (pathname.match(/^\/api\/items\/\d+$/) && method === "GET") { const id = parseIdFromPath(pathname); if (!id) return null; return withErrorHandling(async () => { const item = await itemsService.getItemById(id); if (!item) { return errorResponse("Item not found", 404); } return jsonResponse(item); }, "fetch item"); } /** * @route PUT /api/items/:id * @description Updates an existing item. * * @param id - Item ID (numeric) * @body Partial item fields to update * @response 200 - `{ success: true, item: Item }` * @response 404 - Item not found * @response 409 - Name already taken by another item * @response 500 - Error updating item */ if (pathname.match(/^\/api\/items\/\d+$/) && method === "PUT") { const id = parseIdFromPath(pathname); if (!id) return null; return withErrorHandling(async () => { const data = await req.json() as Partial; const existing = await itemsService.getItemById(id); if (!existing) { return errorResponse("Item not found", 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 errorResponse("An item with this name already exists", 409); } } // Build update data const updateData: Partial = {}; 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); return jsonResponse({ success: true, item: updatedItem }); }, "update item"); } /** * @route DELETE /api/items/:id * @description Deletes an item and its associated asset file. * * @param id - Item ID (numeric) * @response 204 - Item deleted (no content) * @response 404 - Item not found * @response 500 - Error deleting item */ if (pathname.match(/^\/api\/items\/\d+$/) && method === "DELETE") { const id = parseIdFromPath(pathname); if (!id) return null; return withErrorHandling(async () => { const existing = await itemsService.getItemById(id); if (!existing) { return errorResponse("Item not found", 404); } await itemsService.deleteItem(id); // Try to delete associated asset file const assetPath = join(assetsDir, `${id}.png`); try { const assetFile = Bun.file(assetPath); if (await assetFile.exists()) { const { unlink } = await import("node:fs/promises"); await unlink(assetPath); } } catch (e) { // Non-critical: log but don't fail const { logger } = await import("@shared/lib/logger"); logger.warn("web", `Could not delete asset file for item ${id}`, e); } return new Response(null, { status: 204 }); }, "delete item"); } /** * @route POST /api/items/:id/icon * @description Uploads or replaces an item's icon image. * * @param id - Item ID (numeric) * @body (Multipart) { image: File } * @response 200 - `{ success: true, item: Item }` * @response 400 - No image file or invalid format * @response 404 - Item not found * @response 500 - Error uploading icon */ if (pathname.match(/^\/api\/items\/\d+\/icon$/) && method === "POST") { const id = parseInt(pathname.split("/")[3] || "0"); if (!id) return null; return withErrorHandling(async () => { const existing = await itemsService.getItemById(id); if (!existing) { return errorResponse("Item not found", 404); } const formData = await req.formData(); const imageFile = formData.get("image") as File | null; if (!imageFile) { return errorResponse("No image file provided", 400); } const buffer = await imageFile.arrayBuffer(); const bytes = new Uint8Array(buffer); if (!validateImageFormat(bytes)) { return errorResponse("Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed.", 400); } if (buffer.byteLength > MAX_IMAGE_SIZE) { return errorResponse("Image file too large. Maximum size is 15MB.", 400); } const fileName = `${id}.png`; const filePath = join(assetsDir, fileName); await Bun.write(filePath, buffer); const assetUrl = `/assets/items/${fileName}`; const updatedItem = await itemsService.updateItem(id, { iconUrl: assetUrl, imageUrl: assetUrl, }); return jsonResponse({ success: true, item: updatedItem }); }, "upload item icon"); } return null; } export const itemsRoutes: RouteModule = { name: "items", handler };