diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 9617e21..66cda5e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "mangalord" -version = "0.19.2" +version = "0.20.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e46a192..a6c73b8 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.19.2" +version = "0.20.0" edition = "2021" [lib] diff --git a/frontend/e2e/upload.spec.ts b/frontend/e2e/upload.spec.ts index 96c74ab..0b3c161 100644 --- a/frontend/e2e/upload.spec.ts +++ b/frontend/e2e/upload.spec.ts @@ -8,14 +8,18 @@ const userFixture = { const mangaFixture = { id: 'm1', title: 'Berserk', - author: 'Kentaro Miura', + status: 'ongoing', + alt_titles: [], description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z' + updated_at: '2026-01-01T00:00:00Z', + authors: [{ id: 'a1', name: 'Kentaro Miura' }], + genres: [], + tags: [] }; -async function mockBaseUploadApis(page: Page) { +async function stubAuthenticatedAndGenres(page: Page) { await page.route('**/api/v1/auth/me', (route) => route.fulfill({ status: 200, @@ -23,14 +27,14 @@ async function mockBaseUploadApis(page: Page) { body: JSON.stringify({ user: userFixture }) }) ); - await page.route('**/api/v1/mangas?*', (route) => + await page.route('**/api/v1/genres', (route) => route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify({ - items: [mangaFixture], - page: { limit: 200, offset: 0, total: 1 } - }) + body: JSON.stringify([ + { id: 'g-action', name: 'Action' }, + { id: 'g-fantasy', name: 'Fantasy' } + ]) }) ); } @@ -45,61 +49,20 @@ test('anonymous user sees sign-in prompt on /upload', async ({ page }) => { }) }) ); - 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.route('**/api/v1/genres', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) ); 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); +test('/upload creates a manga with no staged chapters and lands on the manga page', async ({ + page +}) => { + await stubAuthenticatedAndGenres(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' }; @@ -112,15 +75,88 @@ test('happy path: create manga + upload chapter (mocked)', async ({ page }) => { route.fallback(); } }); - await page.route('**/api/v1/mangas/m1/chapters', (route) => { + await page.route('**/api/v1/mangas/m2', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ...mangaFixture, id: 'm2', title: 'Naruto' }) + }) + ); + await page.route('**/api/v1/mangas/m2/chapters*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [], + page: { limit: 50, offset: 0, total: 0 } + }) + }) + ); + await page.route('**/api/v1/me/bookmarks*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [], + page: { limit: 50, offset: 0, total: 0 } + }) + }) + ); + await page.route('**/api/v1/me/read-progress/m2', (route) => + route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ + error: { code: 'not_found', message: 'no progress' } + }) + }) + ); + + await page.goto('/upload'); + await page.getByTestId('manga-title').fill('Naruto'); + await page.getByTestId('manga-submit').click(); + // After create, success → navigate to /manga/{id}. + await expect(page).toHaveURL(/\/manga\/m2$/); + expect(createdManga).not.toBeNull(); +}); + +test('/upload stages a chapter with renamed page files (page-NNN.)', async ({ + page +}) => { + await stubAuthenticatedAndGenres(page); + + let createdManga: typeof mangaFixture | null = null; + let submittedPageNames: string[] = []; + + await page.route('**/api/v1/mangas', (route) => { if (route.request().method() === 'POST') { - createdChapter = { id: 'c1', number: 1 }; + createdManga = { ...mangaFixture, id: 'm3', title: 'Vinland Saga' }; + route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify(createdManga) + }); + } else { + route.fallback(); + } + }); + await page.route('**/api/v1/mangas/m3/chapters', (route) => { + if (route.request().method() === 'POST') { + const post = route.request().postDataBuffer()?.toString('binary') ?? ''; + // Pull every Content-Disposition filename out of the + // multipart body — that's what the server (and proxies, + // logs) would see. We expect only renamed `page-NNN.*` + // entries, never the original filenames. + const matches = [ + ...post.matchAll(/filename="([^"]+)"/g) + ].map((m) => m[1]); + submittedPageNames = matches.filter((n) => n.startsWith('page-')); route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ id: 'c1', - manga_id: 'm1', + manga_id: 'm3', number: 1, title: null, page_count: 2, @@ -131,62 +167,188 @@ test('happy path: create manga + upload chapter (mocked)', async ({ page }) => { route.fallback(); } }); + await page.route('**/api/v1/mangas/m3', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ...mangaFixture, id: 'm3', title: 'Vinland Saga' }) + }) + ); + await page.route('**/api/v1/mangas/m3/chapters?*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [], + page: { limit: 50, offset: 0, total: 0 } + }) + }) + ); + await page.route('**/api/v1/me/bookmarks*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [], + page: { limit: 50, offset: 0, total: 0 } + }) + }) + ); + await page.route('**/api/v1/me/read-progress/m3', (route) => + route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ + error: { code: 'not_found', message: 'no progress' } + }) + }) + ); 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 page.getByTestId('manga-title').fill('Vinland Saga'); + await page.getByTestId('add-chapter').click(); + const pngBytes = 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('staged-chapter-pages-input') + .setInputFiles([ + { name: 'IMG_2837.png', mimeType: 'image/png', buffer: pngBytes }, + { name: 'random_file.png', mimeType: 'image/png', buffer: pngBytes } + ]); + // The list renders "Page 001" / "Page 002" not the original filenames. + const list = page.getByTestId('staged-chapter-pages-list'); + await expect(list).toContainText('Page 001'); + await expect(list).toContainText('Page 002'); + // Original filenames are visible as a dimmed caption (uploader- + // reference; dropped after the row). + await expect(list).toContainText('IMG_2837.png'); - await page.getByTestId('chapter-submit').click(); - await expect(page.getByTestId('chapter-success')).toContainText( - '2 pages' - ); - expect(createdChapter).not.toBeNull(); + await page.getByTestId('manga-submit').click(); + await expect(page).toHaveURL(/\/manga\/m3$/); + expect(submittedPageNames).toEqual(['page-001.png', 'page-002.png']); }); -test('client preflight blocks oversized files without hitting the network', async ({ page }) => { - await mockBaseUploadApis(page); +test('/manga/[id]/upload-chapter happy path uploads renamed pages', async ({ + page +}) => { + await stubAuthenticatedAndGenres(page); + await page.route('**/api/v1/mangas/m1', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mangaFixture) + }) + ); + await page.route('**/api/v1/mangas/m1/chapters?*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [{ id: 'c0', manga_id: 'm1', number: 1, title: null, page_count: 3, created_at: '2026-01-01T00:00:00Z' }], + page: { limit: 200, offset: 0, total: 1 } + }) + }) + ); + let submitted: string[] = []; + await page.route('**/api/v1/mangas/m1/chapters', (route) => { + if (route.request().method() === 'POST') { + const post = route.request().postDataBuffer()?.toString('binary') ?? ''; + submitted = [...post.matchAll(/filename="([^"]+)"/g)].map((m) => m[1]); + route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + id: 'c-new', + manga_id: 'm1', + number: 2, + title: null, + page_count: 1, + created_at: '2026-01-01T00:00:00Z' + }) + }); + } else { + route.fallback(); + } + }); + await page.route('**/api/v1/me/bookmarks*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [], + page: { limit: 50, offset: 0, total: 0 } + }) + }) + ); + await page.route('**/api/v1/me/read-progress/m1', (route) => + route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ + error: { code: 'not_found', message: 'no progress' } + }) + }) + ); + + await page.goto('/manga/m1/upload-chapter'); + // Default chapter number is the next free one (existing max 1 → 2). + await expect(page.getByTestId('chapter-number')).toHaveValue('2'); + + const pngBytes = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a + ]); + await page.getByTestId('pages-input').setInputFiles({ + name: 'whatever.png', + mimeType: 'image/png', + buffer: pngBytes + }); + await expect(page.getByTestId('pages-list')).toContainText('Page 001'); + + await page.getByTestId('chapter-submit').click(); + await expect(page).toHaveURL(/\/manga\/m1$/); + expect(submitted.filter((n) => n.startsWith('page-'))).toEqual([ + 'page-001.png' + ]); +}); + +test('chapter upload client preflight blocks oversized files', async ({ + page +}) => { + await stubAuthenticatedAndGenres(page); + await page.route('**/api/v1/mangas/m1', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mangaFixture) + }) + ); + await page.route('**/api/v1/mangas/m1/chapters?*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [], + page: { limit: 200, offset: 0, total: 0 } + }) + }) + ); 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. + await page.goto('/manga/m1/upload-chapter'); const big = Buffer.alloc(21 * 1024 * 1024, 0xff); - await page.getByTestId('chapter-pages-input').setInputFiles({ + await page.getByTestId('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('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 dfbf692..b2193f6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.19.2", + "version": "0.20.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/chapters.test.ts b/frontend/src/lib/api/chapters.test.ts index c1c8568..1e8034d 100644 --- a/frontend/src/lib/api/chapters.test.ts +++ b/frontend/src/lib/api/chapters.test.ts @@ -7,7 +7,12 @@ import { afterEach, type MockInstance } from 'vitest'; -import { listChapters, getChapter, getChapterPages } from './chapters'; +import { + listChapters, + getChapter, + getChapterPages, + createChapter +} from './chapters'; function ok(body: unknown): Response { return new Response(JSON.stringify(body), { @@ -87,6 +92,43 @@ describe('chapters api client', () => { }); }); + it('createChapter POSTs multipart and renames page files to page-NNN.', async () => { + fetchSpy.mockResolvedValueOnce(ok({ ...chapterFixture, page_count: 3 })); + const pages = [ + new File([new Uint8Array([1, 2])], 'IMG_2837.HEIC', { type: 'image/jpeg' }), + new File([new Uint8Array([3, 4])], 'random.png', { type: 'image/png' }), + // No extension; MIME-derived fallback should kick in. + new File([new Uint8Array([5])], 'scan_42', { type: 'image/webp' }) + ]; + const result = await createChapter( + 'm1', + { number: 1, title: null }, + pages + ); + expect(result.page_count).toBe(3); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/mangas\/m1\/chapters$/); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('POST'); + const form = init.body as FormData; + // Metadata part is JSON. + const metadata = form.get('metadata') as Blob; + expect(metadata.type).toBe('application/json'); + // Three pages, all renamed; original filenames discarded. + const submitted = form.getAll('page') as File[]; + expect(submitted).toHaveLength(3); + // Original-extension preferred over MIME-derived; capitalised + // .HEIC dropped because it's not in the allowed list, so the + // MIME-derived `.jpg` wins. + expect(submitted[0].name).toBe('page-001.jpg'); + expect(submitted[1].name).toBe('page-002.png'); + expect(submitted[2].name).toBe('page-003.webp'); + // No original filenames leak through. + for (const f of submitted) { + expect(f.name).not.toMatch(/IMG_2837|random|scan_42/); + } + }); + it('getChapterPages unwraps the {pages} envelope into the array', async () => { fetchSpy.mockResolvedValueOnce( ok({ diff --git a/frontend/src/lib/api/chapters.ts b/frontend/src/lib/api/chapters.ts index 9a330c9..a9f4315 100644 --- a/frontend/src/lib/api/chapters.ts +++ b/frontend/src/lib/api/chapters.ts @@ -55,3 +55,66 @@ export async function getChapterPages( ); return r.pages; } + +export type NewChapter = { + number: number; + title?: string | null; +}; + +/** + * `POST /api/v1/mangas/:id/chapters` is multipart: a `metadata` part + * (JSON) plus one or more ordered `page` parts. Each page file is + * renamed to `page-NNN.` before submission so the user's + * original filenames (often personally-identifying or just messy: + * `IMG_2837.HEIC`, `~/scans/full chapter pack/`) don't end up in + * request bodies or server logs. The bytes are unchanged — the + * backend still sniffs the MIME from magic bytes and stores under + * its own `{nnnn}.{ext}` scheme. + */ +export async function createChapter( + mangaId: string, + metadata: NewChapter, + pages: File[] +): Promise { + const form = new FormData(); + form.append( + 'metadata', + new Blob([JSON.stringify(metadata)], { type: 'application/json' }) + ); + pages.forEach((file, i) => { + const ext = extensionFor(file); + const renamed = new File( + [file], + `page-${String(i + 1).padStart(3, '0')}${ext}`, + { type: file.type } + ); + form.append('page', renamed); + }); + return request( + `/v1/mangas/${encodeURIComponent(mangaId)}/chapters`, + { method: 'POST', body: form } + ); +} + +/** + * Pick a sensible extension for the renamed multipart part. Prefer + * the original filename's extension when present (jpg/jpeg/png/webp/ + * gif/avif), otherwise derive from the MIME type. Falls back to an + * empty string so the renamed file is just `page-001` — the + * server sniffs bytes anyway. + */ +function extensionFor(file: File): string { + const dot = file.name.lastIndexOf('.'); + if (dot > 0) { + const ext = file.name.slice(dot).toLowerCase(); + if (/^\.(jpe?g|png|webp|gif|avif)$/.test(ext)) return ext; + } + const fromMime: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/gif': '.gif', + 'image/avif': '.avif' + }; + return fromMime[file.type] ?? ''; +} diff --git a/frontend/src/lib/components/ChapterPagesEditor.svelte b/frontend/src/lib/components/ChapterPagesEditor.svelte new file mode 100644 index 0000000..57e0b91 --- /dev/null +++ b/frontend/src/lib/components/ChapterPagesEditor.svelte @@ -0,0 +1,337 @@ + + + + +
+
+ +{#if pages.length > 0} +
    + {#each pages as p, i (p.id)} +
  1. + +
    + {pageLabel(i)} + + from {p.file.name} · {formatBytes(p.file.size)} + +
    + + + + {#if p.error} + {p.error} + {/if} +
  2. + {/each} +
+{/if} + + (previewIndex = null)} + size="lg" + closeOnBackdrop={true} + testid="page-preview-modal" +> + {#if previewPage} + {pageLabel(previewIndex + {/if} + + + diff --git a/frontend/src/routes/manga/[id]/+page.svelte b/frontend/src/routes/manga/[id]/+page.svelte index a394ebf..d3203f1 100644 --- a/frontend/src/routes/manga/[id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/+page.svelte @@ -14,6 +14,7 @@ import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte'; import Plus from '@lucide/svelte/icons/plus'; import FolderPlus from '@lucide/svelte/icons/folder-plus'; + import UploadCloud from '@lucide/svelte/icons/upload-cloud'; let { data } = $props(); const manga = $derived(data.manga); @@ -323,6 +324,14 @@