Files
library/api/src/app/services/game.service.ts
T
hikari 3668a67a62 fix: resolve base64 image upload issues
This resolves issue #65 by addressing multiple problems that were preventing base64-encoded cover images from being uploaded:

1. Increased Fastify body limit from 1MB to 10MB to accommodate base64-encoded images
2. Removed duplicate coverImage string length validation that was blocking base64 data URLs
3. Fixed base64 size calculation to properly extract and measure just the base64 data portion
4. Updated validateDataUrl regex to allow whitespace in base64 strings
5. Added proper error handling with 400 status codes and helpful error messages instead of 500 errors

Users can now successfully upload cover images as base64 data URLs up to 5MB (decoded size) and will receive clear validation error messages if uploads fail.

Closes #65
2026-02-20 18:28:24 -08:00

229 lines
6.5 KiB
TypeScript

/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Game, GameStatus, CreateGameDto, UpdateGameDto } from "@library/shared-types";
import { prisma } from "../lib/prisma";
import {
validateUrl,
validateRating,
validateStringLength,
validateDataUrl,
MAX_LENGTHS,
} from "../utils/validation";
export class GameService {
private prisma = prisma;
constructor() {}
/**
* Validate game data for security.
*/
private validateGameData(data: CreateGameDto | UpdateGameDto): void {
// Validate string lengths
if (!validateStringLength(data.title, MAX_LENGTHS.TITLE)) {
throw new Error(`Title must be ${MAX_LENGTHS.TITLE} characters or less.`);
}
if (!validateStringLength(data.platform, MAX_LENGTHS.AUTHOR)) {
throw new Error(`Platform must be ${MAX_LENGTHS.AUTHOR} characters or less.`);
}
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
}
// Validate rating
if (!validateRating(data.rating)) {
throw new Error("Rating must be an integer between 0 and 10.");
}
// Validate cover image URL
if (data.coverImage) {
if (data.coverImage.startsWith("data:")) {
// Extract just the base64 data (after the comma)
const base64Data = data.coverImage.split(",")[1];
if (!base64Data) {
throw new Error("Invalid image data URL format.");
}
// Calculate decoded size: base64 is ~4/3 larger than original, so multiply by 0.75
const decodedSizeInBytes = base64Data.length * 0.75;
if (decodedSizeInBytes > MAX_LENGTHS.DATA_URL) {
throw new Error("Cover image must be under 5MB.");
}
if (!validateDataUrl(data.coverImage)) {
throw new Error("Invalid image data URL.");
}
} else {
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
if (!validateUrl(data.coverImage)) {
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
}
}
}
// Validate tags
if (data.tags) {
for (const tag of data.tags) {
if (!validateStringLength(tag, MAX_LENGTHS.TAGS)) {
throw new Error(`Each tag must be ${MAX_LENGTHS.TAGS} characters or less.`);
}
}
}
// Validate link URLs
if (data.links) {
for (const link of data.links) {
if (!validateUrl(link.url)) {
throw new Error(`Invalid link URL: ${link.title}. Only http and https URLs are allowed.`);
}
if (!validateStringLength(link.title, MAX_LENGTHS.TITLE)) {
throw new Error(`Link title must be ${MAX_LENGTHS.TITLE} characters or less.`);
}
if (!validateStringLength(link.url, MAX_LENGTHS.URL)) {
throw new Error(`Link URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
}
}
}
/**
* Get all games.
*/
async getAllGames(): Promise<Game[]> {
const games = await this.prisma.game.findMany({
orderBy: { updatedAt: "desc" },
});
return games.map((game) => ({
...game,
status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded,
dateStarted: game.dateStarted || undefined,
dateCompleted: game.dateCompleted || undefined,
dateFinished: game.dateFinished || undefined,
tags: game.tags ?? [],
links: game.links ?? [],
createdAt: game.createdAt,
updatedAt: game.updatedAt,
}));
}
/**
* Get game by ID.
*/
async getGameById(id: string): Promise<Game | null> {
const game = await this.prisma.game.findUnique({
where: { id },
});
if (!game) return null;
return {
...game,
status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded,
dateStarted: game.dateStarted || undefined,
dateCompleted: game.dateCompleted || undefined,
dateFinished: game.dateFinished || undefined,
tags: game.tags ?? [],
links: game.links ?? [],
createdAt: game.createdAt,
updatedAt: game.updatedAt,
};
}
/**
* Get all games in a series, ordered by seriesOrder.
*/
async getGamesBySeries(seriesName: string): Promise<Game[]> {
const games = await this.prisma.game.findMany({
where: { series: seriesName },
orderBy: { seriesOrder: "asc" },
});
return games.map((game) => ({
...game,
status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded,
dateStarted: game.dateStarted || undefined,
dateCompleted: game.dateCompleted || undefined,
dateFinished: game.dateFinished || undefined,
tags: game.tags ?? [],
links: game.links ?? [],
createdAt: game.createdAt,
updatedAt: game.updatedAt,
}));
}
/**
* Create new game.
*/
async createGame(data: CreateGameDto): Promise<Game> {
// Validate input
this.validateGameData(data);
const game = await this.prisma.game.create({
data: {
...data,
status: data.status.toUpperCase() as any,
},
});
return {
...game,
status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded,
dateStarted: game.dateStarted || undefined,
dateCompleted: game.dateCompleted || undefined,
dateFinished: game.dateFinished || undefined,
tags: game.tags ?? [],
links: game.links ?? [],
createdAt: game.createdAt,
updatedAt: game.updatedAt,
};
}
/**
* Update game by ID.
*/
async updateGame(id: string, data: UpdateGameDto): Promise<Game> {
// Validate input
this.validateGameData(data);
const updateData = { ...data };
if (updateData.status) {
updateData.status = updateData.status.toUpperCase() as any;
}
const game = await this.prisma.game.update({
where: { id },
data: updateData,
});
return {
...game,
status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded,
dateStarted: game.dateStarted || undefined,
dateCompleted: game.dateCompleted || undefined,
dateFinished: game.dateFinished || undefined,
tags: game.tags ?? [],
links: game.links ?? [],
createdAt: game.createdAt,
updatedAt: game.updatedAt,
};
}
/**
* Delete game by ID.
*/
async deleteGame(id: string): Promise<void> {
await this.prisma.game.delete({
where: { id },
});
}
}