fix(review-2): critical — repair comment posting + close export data leak
CR1: the lightbox comment button POSTed to /upload/{id}/comment (singular);
no such route exists, so every UI-submitted comment 404'd and was lost.
Fixed to /comments (plural). The prior e2e passed because its seed helper
POSTs the API directly — added comment-ui.spec.ts which drives the real
component so this can't regress silently again.
CR2: export archives (Gallery.zip / Memories.zip / HTML viewer) were written
under media_path, which is a public ServeDir — so GET /media/exports/
Gallery.zip served the entire event (every photo, caption, comment,
uploader name) to any anonymous visitor at a guessable URL, bypassing the
ticket + release gate. Moved exports to a separate EXPORT_PATH (=/exports)
on its own volume (Dockerfile chown, compose volume, gated handler reads
the new path). export-leak.spec.ts asserts /media/exports/Gallery.zip → 404.
Riders (git can't split hunks): LightboxModal also gains H3 live-eviction on
comment-deleted/upload-deleted; config.rs also wires compression_concurrency
from boot config (medium).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
53
e2e/specs/03-feed/comment-ui.spec.ts
Normal file
53
e2e/specs/03-feed/comment-ui.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Regression for the review's CR1: the LightboxModal posted comments to
|
||||
* `/upload/{id}/comment` (singular) while the only route is `/comments` (plural),
|
||||
* so every comment submitted through the UI 404'd and was silently lost. The
|
||||
* earlier "comment → SSE" spec passed by posting via a fetch helper, bypassing
|
||||
* the component — a false green. This drives the real component end-to-end.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
|
||||
test.describe('Comments — UI round-trip (CR1)', () => {
|
||||
test('a comment typed in the lightbox persists to the backend', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const author = await guest('CommentAuthor');
|
||||
const commenter = await guest('Commenter');
|
||||
const uploadId = await seedUpload(author.jwt, { caption: 'Comment target' });
|
||||
|
||||
await signIn(page, commenter);
|
||||
await page.goto('/feed');
|
||||
|
||||
// Open the lightbox. Only one upload exists, so the first open-button is it.
|
||||
const imageButton = page.getByRole('button', { name: 'Bild vergrößern' }).first();
|
||||
await expect(imageButton).toBeVisible({ timeout: 15_000 });
|
||||
await imageButton.click();
|
||||
|
||||
const lightbox = page.locator('[role="dialog"][aria-labelledby="lightbox-title"]');
|
||||
await expect(lightbox).toBeVisible();
|
||||
|
||||
const text = `Wunderschönes Foto ${Date.now()}`;
|
||||
await lightbox.getByPlaceholder(/kommentar/i).fill(text);
|
||||
await lightbox.getByRole('button', { name: /senden/i }).click();
|
||||
|
||||
// The component appends the comment only on a 2xx — with the old singular path
|
||||
// it threw and nothing appeared. Assert it's visible in the panel...
|
||||
await expect(lightbox.getByText(text)).toBeVisible();
|
||||
|
||||
// ...and that it actually persisted server-side (the crux CR1 broke).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
|
||||
headers: { Authorization: `Bearer ${commenter.jwt}` },
|
||||
});
|
||||
const body = await res.json();
|
||||
return Array.isArray(body) && body.some((c: { body: string }) => c.body === text);
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
22
e2e/specs/06-export/export-leak.spec.ts
Normal file
22
e2e/specs/06-export/export-leak.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Regression for the review's CR2: export archives (Gallery.zip / Memories.zip)
|
||||
* were written under media_path/exports, and /media is a public ServeDir — so
|
||||
* anyone could GET /media/exports/Gallery.zip and download the whole gallery,
|
||||
* bypassing the ticket + export_*_ready gate. Exports now live OUTSIDE media_path
|
||||
* and are reachable only via the gated /api/v1/export/{zip,html} handlers (covered
|
||||
* by export.spec.ts). Here we assert the public path is dead.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
|
||||
test.describe('Export — no public leak (CR2)', () => {
|
||||
test('archives are not reachable through the public /media path', async () => {
|
||||
for (const name of ['Gallery.zip', 'Memories.zip']) {
|
||||
const res = await fetch(`${BASE}/media/exports/${name}`);
|
||||
// 404 (not 200): a 200 here would mean the whole-gallery archive is
|
||||
// downloadable without any auth — the CR2 data-exposure regression.
|
||||
expect(res.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user