Files
discord-rpg-concept/web/src/routes/items.routes.ts

372 lines
14 KiB
TypeScript

/**
* @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<Response | null> {
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<UpdateItemDTO>;
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<UpdateItemDTO> = {};
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
};