forked from syntaxbullet/aurorabot
372 lines
14 KiB
TypeScript
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
|
|
};
|