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
This commit is contained in:
2026-02-20 18:28:24 -08:00
committed by Naomi Carrigan
parent 208c11d153
commit 3668a67a62
4 changed files with 49 additions and 31 deletions
+18 -4
View File
@@ -41,13 +41,14 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
);
// Create game (protected admin route)
app.post<{ Body: CreateGameDto; Reply: Game }>(
app.post<{ Body: CreateGameDto; Reply: Game | { error: string } }>(
"/",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
async (request, reply) => {
try {
const game = await gameService.createGame(request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.entryCreate,
@@ -57,6 +58,12 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
details: `Created game: ${game.title}`,
});
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<{
Params: { id: string };
Body: UpdateGameDto;
Reply: Game | null;
Reply: Game | null | { error: string };
}>(
"/:id",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
async (request, reply) => {
try {
const { id } = request.params;
const game = await gameService.updateGame(id, request.body);
if (game) {
@@ -84,6 +92,12 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
});
}
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)) {
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
if (!validateRating(data.rating)) {
@@ -45,8 +42,14 @@ export class GameService {
// Validate cover image URL
if (data.coverImage) {
if (data.coverImage.startsWith("data:")) {
const sizeInBytes = data.coverImage.length * 0.75;
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
// 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)) {
+2 -1
View File
@@ -6,9 +6,10 @@
/**
* 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 {
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
const server = Fastify({
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.