diff --git a/backend/Cargo.lock b/backend/Cargo.lock index dd47dbe..7fa981f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "mangalord" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index a4a9604..8b4d547 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.8.0" +version = "0.9.0" edition = "2021" [lib] diff --git a/frontend/e2e/upload.spec.ts b/frontend/e2e/upload.spec.ts new file mode 100644 index 0000000..96c74ab --- /dev/null +++ b/frontend/e2e/upload.spec.ts @@ -0,0 +1,192 @@ +import { test, expect, type Page } from '@playwright/test'; + +const userFixture = { + id: 'u1', + username: 'alice', + created_at: '2026-01-01T00:00:00Z' +}; +const mangaFixture = { + id: 'm1', + title: 'Berserk', + author: 'Kentaro Miura', + description: null, + cover_image_path: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' +}; + +async function mockBaseUploadApis(page: Page) { + await page.route('**/api/v1/auth/me', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ user: userFixture }) + }) + ); + await page.route('**/api/v1/mangas?*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [mangaFixture], + page: { limit: 200, offset: 0, total: 1 } + }) + }) + ); +} + +test('anonymous user sees sign-in prompt on /upload', async ({ page }) => { + await page.route('**/api/v1/auth/me', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ + error: { code: 'unauthenticated', message: 'unauthenticated' } + }) + }) + ); + await page.route('**/api/v1/mangas?*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } }) + }) + ); + + await page.goto('/upload'); + await expect(page.getByTestId('upload-signin')).toBeVisible(); +}); + +test('uploading a non-image page surfaces the backend 415 message', async ({ page }) => { + await mockBaseUploadApis(page); + + // Backend rejects with 415 unsupported_media_type — we want to see + // the human message rendered as the chapter error. + await page.route('**/api/v1/mangas/m1/chapters', (route) => + route.fulfill({ + status: 415, + contentType: 'application/json', + body: JSON.stringify({ + error: { + code: 'unsupported_media_type', + message: 'page[0]: unsupported image type application/pdf' + } + }) + }) + ); + + await page.goto('/upload'); + await page.getByTestId('chapter-manga').selectOption('m1'); + await page.getByTestId('chapter-number').fill('1'); + + // Client validator allows image/png; we lie about the file type so + // the request actually reaches the (mocked) backend, exercising the + // 415 envelope path. + await page.getByTestId('chapter-pages-input').setInputFiles({ + name: 'fake.png', + mimeType: 'image/png', + buffer: Buffer.from('%PDF-1.4', 'utf-8') + }); + + await page.getByTestId('chapter-submit').click(); + await expect(page.getByTestId('chapter-error')).toContainText( + 'unsupported image type' + ); +}); + +test('happy path: create manga + upload chapter (mocked)', async ({ page }) => { + await mockBaseUploadApis(page); + + let createdManga: typeof mangaFixture | null = null; + let createdChapter: { id: string; number: number } | null = null; + + await page.route('**/api/v1/mangas', (route) => { + if (route.request().method() === 'POST') { + createdManga = { ...mangaFixture, id: 'm2', title: 'Naruto' }; + route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify(createdManga) + }); + } else { + route.fallback(); + } + }); + await page.route('**/api/v1/mangas/m1/chapters', (route) => { + if (route.request().method() === 'POST') { + createdChapter = { id: 'c1', number: 1 }; + route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + id: 'c1', + manga_id: 'm1', + number: 1, + title: null, + page_count: 2, + created_at: '2026-01-01T00:00:00Z' + }) + }); + } else { + route.fallback(); + } + }); + + await page.goto('/upload'); + + // Create manga. + await page.getByTestId('manga-title').fill('Naruto'); + await page.getByTestId('manga-submit').click(); + await expect(page.getByTestId('manga-success')).toContainText('Created'); + expect(createdManga).not.toBeNull(); + + // Upload chapter with two pages. + await page.getByTestId('chapter-manga').selectOption('m1'); + await page.getByTestId('chapter-number').fill('1'); + await page.getByTestId('chapter-pages-input').setInputFiles([ + { + name: '1.png', + mimeType: 'image/png', + buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + }, + { + name: '2.png', + mimeType: 'image/png', + buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + } + ]); + await expect(page.getByTestId('chapter-pages-list')).toContainText('1.png'); + await expect(page.getByTestId('chapter-pages-list')).toContainText('2.png'); + + await page.getByTestId('chapter-submit').click(); + await expect(page.getByTestId('chapter-success')).toContainText( + '2 pages' + ); + expect(createdChapter).not.toBeNull(); +}); + +test('client preflight blocks oversized files without hitting the network', async ({ page }) => { + await mockBaseUploadApis(page); + + let chapterPostCalls = 0; + await page.route('**/api/v1/mangas/m1/chapters', (route) => { + if (route.request().method() === 'POST') chapterPostCalls += 1; + route.fallback(); + }); + + await page.goto('/upload'); + await page.getByTestId('chapter-manga').selectOption('m1'); + await page.getByTestId('chapter-number').fill('1'); + + // A ~21 MiB buffer — exceeds the 20 MiB client cap. + const big = Buffer.alloc(21 * 1024 * 1024, 0xff); + await page.getByTestId('chapter-pages-input').setInputFiles({ + name: 'huge.png', + mimeType: 'image/png', + buffer: big + }); + + await expect(page.getByTestId('chapter-pages-list')).toContainText('too large'); + await expect(page.getByTestId('chapter-submit')).toBeDisabled(); + expect(chapterPostCalls).toBe(0); +}); diff --git a/frontend/package.json b/frontend/package.json index e767409..dff96a2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.8.0", + "version": "0.9.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/upload-validation.test.ts b/frontend/src/lib/upload-validation.test.ts new file mode 100644 index 0000000..03340f1 --- /dev/null +++ b/frontend/src/lib/upload-validation.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { validateImageFile, MAX_FILE_BYTES, formatBytes } from './upload-validation'; + +function makeFile(name: string, type: string, size: number): File { + // jsdom's File doesn't honour `size` from a tiny Blob, so we expose + // the desired size by overriding the getter on the constructed File. + const f = new File([new Uint8Array(0)], name, { type }); + Object.defineProperty(f, 'size', { value: size }); + return f; +} + +describe('validateImageFile', () => { + it('returns null for a small image', () => { + const f = makeFile('cover.png', 'image/png', 1024); + expect(validateImageFile(f)).toBeNull(); + }); + + it('rejects oversized files with a sized message', () => { + const f = makeFile('cover.png', 'image/png', MAX_FILE_BYTES + 1); + const err = validateImageFile(f); + expect(err).toContain('cover.png'); + expect(err).toContain('too large'); + }); + + it('rejects non-image MIME types', () => { + const f = makeFile('doc.pdf', 'application/pdf', 1024); + const err = validateImageFile(f); + expect(err).toContain('unsupported image type'); + expect(err).toContain('application/pdf'); + }); + + it('allows files with unknown MIME (lets the backend sniff decide)', () => { + const f = makeFile('mystery.bin', '', 1024); + expect(validateImageFile(f)).toBeNull(); + }); + + it.each(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif'])( + 'allows %s', + (type) => { + const f = makeFile('x', type, 1024); + expect(validateImageFile(f)).toBeNull(); + } + ); +}); + +describe('formatBytes', () => { + it('formats KiB and MiB sensibly', () => { + expect(formatBytes(512)).toBe('512 B'); + expect(formatBytes(2048)).toBe('2 KiB'); + expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MiB'); + }); +}); diff --git a/frontend/src/lib/upload-validation.ts b/frontend/src/lib/upload-validation.ts new file mode 100644 index 0000000..9fbfa5a --- /dev/null +++ b/frontend/src/lib/upload-validation.ts @@ -0,0 +1,39 @@ +// Client-side preflight for uploads. Mirrors the backend's whitelist so +// obviously bad files don't make a doomed multipart round-trip; the +// backend's magic-byte sniff (in /api/v1/mangas and /chapters) stays the +// authoritative check and surfaces its own error envelope when the +// client-supplied MIME is misleading. + +export const MAX_FILE_BYTES = 20 * 1024 * 1024; // 20 MiB + +export const ALLOWED_IMAGE_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'image/avif' +] as const; + +export type ImageType = (typeof ALLOWED_IMAGE_TYPES)[number]; + +export function formatBytes(n: number): string { + if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MiB`; + if (n >= 1024) return `${(n / 1024).toFixed(0)} KiB`; + return `${n} B`; +} + +/** + * Returns an error message if `file` is obviously unsuitable, or null + * if it should be allowed through to the multipart request. An unknown + * file.type (empty string — happens on some drag sources) is treated as + * "let the backend sniff decide". + */ +export function validateImageFile(file: File): string | null { + if (file.size > MAX_FILE_BYTES) { + return `${file.name} is too large (${formatBytes(file.size)} > ${formatBytes(MAX_FILE_BYTES)}).`; + } + if (file.type && !(ALLOWED_IMAGE_TYPES as readonly string[]).includes(file.type)) { + return `${file.name}: unsupported image type ${file.type}.`; + } + return null; +} diff --git a/frontend/src/routes/upload/+page.svelte b/frontend/src/routes/upload/+page.svelte new file mode 100644 index 0000000..2e1aca8 --- /dev/null +++ b/frontend/src/routes/upload/+page.svelte @@ -0,0 +1,461 @@ + + + + Upload — Mangalord + + +

Upload

+ +{#if !session.loaded} +

Loading…

+{:else if !session.user} +

+ Sign in to upload mangas or chapters. +

+{:else} +
+

Create manga

+
+ + + + + + {#if mangaSuccess} +

{mangaSuccess}

+ {/if} + {#if mangaError} + + {/if} +
+
+ +
+

Upload chapter

+ {#if mangas.length === 0} +

+ No mangas yet — create one above first. +

+ {:else} +
+ + + + +
+

+ Drop pages here, or + +

+
+ + {#if chapterPages.length > 0} +
    + {#each chapterPages as p, i (p.id)} +
  1. + {p.file.name} + {formatBytes(p.file.size)} + + + + {#if p.error} + {p.error} + {/if} +
  2. + {/each} +
+ {/if} + + + {#if chapterSuccess} +

{chapterSuccess}

+ {/if} + {#if chapterError} + + {/if} +
+ {/if} +
+{/if} + + diff --git a/frontend/src/routes/upload/+page.ts b/frontend/src/routes/upload/+page.ts new file mode 100644 index 0000000..96ca08a --- /dev/null +++ b/frontend/src/routes/upload/+page.ts @@ -0,0 +1,12 @@ +import { listMangas, type Manga } from '$lib/api/mangas'; +import type { PageLoad } from './$types'; + +export const ssr = false; + +export const load: PageLoad = async () => { + // The chapter form needs a list of mangas to attach the new chapter + // to. There's no ownership concept yet, so any authenticated user can + // see and add to any manga. + const { items } = await listMangas({ limit: 200, sort: 'title' }); + return { mangas: items as Manga[] }; +};