feat: base64 uploads, reusable forms, Discord roles, and UX improvements #66

Merged
naomi merged 8 commits from fix/base64 into main 2026-02-20 20:32:52 -08:00
4 changed files with 49 additions and 31 deletions
Showing only changes of commit 3668a67a62 - Show all commits
+18 -4
View File
@@ -41,13 +41,14 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
); );
// Create game (protected admin route) // Create game (protected admin route)
app.post<{ Body: CreateGameDto; Reply: Game }>( app.post<{ Body: CreateGameDto; Reply: Game | { error: string } }>(
"/", "/",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection], preHandler: [app.csrfProtection],
}, },
async (request) => { async (request, reply) => {
try {
const game = await gameService.createGame(request.body); const game = await gameService.createGame(request.body);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryCreate, action: AuditAction.entryCreate,
@@ -57,6 +58,12 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
details: `Created game: ${game.title}`, details: `Created game: ${game.title}`,
}); });
return game; return game;
} catch (error) {
if (error instanceof Error) {
return reply.code(400).send({ error: error.message });
}
throw error;
}
} }
); );
@@ -64,14 +71,15 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
app.put<{ app.put<{
Params: { id: string }; Params: { id: string };
Body: UpdateGameDto; Body: UpdateGameDto;
Reply: Game | null; Reply: Game | null | { error: string };
}>( }>(
"/:id", "/:id",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection], preHandler: [app.csrfProtection],
}, },
async (request) => { async (request, reply) => {
try {
const { id } = request.params; const { id } = request.params;
const game = await gameService.updateGame(id, request.body); const game = await gameService.updateGame(id, request.body);
if (game) { if (game) {
@@ -84,6 +92,12 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
}); });
} }
return game; return game;
} catch (error) {
if (error instanceof Error) {
return reply.code(400).send({ error: error.message });
}
throw error;
}
} }
); );
+8 -5
View File
@@ -33,9 +33,6 @@ export class GameService {
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) { if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`); throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
} }
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
// Validate rating // Validate rating
if (!validateRating(data.rating)) { if (!validateRating(data.rating)) {
@@ -45,8 +42,14 @@ export class GameService {
// Validate cover image URL // Validate cover image URL
if (data.coverImage) { if (data.coverImage) {
if (data.coverImage.startsWith("data:")) { if (data.coverImage.startsWith("data:")) {
const sizeInBytes = data.coverImage.length * 0.75; // Extract just the base64 data (after the comma)
if (sizeInBytes > MAX_LENGTHS.DATA_URL) { 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."); throw new Error("Cover image must be under 5MB.");
} }
if (!validateDataUrl(data.coverImage)) { if (!validateDataUrl(data.coverImage)) {
+2 -1
View File
@@ -6,9 +6,10 @@
/** /**
* Validates that a URL is a proper base64 data string. * Validates that a URL is a proper base64 data string.
* Allows whitespace in the base64 data which may occur during transmission.
*/ */
export function validateDataUrl(url: string): boolean { export function validateDataUrl(url: string): boolean {
return /^data:image\/(jpeg|png|gif|webp|svg\+xml);base64,[A-Za-z0-9+/=]+$/.test(url); return /^data:image\/(jpeg|png|gif|webp|svg\+xml);base64,[A-Za-z0-9+/=\s]+$/.test(url);
} }
/** /**
+1 -1
View File
@@ -40,7 +40,7 @@ process.on('SIGINT', () => {
// Instantiate Fastify with some config // Instantiate Fastify with some config
const server = Fastify({ const server = Fastify({
logger: true, logger: true,
bodyLimit: 1048576, // 1MB max body size bodyLimit: 10485760, // 10MB max body size (to accommodate base64-encoded images)
}); });
// Register your application as a normal plugin. // Register your application as a normal plugin.