Implement photo processing library and uploads streaming route #4
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Task 4 — Plan reference:
docs/superpowers/plans/2026-05-05-erbstuecke-wannsee.mdUser story: As an admin, I want uploaded photos to be automatically resized and converted to WebP so that the gallery loads fast on mobile; and as a developer I need a route to stream those photos to the browser.
Acceptance criteria
processAndSavePhoto(buffer, artikelId)resizes to max 1200×1200px (preserving aspect ratio), converts to WebP quality 80, saves to$UPLOAD_DIR/{artikelId}/{uuid}.webp, returns relative filenamedeletePhoto(filename)removes the file; does not throw if file is already gonedeleteArtikelPhotos(artikelId)removes the entire{artikelId}/directory; does not throw if missingresolveUploadPath(filename)returns the absolute path for streamingGET /uploads/[...path]streams the WebP withContent-Type: image/webpandCache-Control: public, max-age=31536000, immutableUPLOAD_DIR)Files to create
src/lib/photos.tssrc/lib/photos.test.tssrc/routes/uploads/[...path]/+server.tsNFR
/uploads/— URLs are UUID-based and not guessable (spec decision)Depends on: #1 | Size: S | Spec: system-design §5
👨💻 Felix Brandt — Senior Fullstack Developer
Observations
photos.tsusessharp(...).toFile(dest)which is correct, but note the plan also shows.toBuffer()in the persona guide example — thetoFilevariant is the right choice here since we're writing to disk anyway (avoids a double-buffer).uploadDir()is a private helper that readsprocess.env.UPLOAD_DIR ?? 'uploads'at call time — this is the right approach for test isolation (tests setprocess.env.UPLOAD_DIR = TEST_DIRinbeforeEach). If the function were called at module-load time the test setup wouldn't work.tmpdir() + Date.now()for isolation — good. But theprocessAndSavePhototests call the realsharp, which requires a valid image buffer. TheTINY_JPEGbase64 constant is correct for this, but it creates a real filesystem dependency. This is intentional and acceptable ���photos.tsis inherently side-effectful.it()for the filename pattern.deletePhotosilently catches all errors, not justENOENT. If sharp writes a corrupted file that causes a permissions error, it will be silently swallowed. The AC says "does not throw if file is already gone" — a tighter catch would beif (e.code !== 'ENOENT') throw e.resolveUploadPathfunction is inphotos.tsbut has no test. The AC lists it — a one-liner test covering the return value would close this gap.Recommendations
deletePhotocatch to re-throw non-ENOENTerrors:resolveUploadPath:it()to satisfy the "5 tests" AC exactly and make failures immediately diagnosable:!target.startsWith(base)without appendingpath.sep. Per the project's own security conventions (seeSecure Codesection in the developer persona), it must be!target.startsWith(base + sep)to prevent/uploads-evil/matching/uploads. Fix before committing.🏛️ Markus Keller — Application Architect
Observations
src/lib/photos.ts,src/lib/photos.test.ts,src/routes/uploads/[...path]/+server.ts) is exactly right —photos.tsis a pure I/O module with one responsibility, the route is a thin streaming adapter. This matches thelib/one-responsibility-per-module principle.uploadDir()reads fromprocess.env.UPLOAD_DIRat call time rather than module load — this is the correct design for testability. Do not move it to module scope.resolveUploadPathfunction creates a clean seam betweenphotos.ts(which knows the storage layout) and the route (which callsresolveUploadPathto get the absolute path). This is the right abstraction — the route should not reconstruct the path itself.createReadStream+ casts toReadableStream. In Node 18+ / SvelteKit's Node adapter this works, but it is fragile — SvelteKit'sResponseexpects a Web StreamsReadableStream, not a NodeReadable. The cast (stream as unknown as ReadableStream) papers over a type mismatch that will produce runtime errors on certain Node versions.Content-Length. For a 1-year immutable cache this is a cosmetic issue on first load but a correctness issue if the client needs to show a progress bar. Not a blocker for MVP.deleteArtikelPhotosusesexistsSync(sync) to guard before asyncrm. The guard is redundant —rm(dir, { recursive: true, force: true })(withforce: true) never throws if the directory is missing, and removes the sync I/O call.Recommendations
createReadStreamcast with a proper Node-to-Web stream conversion:Readable.toWebis available in Node 17+ and is type-safe.deleteArtikelPhotosby removing the sync guard:force: truemakes it idempotent. Fewer lines, no sync I/O, same behavior.UPLOAD_DIRis set indocker-compose.ymlto/app/uploads(it is, per the system design) and that the named volumeuploads:maps to that path. The route and the photo library both depend on this env var at runtime — no startup check exists yet. Consider adding a startup guard alongsideSESSION_SECRET.🔒 Nora Steiner (NullX) — Application Security Engineer
Observations
The issue explicitly carries a security decision: no auth on
/uploads/because URLs are UUID-based and not guessable. I'll audit that decision and everything else on the attack surface.Path traversal (CWE-22) — the primary risk in this task:
The plan's route has:
Two problems:
baseis computed fromuploadDir()which may or may not have a trailing separator.startsWith('/app/uploads')would incorrectly allow/app/uploads-evil/.... The fix documented in the project's own persona files istarget.startsWith(base + path.sep). This is not applied in the plan.404, not400. Returning404for a traversal attempt is actually better from an information-disclosure standpoint (doesn't confirm the check exists), but the existing security comment in the project (error(400, 'Invalid path')) uses400. Pick one and be consistent — I have no strong preference, but400is more semantically honest that the request was malformed.No auth on uploads — threat model assessment:
The spec decision (UUID-based, unguessable) is sound for a private family app. UUIDs from
crypto.randomUUID()provide 122 bits of entropy. The risk is acceptable if:artikelId/uuid.webpis the pattern —artikelIdis sequential butuuidis random).Content-Type hardcoding:
The route always returns
Content-Type: image/webp. If a non-WebP file were written to the uploads directory by some future path, it would be served with the wrong MIME type. This is currently a non-issue becausephotos.tsis the only writer and always produces.webp. Document this assumption with a comment in the route.Missing security comment in the route:
The path traversal guard has no explanatory comment. Per project standards, security code must explain the threat model.
Recommendations
path.sep:UPLOAD_DIRto the startup env-var check alongsideSESSION_SECRET— failing silently (falling back to'uploads'relative path) in production would write files to the wrong location.🧪 Sara Holt — QA Engineer & Test Strategist
Observations
The issue specifies 5 Vitest tests by name. The plan's
photos.test.tscurrently covers exactly 5 behaviors across 4it()blocks — the "saves WebP" test also asserts the filename pattern, which conflates two assertions. Here's the count:it('saves a WebP file under UPLOAD_DIR...')expectit('removes the file')it('does not throw if file is already gone')it('removes the entire artikel directory')The AC states 5 tests pass — strictly interpreted, splitting the filename pattern into its own
it()would satisfy "5 named tests" unambiguously.Positive observations:
beforeEach/afterEachwith a uniqueTEST_DIRper test run — clean, deterministic, no shared state.existsSyncto assert filesystem side-effects — that's the right assertion for file operations.TINY_JPEGconstant is well-chosen: a real parseable JPEG (~600 bytes) means sharp will run its full pipeline without mocking.Gaps I notice:
resolveUploadPathhas no test — it's listed in the AC as a deliverable function.Content-TypeorCache-Controlheaders of the streaming route. The AC requires these headers specifically. These need at minimum a note explaining how they'll be verified (integration test, Playwright, or manual smoke test).deleteArtikelPhotosidempotency (second call on already-deleted dir) — the AC says "does not throw if missing." The plan has one test for this (deleteArtikelPhotos does not throw if directory does not exist) which is correct, but it's counted as the 5th test — once the filename-patternit()is split out there will be 6 tests total, which is fine.describeblocks — good for organization. ThebeforeEach/afterEachare at module scope and apply to all tests, which is correct.Recommendations
resolveUploadPathtest as a 6th test (the AC names it, so it needs coverage regardless of the count):Content-Type: image/webpandCache-Control: public, max-age=31536000, immutablein a post-implementation smoke test. These are AC items — they need evidence of being tested. A singlefetch('/uploads/...')check in a Playwright smoke test or a dedicated+server.tsunit test would suffice.it('saves a WebP file under UPLOAD_DIR/{artikelId}/'),it('removes the file'), etc.describe.eachif thedeletePhoto/deleteArtikelPhotosidempotency pattern is reused — not needed now but the pattern is worth noting for whenauth.test.tsanddb.test.tsfollow.🎨 Leonie Voss — UX Design Lead & Accessibility Strategist
Observations
This task is backend-only (
photos.ts,photos.test.ts, and the uploads streaming route). No Svelte components, no UI, no design tokens involved. My primary concern is therefore the downstream UX contract this module establishes for the gallery and admin views that depend on it.What this task determines for UX:
processAndSavePhotooutputs{artikelId}/{uuid}.webp— this relative path is what gets stored inartikel_fotos.filename. The gallery (GalerieKarte) and admin (FotoStreifen) will construct<img src="/uploads/{filename}">from this. The path format is stable and correct.Cache-Control: public, max-age=31536000, immutableheader is excellent for gallery performance: photos won't re-download on navigation. Since filenames are UUID-based, cache busting is automatic on re-upload.One UX-adjacent concern:
The route returns
404for both path-traversal attempts and genuinely missing files (in the current plan). When<img src="/uploads/...">fails, the browser shows a broken image. There is no alt text contract established here. Whoever implementsGalerieKartemust ensure every<img>has a meaningfulaltattribute — e.g.,alt={artikel.titel ?? artikel.kategorie}. This task doesn't implement the<img>tag, but the broken-image fallback behaviour should be addressed before the gallery task.No accessibility concerns in this task itself — it's a server module with no rendered output.
Recommendations
photos.tsJSDoc that the returnedfilenameformat ({artikelId}/{uuid}.webp) is the storage contract used byGalerieKarteandFotoStreifen— so future refactoring of the path format knows to update the gallery components too.<img src="/uploads/{filename}">carriesalt={artikel.titel ?? artikel.kategorie}— broken images need a text fallback for screen readers and slow connections.🚀 Tobias Wendt — DevOps & Platform Engineer
Observations
UPLOAD_DIRenv var: The system design anddocker-compose.ymlsetUPLOAD_DIR: /app/uploads, backed by the named volumeuploads: → /app/uploads. This task'sphotos.tsreadsprocess.env.UPLOAD_DIR ?? 'uploads'. The fallback'uploads'is a relative path that resolves to the project working directory — in the production container that is/app/uploadsonly if Node is started from/app, which the Dockerfile does (WORKDIR /app). So the fallback accidentally works in Docker, but it is still wrong: a missingUPLOAD_DIRin production should fail loudly, not silently write to a relative path inside the build output.uploadsnamed volume survivesdocker compose up --build— any photos processed by this library will persist across container rebuilds. This is correct and already designed into thedocker-compose.yml./health. The uploads route is not/health, so photo streaming failures won't be caught by the health check — this is acceptable; the health check tests liveness, not functionality.sharpis a runtime dependency that needs thelibvipsnative library — in thenode:22-alpineDocker image, sharp ships its own prebuilt binaries forlinux/x64andlinux/arm64. No extraapk addneeded.UPLOAD_DIRin production without leaking filesystem layout.Recommendations
UPLOAD_DIRto the startup env-var guard inhooks.server.ts(or whereverSESSION_SECRETis checked):?? 'uploads'fallback fromphotos.tsonce the startup check is in place.UPLOAD_DIRto.env.example:node_modulesfrom the builder stage —sharp's native binaries must come fromnpm ci --omit=devin the runner stage (they are platform-specific). The plan's Dockerfile already does this correctly; just confirm it when implementing.📋 Elicit — Requirements Engineer
Observations
Issue #4 is well-structured and implementation-ready. Reviewing against the Definition of Ready:
AC completeness check:
Going through each acceptance criterion:
processAndSavePhoto(buffer, artikelId)— ✅ fully specified (dimensions, format, quality, path pattern, return value)deletePhoto(filename)— ✅ idempotency condition explicitdeleteArtikelPhotos(artikelId)— ✅ idempotency condition explicitresolveUploadPath(filename)— ✅ return contract specifiedGET /uploads/[...path]— ✅ Content-Type and Cache-Control headers namedit()blocks covering 5 behaviors)One requirements gap:
The AC says path traversal "is blocked" but does not specify what HTTP status is returned. The plan uses
error(404), while project security conventions useerror(400, 'Invalid path'). This is an unresolved inconsistency between the issue AC and the implementation plan. It should be decided before implementation and the AC updated to reflect it.NFR-PERF-002 traceability:
The issue correctly references NFR-PERF-002 (≤~800 KB). The spec notes "sharp quality 80 + 1200px limit achieves this for typical photos" — this is a probabilistic claim, not a hard guarantee. Very high-resolution photos with complex content can exceed 800 KB even at quality 80 and 1200px. The AC says "must be ≤ ~800 KB" with the tilde indicating an approximation. This is acceptable for a family app; document that edge cases (extremely dense photos) may exceed the target.
Recommendations
feature,backend,size:S) for backlog health.400(malformed request) or404(not found, less information-disclosing) and state it explicitly.processAndSavePhotosaves a WebP file underUPLOAD_DIR/{artikelId}/processAndSavePhotoreturns a filename matching{artikelId}/{uuid}.webpdeletePhotoremoves the filedeletePhotodoes not throw if file is already gonedeleteArtikelPhotosremoves the entire{artikelId}/directorydeleteArtikelPhotosdoes not throw if directory does not existresolveUploadPathreturns the absolute path (this makes 7 — worth counting)🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
Security
400vs404— The plan useserror(404)for a traversal attempt, but project security conventions (persona files) useerror(400, 'Invalid path').404leaks less information (attacker can't confirm the guard exists);400is semantically honest (the request is malformed). Both are defensible. Pick one and update the AC to name it explicitly. (Raised by: Nora, Elicit)Infrastructure / Reliability
UPLOAD_DIRenv-var fallback: remove it or keep it? —photos.tscurrently hasprocess.env.UPLOAD_DIR ?? 'uploads'as a silent fallback. In production Docker this accidentally resolves correctly, but a missing env var should fail loudly. Decision: addUPLOAD_DIRto the startup guard (alongsideSESSION_SECRET) and remove the fallback, OR keep the fallback with aconsole.warn. Tobias and Nora both flag this — the startup-guard approach is recommended. (Raised by: Tobias, Nora)