Compare commits

...

2 Commits

Author SHA1 Message Date
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
naomi 208c11d153 fix: handle base64 uploads correctly (#64)
Node.js CI / CI (push) Successful in 1m28s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m31s
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #64
2026-02-20 16:53:48 -08:00
8 changed files with 144 additions and 39 deletions
+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;
}
} }
); );
+17 -2
View File
@@ -10,6 +10,7 @@ import {
validateUrl, validateUrl,
validateRating, validateRating,
validateStringLength, validateStringLength,
validateDataUrl,
MAX_LENGTHS, MAX_LENGTHS,
} from "../utils/validation"; } from "../utils/validation";
@@ -44,10 +45,24 @@ export class BookService {
throw new Error("Rating must be an integer between 0 and 10."); throw new Error("Rating must be an integer between 0 and 10.");
} }
// Validate cover image URL if (data.coverImage) {
if (data.coverImage && !validateUrl(data.coverImage)) { if (data.coverImage.startsWith("data:")) {
const sizeInBytes = data.coverImage.length * 0.75;
if (sizeInBytes > 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."); throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
} }
}
}
// Validate tags // Validate tags
if (data.tags) { if (data.tags) {
+23 -4
View File
@@ -10,6 +10,7 @@ import {
validateUrl, validateUrl,
validateRating, validateRating,
validateStringLength, validateStringLength,
validateDataUrl,
MAX_LENGTHS, MAX_LENGTHS,
} from "../utils/validation"; } from "../utils/validation";
@@ -32,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)) {
@@ -42,9 +40,30 @@ export class GameService {
} }
// Validate cover image URL // Validate cover image URL
if (data.coverImage && !validateUrl(data.coverImage)) { 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."); throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
} }
}
}
// Validate tags // Validate tags
if (data.tags) { if (data.tags) {
+17 -1
View File
@@ -10,6 +10,7 @@ import {
validateUrl, validateUrl,
validateRating, validateRating,
validateStringLength, validateStringLength,
validateDataUrl,
MAX_LENGTHS, MAX_LENGTHS,
} from "../utils/validation"; } from "../utils/validation";
@@ -42,9 +43,24 @@ export class MangaService {
} }
// Validate cover image URL // Validate cover image URL
if (data.coverImage && !validateUrl(data.coverImage)) { if (data.coverImage) {
if (data.coverImage.startsWith("data:")) {
const sizeInBytes = data.coverImage.length * 0.75;
if (sizeInBytes > 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."); throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
} }
}
}
// Validate tags // Validate tags
if (data.tags) { if (data.tags) {
+18 -2
View File
@@ -10,6 +10,7 @@ import {
validateUrl, validateUrl,
validateRating, validateRating,
validateStringLength, validateStringLength,
validateDataUrl,
MAX_LENGTHS, MAX_LENGTHS,
} from "../utils/validation"; } from "../utils/validation";
@@ -42,8 +43,23 @@ export class MusicService {
} }
// Validate cover art URL // Validate cover art URL
if (data.coverArt && !validateUrl(data.coverArt)) { if (data.coverArt) {
throw new Error("Invalid cover art URL. Only http and https URLs are allowed."); if (data.coverArt.startsWith("data:")) {
const sizeInBytes = data.coverArt.length * 0.75;
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
throw new Error("Cover image must be under 5MB.");
}
if (!validateDataUrl(data.coverArt)) {
throw new Error("Invalid image data URL.");
}
} else {
if (!validateStringLength(data.coverArt, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
if (!validateUrl(data.coverArt)) {
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
}
}
} }
// Validate tags // Validate tags
+17 -1
View File
@@ -10,6 +10,7 @@ import {
validateUrl, validateUrl,
validateRating, validateRating,
validateStringLength, validateStringLength,
validateDataUrl,
MAX_LENGTHS, MAX_LENGTHS,
} from "../utils/validation"; } from "../utils/validation";
@@ -39,9 +40,24 @@ export class ShowService {
} }
// Validate cover image URL // Validate cover image URL
if (data.coverImage && !validateUrl(data.coverImage)) { if (data.coverImage) {
if (data.coverImage.startsWith("data:")) {
const sizeInBytes = data.coverImage.length * 0.75;
if (sizeInBytes > 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."); throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
} }
}
}
// Validate tags // Validate tags
if (data.tags) { if (data.tags) {
+9
View File
@@ -4,6 +4,14 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/**
* 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+/=\s]+$/.test(url);
}
/** /**
* Validates that a URL is safe and points to an allowed protocol. * Validates that a URL is safe and points to an allowed protocol.
* Prevents javascript:, data:, vbscript:, and file: URLs. * Prevents javascript:, data:, vbscript:, and file: URLs.
@@ -83,4 +91,5 @@ export const MAX_LENGTHS = {
NOTES: 5000, NOTES: 5000,
TAGS: 50, // per tag TAGS: 50, // per tag
ISBN: 50, ISBN: 50,
DATA_URL: 5 * 1024 * 1024, // 5MB in bytes (not chars)
} as const; } as const;
+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.