Files
library/api/src/app/services/game.service.ts
T
hikari 983b78b0e9
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m20s
Node.js CI / CI (push) Successful in 1m24s
feat: base64 uploads, reusable forms, Discord roles, and UX improvements (#66)
## Summary

This PR includes multiple feature additions and fixes to improve the library application:

### 🎨 Base64 Image Upload Support
- Fixed Fastify body limit (1MB → 10MB) to accommodate base64-encoded images
- Corrected base64 size calculation and validation logic
- Improved error handling with proper 400 status codes and helpful messages
- Removed duplicate validation that was blocking uploads
- Users can now upload cover images up to 5MB (decoded size)

### 📝 Reusable Form Components
- Created 6 form components: `GameForm`, `BookForm`, `MusicForm`, `ShowForm`, `MangaForm`, `ArtForm`
- All forms support both 'add' and 'edit' modes with pre-population
- Integrated inline editing into all detail views (edit/delete buttons)
- Enhanced admin suggestions workflow with full forms instead of basic modals
- Added scroll-to-top when clicking edit in list views for better UX

### 🖼️ Default Cover Image
- Added beautiful library reading image as default cover for all media types
- Fixed static asset serving to use correct MIME types
- Updated all 12 components (6 list views + 6 detail views) to always show images

### 🔒 Tiered Rate Limiting
- Unauthenticated users: 100 requests/minute
- Authenticated users: 500 requests/minute (5x more lenient)
- Admin users: No rate limits (complete bypass via allowList)

### 🎮 Discord Integration
- Auto-assign library member role to users in NHCarrigan Discord server
- Checks server membership on every login
- Only assigns role if user is in server and doesn't have it yet
- Graceful error handling without blocking login
- Similar pattern to badge refresh flow

### 📚 Documentation
- Added comprehensive CLAUDE.md with:
  - Project structure and tech stack
  - Development workflow and commands
  - Database schema documentation
  - Authentication flow details
  - Security features
  - Code style conventions
  - Common gotchas and solutions

## Test Plan

- [x] Base64 image uploads work for cover images up to 5MB
- [x] Helpful error messages appear for validation failures
- [x] Edit/delete buttons appear on all detail views for admin users
- [x] Inline edit forms display and save correctly
- [x] Admin suggestions workflow uses full forms for all media types
- [x] Scroll-to-top works when editing from list views
- [x] Default cover image displays when no cover is provided
- [x] Static assets serve with correct MIME types
- [x] Rate limiting works correctly for different user types
- [x] Discord role assignment works on login
- [x] All builds pass without errors
- [x] No TypeScript errors

## Related Issues

Closes #65 - Base64 image upload issue

 This pull request was created with help from Hikari~ 🌸

Reviewed-on: #66
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-20 20:32:52 -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 },
});
}
}