generated from nhcarrigan/template
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:
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user