From 6d5b0581a5fbe3f9745f364fa6e8fa0d1a002fb0 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 23 Feb 2026 20:37:52 -0800 Subject: [PATCH] fix: base64 uploads, audit log noise, and stale chunk reloads (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - **Base64 cover image uploads broken for books, shows, manga, and music** — a premature `validateStringLength` check ran before the data URL detection, rejecting all base64 images with a 2,048-char URL limit error. Also fixed the size calculation to extract only the base64 portion after the comma (matching the correct pattern already in `game.service.ts`). - **Audit log flooded with expected 401s on `/api/auth/me`** — these occur during normal token refresh flow and are not genuine security events. Excluded this URL from the global 401/403 audit log handler. - **ChunkLoadError spam after deployments** — when Angular lazy-loaded chunks are missing (stale cache after a redeploy), the global error handler now detects `ChunkLoadError` and silently reloads the page instead of logging the error and sending it to the API/Discord. ## Test plan - [ ] Upload a base64 cover image for a book, show, manga, and music item — should succeed - [ ] Verify `/api/auth/me` 401s no longer appear in the audit log - [ ] Deploy a new build and confirm stale-chunk users are silently reloaded ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/library/pulls/69 Co-authored-by: Hikari Co-committed-by: Hikari --- api/src/app/app.ts | 4 ++-- api/src/app/services/book.service.ts | 10 +++++----- api/src/app/services/manga.service.ts | 10 +++++----- api/src/app/services/music.service.ts | 10 +++++----- api/src/app/services/show.service.ts | 10 +++++----- .../src/app/services/global-error-handler.service.ts | 5 +++++ 6 files changed, 27 insertions(+), 22 deletions(-) diff --git a/api/src/app/app.ts b/api/src/app/app.ts index bdaee04..433b8a5 100644 --- a/api/src/app/app.ts +++ b/api/src/app/app.ts @@ -22,8 +22,8 @@ export async function app(fastify: FastifyInstance, opts: AppOptions) { }); } - // Log unauthorized access attempts - if (error.statusCode === 401 || error.statusCode === 403) { + // Log unauthorized access attempts (exclude /api/auth/me as 401s there are expected during token refresh) + if ((error.statusCode === 401 || error.statusCode === 403) && request.url !== '/api/auth/me') { await AuditService.log({ action: AuditAction.unauthorizedAccess, category: AuditCategory.security, diff --git a/api/src/app/services/book.service.ts b/api/src/app/services/book.service.ts index 638d302..5fcf34c 100644 --- a/api/src/app/services/book.service.ts +++ b/api/src/app/services/book.service.ts @@ -36,10 +36,6 @@ export class BookService { 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)) { throw new Error("Rating must be an integer between 0 and 10."); @@ -47,7 +43,11 @@ export class BookService { if (data.coverImage) { if (data.coverImage.startsWith("data:")) { - const sizeInBytes = data.coverImage.length * 0.75; + const base64Data = data.coverImage.split(",")[1]; + if (!base64Data) { + throw new Error("Invalid image data URL format."); + } + const sizeInBytes = base64Data.length * 0.75; if (sizeInBytes > MAX_LENGTHS.DATA_URL) { throw new Error("Cover image must be under 5MB."); } diff --git a/api/src/app/services/manga.service.ts b/api/src/app/services/manga.service.ts index 1fa19b5..40beb10 100644 --- a/api/src/app/services/manga.service.ts +++ b/api/src/app/services/manga.service.ts @@ -33,10 +33,6 @@ export class MangaService { 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)) { throw new Error("Rating must be an integer between 0 and 10."); @@ -45,7 +41,11 @@ export class MangaService { // Validate cover image URL if (data.coverImage) { if (data.coverImage.startsWith("data:")) { - const sizeInBytes = data.coverImage.length * 0.75; + const base64Data = data.coverImage.split(",")[1]; + if (!base64Data) { + throw new Error("Invalid image data URL format."); + } + const sizeInBytes = base64Data.length * 0.75; if (sizeInBytes > MAX_LENGTHS.DATA_URL) { throw new Error("Cover image must be under 5MB."); } diff --git a/api/src/app/services/music.service.ts b/api/src/app/services/music.service.ts index c0480d8..0652c0b 100644 --- a/api/src/app/services/music.service.ts +++ b/api/src/app/services/music.service.ts @@ -33,10 +33,6 @@ export class MusicService { if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) { throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`); } - if (!validateStringLength(data.coverArt, MAX_LENGTHS.URL)) { - throw new Error(`Cover art URL must be ${MAX_LENGTHS.URL} characters or less.`); - } - // Validate rating if (data.rating !== undefined && !validateRating(data.rating)) { throw new Error("Rating must be an integer between 0 and 10."); @@ -45,7 +41,11 @@ export class MusicService { // Validate cover art URL if (data.coverArt) { if (data.coverArt.startsWith("data:")) { - const sizeInBytes = data.coverArt.length * 0.75; + const base64Data = data.coverArt.split(",")[1]; + if (!base64Data) { + throw new Error("Invalid image data URL format."); + } + const sizeInBytes = base64Data.length * 0.75; if (sizeInBytes > MAX_LENGTHS.DATA_URL) { throw new Error("Cover image must be under 5MB."); } diff --git a/api/src/app/services/show.service.ts b/api/src/app/services/show.service.ts index cda0ec9..5d5aed9 100644 --- a/api/src/app/services/show.service.ts +++ b/api/src/app/services/show.service.ts @@ -30,10 +30,6 @@ export class ShowService { 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)) { throw new Error("Rating must be an integer between 0 and 10."); @@ -42,7 +38,11 @@ export class ShowService { // Validate cover image URL if (data.coverImage) { if (data.coverImage.startsWith("data:")) { - const sizeInBytes = data.coverImage.length * 0.75; + const base64Data = data.coverImage.split(",")[1]; + if (!base64Data) { + throw new Error("Invalid image data URL format."); + } + const sizeInBytes = base64Data.length * 0.75; if (sizeInBytes > MAX_LENGTHS.DATA_URL) { throw new Error("Cover image must be under 5MB."); } diff --git a/apps/frontend/src/app/services/global-error-handler.service.ts b/apps/frontend/src/app/services/global-error-handler.service.ts index d798e38..7270dea 100644 --- a/apps/frontend/src/app/services/global-error-handler.service.ts +++ b/apps/frontend/src/app/services/global-error-handler.service.ts @@ -12,6 +12,11 @@ export class GlobalErrorHandler implements ErrorHandler { private toast = inject(ToastService); handleError(error: Error): void { + if (error.name === 'ChunkLoadError' || error.message.includes('Loading chunk')) { + window.location.reload(); + return; + } + console.error('Global error caught:', error); // Show user-friendly error message