Skip to content

feat(photos): firearm photo management with reusable upload/storage (#9) - #62

Merged
unclesp1d3r merged 18 commits into
mainfrom
9-add-firearm-photo-management-with-reusable-uploadstorage-support
Jul 11, 2026
Merged

unclesp1d3r merged 18 commits into
mainfrom
9-add-firearm-photo-management-with-reusable-uploadstorage-support

Conversation

@unclesp1d3r

@unclesp1d3r unclesp1d3r commented Jul 10, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds firearm photo management (#9) on a reusable upload/media foundation: multi-photo upload, a thumbnail gallery, reorder, captions, delete, and a designated primary image shown on the firearm detail view and as a list/search thumbnail. Photos are a firearm child-record family (owner/grants inherited through the parent firearm), processed synchronously on upload, and served only through authenticated, authorization-checked endpoints.

Scope 51 files · +9,189 / −46
Review surface ~2,160 lines of production code across ~25 files. The rest is generated (Drizzle meta snapshots ~5,200, bun.lock) or tests (~1,400) — safe to skim.
Risk 🟡 Medium-low — new feature touching auth, uploads, filesystem, and sharp, but isolated behind a child-record seam and reviewed across three passes (two multi-agent reviews + bot feedback), all applied.
Est. review time ~45–60 min for the code surface
CI ✅ ci, e2e, CodeQL, Analyze, CodeRabbit, DCO all green

Planned via /ce-brainstorm → /ce-plan (plan: docs/plans/2026-07-09-001-feat-firearm-photo-management-plan.md) and implemented across 8 units.

Suggested review order

The feature reads cleanly bottom-up — each layer only depends on the ones above it:

  1. Storage seam — src/storage/service.ts (interface) → local-fs-adapter.ts (traversal guard, 0700/0600 modes) → keys.ts / index.ts (lazy singleton).
  2. Schema — src/db/inventory-schema.ts (firearmPhoto) + migrations 0014–0016 (table, single-primary partial unique index, CHECK backstops). (Skip the meta/*.json snapshots — generated.)
  3. Domain — src/domain/firearm-photos/: constants.ts → validate.ts → pipeline.ts (sharp) → service.ts (the core; authz-through-parent, primary invariant, quota lock).
  4. Delete seam — src/auth/authorize.ts (onBeforeDelete hook) + src/domain/firearms/service.ts (post-commit blob cleanup).
  5. HTTP / UI — app/(app)/firearms/photo-actions.ts (server actions) → app/api/photos/[id]/[variant]/route.ts (serving) → firearm-photos.tsx gallery + list thumbnails.

What's included

Unit Change
U1 Reusable StorageService interface + local-filesystem adapter (traversal-safe server-generated keys, derivative-key convention, owner-only modes), UPLOAD_DIR config, uploads volume in docker-compose.yml
U2 firearm_photo child table + migrations (0014 table, 0015 single-primary partial unique index, 0016 mime/positivity CHECKs)
U3 Upload pipeline with sharp: MIME/size/batch/pixel validation, EXIF/XMP/IPTC location-metadata stripping (via re-encode), thumbnail/preview derivatives, magic-byte mediaType gating (accepts AVIF, rejects SVG-SSRF)
U4 Photo domain service: authz-through-parent-firearm, multi-file per-file results, primary uniqueness + auto-promotion, delete, reorder, caption, row-locked per-firearm quota, batched primary-thumbnail lookup
U5 Firearm-delete blob cleanup via an opt-in pre-delete hook on authorizeAndDeleteParent (magazine/ammo delete unaffected) + orphan sweep
U6 Server actions (rate-limited upload) + authenticated /api/photos/[id]/[variant] route with Content-Type pinned from the stored type + nosniff + immutable cache
U7 Detail-view gallery: multi-upload with loading + per-file failure reporting, set-primary, keyboard-operable reorder (WCAG 2.2 AA), caption edit/remove, delete, empty state
U8 Primary thumbnail in the firearm list/table + search via a single batched lookup, with a fixed-footprint placeholder

Change map

  • 🔧 Source (25) — src/storage/*, src/domain/firearm-photos/*, serving route, server actions, gallery + thumbnail UI, authorize.ts hook, firearms/service.ts, schema.
  • ✅ Tests (13) — unit (pipeline/validate/service/serving/storage-adapter), firearm-delete integration, 2 Playwright e2e specs + fixtures.
  • ⚙️ Config/build (4) — next.config.ts (Server Action body limit), Dockerfile (uploads dir ownership), docker-compose.yml (uploads volume), .env.example.
  • 🗄️ Migrations (7) — 3 SQL + 3 generated snapshots + journal.
  • 📝 Docs (1) — implementation plan.

Key decisions

  • Local-filesystem storage behind the reusable interface (not a bundled object store) — matches the minimal self-host stack (no new service, one UPLOAD_DIR volume, no new test container). An S3-compatible adapter is a later drop-in behind the same interface.
  • Photos are a firearm child family — reuse the Range Session / Inventory Log seam (no own owner_id/grants; authz resolves through the parent firearm).
  • Synchronous sharp processing — re-encoding strips EXIF/XMP/IPTC by default; limitInputPixels caps decompression bombs; per-request file cap bounds request time.
  • Authenticated serving only — no public object URLs; the route re-resolves the session, authorizes against the parent firearm, pins Content-Type + nosniff, and hides existence (404, never 403).

Security posture

  • Server-generated, path-traversal-safe storage keys (user filenames never touch the path); uploads stored 0600, dirs 0700.
  • MIME allow-list and magic-byte mediaType gating before rasterization — closes an SVG-via-image/png SSRF vector against the internal network while correctly accepting AVIF.
  • All reads/deletes/writes authorization-checked through the parent firearm; existence-hiding preserved on the serving route.
  • EXIF/XMP/IPTC location metadata stripped before persistence; user-controlled mime kept out of log format strings.
  • Upload endpoint rate-limited; per-firearm quota enforced under a row lock; reorder length-capped.

Review checklist

  • Authz — every photo read/write/delete resolves through the parent firearm; serving route hides existence (404).
  • Upload safety — SSRF (SVG/format-confusion) rejected pre-rasterize; pixel-bomb + size + batch caps enforced; EXIF/GPS stripped.
  • Invariants — single-primary is DB-enforced (partial unique index); quota race closed by the firearm row lock; blob deletion is post-commit.
  • Migrations — 0014–0016 are additive on a new table (no backfill/legacy-row concern).
  • Config — Server Action body limit sized for real photos; Docker uploads volume is writable by the runtime user.
  • Tests — happy + negative authz + edge (processingFailed, AVIF, SSRF, path-traversal, primary auto-promotion) covered; e2e green.
  • No leaks — no secrets/log leakage; no debug statements.

Test plan

just ci-check passes end to end: Biome lint + format, tsc, pre-commit hooks, the full bun test unit/integration suite (photo storage/pipeline/service/serving + firearm-delete regression + AVIF regression + negative authz), and the Playwright e2e suite (e2e/firearm-photos.spec.ts, e2e/firearm-list-thumbnails.spec.ts) covering upload, mixed-validity batches, set-primary, keyboard reorder, delete + auto-promotion, empty state, list thumbnails, and serving-route headers.

Second-pass review — fixes applied

A follow-up multi-agent review + bot feedback caught issues the first pass and green CI missed (the tests never exercise a real deployment or a file over ~278 bytes). All fixed on this branch:

  • AVIF uploads were rejected — sharp reports AVIF's container format as "heif"; the guard now uses the magic-byte mediaType, with an AVIF regression test.
  • Server Action 1 MB body cap — real photos (2–8 MB) were rejected before the upload action ran; next.config now sets serverActions.bodySizeLimit.
  • Docker upload-volume perms — the image creates /data/uploads owned by bun so the named volume mounts writable (was EACCES).
  • Firearm-delete blob ordering — blobs deleted post-commit (mirrors deletePhoto).
  • Quota race — parent firearm row locked .for("update").
  • Hardening — DB CHECKs (mime + positivity), single-source failure-code/variant unions, 0600/0700 modes, best-effort orphan sweep, zero-copy serving response, listPhotos 404 guard, e2e temp-dir cleanup.

Residual follow-ups — resolved

The three previously-deferred items are now fixed on this branch (commit 8505d1b):

  • createPhotos per-file isolation + processing out of the transaction — sharp decode/encode and blob writes now run per file outside any transaction, so a validation, processing, or storage failure on one file becomes that file's result without aborting the batch or holding a pool connection during CPU/IO. Only successful rows insert in a short transaction that re-authorizes, locks the firearm row, and re-checks the quota; on rollback the batch's blobs are deleted directly (self-cleaning).
  • orphanSweep in-flight-upload guard — an mtime grace period (ORPHAN_MIN_AGE_MS, 1 h; injectable) so a blob written just before its row commits is never reclaimed; age clamped ≥ 0 against sub-ms mtime skew. Reports skippedRecentCount, with a test proving a recent unreferenced blob is spared.

No known residuals remain.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…#9)

Backend-agnostic StorageService interface (save/read/delete/generateKey) with a
local-filesystem adapter behind a mounted uploads volume; path-traversal-safe
server-generated keys and a derivative-key convention. U1.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Adds the firearm_photo child table (firearm-scoped, no own owner/grants),
migration 0014, and a makeFirearmPhoto test factory. U2.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Validate MIME/size/batch, strip EXIF/XMP/IPTC location metadata by re-encoding,
enforce a decoded-pixel cap, and generate thumb/preview derivatives. Adds sharp
as a direct dependency and unblocks its install script. U3.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Owner/grant-scoped CRUD resolved through the parent firearm (child-record
pattern): multi-file create with per-file results, primary uniqueness, delete
with blob cleanup + primary auto-promotion, reorder, caption, quota, and a
batched primary-thumbnail lookup. U4.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Add an opt-in pre-delete hook to the shared authorizeAndDeleteParent helper,
wired only for firearm delete, that best-effort removes photo blobs; magazine
and ammo delete are unaffected. orphanSweep reclaims unreferenced blobs, scanning
the storage singleton's actual root. U5.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…ute (#9)

Server Actions for upload (rate-limited), delete, set-primary, reorder, caption;
an authenticated Route Handler streams photo variants with authz via the parent
firearm and a pinned Content-Type + nosniff. U6.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Upload (multi-file, per-file result, loading state), thumbnail gallery, set
primary, keyboard-operable reorder, caption edit/remove, delete, and an empty
state. Photos serve via the authenticated /thumb + /preview variants. U7.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Compact primary thumbnail per row via a single batched lookup (no N+1), with a
fixed-footprint placeholder when absent so the dense table keeps its rhythm. U8.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…tchers (#9)

The e2e test server did not set UPLOAD_DIR, so served-app uploads failed; set an
ephemeral per-run dir. Also make the gallery's primary-badge assertions exact so
they don't match the 'Set primary' button's substring.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…ath (#9)

Extract shared deletePhotoBlobs + photoVariantUrl helpers, run the three sharp
re-encodes and orphan-sweep deletes concurrently, and select only the columns the
serving path uses. Behavior-preserving; simplify pass over U1-U8.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Reject non-raster sharp formats before rasterization (an SVG labeled image/png
would otherwise reach librsvg and fetch external refs — SSRF); cap reorderPhotos
length like other bulk ops; and delete a photo's blobs after the delete
transaction commits so a rollback can't strand a live row without its blob.
From code review of U1-U8.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Copilot AI review requested due to automatic review settings July 10, 2026 02:25
@unclesp1d3r unclesp1d3r linked an issue Jul 10, 2026 that may be closed by this pull request
10 tasks
@coderabbitai

coderabbitai Bot commented Jul 10, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds firearm photo storage, processing, database persistence, authorization, authenticated serving, gallery management, list thumbnails, deletion cleanup, and automated verification.

Changes

Firearm photo management

Layer / File(s) Summary
Storage and image processing foundation
src/storage/*, src/domain/firearm-photos/{constants,pipeline,validate}.ts, package.json, docker-compose.yml
Adds local blob storage, derived keys, upload validation, Sharp-based derivatives, orphan sweeping, and persistent upload configuration.
Photo schema and domain lifecycle
src/db/..., src/auth/authorize.ts, src/domain/firearm-photos/service.ts, src/domain/firearms/service.ts
Adds the photo model, authorized lifecycle operations, primary selection, ordering, captions, thumbnail lookup, deletion cleanup, and transactional pre-delete hooks.
Serving and application integration
app/api/photos/..., app/(app)/firearms/..., components/ui/photo-thumbnail.tsx
Adds authenticated variant serving, server actions, detail-gallery controls, and primary thumbnails in the firearms list.
Feature verification and plan
docs/plans/..., e2e/..., src/domain/firearm-photos/__tests__/*
Documents the implementation contract and verifies processing, validation, uploads, captions, ordering, primary changes, deletion promotion, empty states, and list thumbnails.

Sequence Diagram(s)

sequenceDiagram
  participant FirearmPhotos
  participant uploadPhotosAction
  participant createPhotos
  participant PhotoServingRoute
  FirearmPhotos->>uploadPhotosAction: submit photo files
  uploadPhotosAction->>createPhotos: pass file bytes and metadata
  createPhotos-->>uploadPhotosAction: return upload results
  uploadPhotosAction-->>FirearmPhotos: refresh gallery
  FirearmPhotos->>PhotoServingRoute: request photo variant
  PhotoServingRoute-->>FirearmPhotos: return authenticated image bytes
Loading

Possibly related issues

Possibly related PRs

Suggested labels: enhancement, backend, frontend, infrastructure, testing, dependencies, security, priority:medium

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title uses a valid Conventional Commits format and accurately summarizes the firearm photo management feature.
Description check ✅ Passed The description is mostly complete and covers summary, changes, and test plan, though it omits the related issue and AI disclosure headings.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch 9-add-firearm-photo-management-with-reusable-uploadstorage-support

Warning

Review ran into problems

🔥 Problems

These MCP integrations need to be re-authenticated in the Integrations settings: Notion


Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added backend dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation enhancement New feature or request frontend infrastructure priority:medium shared testing labels Jul 10, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements firearm photo management on top of a reusable local-filesystem storage abstraction, including upload + processing (derivatives + metadata stripping), authenticated serving endpoints, firearm-scoped authorization, and UI surfaces for gallery management and list thumbnails.

Changes:

  • Added a backend-agnostic StorageService with a local-filesystem adapter, UPLOAD_DIR config, and docker-compose volume wiring.
  • Introduced firearm_photo persistence (schema + migration) plus a domain service for upload/list/reorder/caption/delete/primary, with a sharp-based processing pipeline.
  • Wired authenticated photo serving and UI integration (detail gallery + list thumbnails) with unit/integration/e2e coverage and documentation.

Reviewed changes

Copilot reviewed 42 out of 45 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/test-support/factories.ts Adds makeFirearmPhoto factory for integration tests.
src/storage/service.ts Defines storage interface contract and key type.
src/storage/photo-blobs.ts Best-effort deletion for original + derivative photo blobs.
src/storage/orphan-sweep.ts Adds on-demand orphaned-blob sweep utility for photo storage.
src/storage/local-fs-adapter.ts Implements local filesystem storage with traversal protection.
src/storage/keys.ts Adds server-generated key + derivative key conventions.
src/storage/index.ts Exposes a lazy-initialized shared storage singleton and helpers.
src/storage/env.ts Validates UPLOAD_DIR configuration at runtime.
src/storage/tests/local-fs-adapter.test.ts Tests adapter behavior (round-trip, delete, traversal, env validation).
src/domain/firearms/service.ts Wires firearm delete to best-effort photo blob cleanup via pre-delete hook.
src/domain/firearms/tests/service.test.ts Adds regression tests for firearm-delete blob cleanup + orphan sweep.
src/domain/firearm-photos/validate.ts Adds pure validation for MIME/size and batch-size caps.
src/domain/firearm-photos/urls.ts Adds client-safe URL builder for authenticated photo variants.
src/domain/firearm-photos/service.ts Implements photo CRUD domain logic, authz-through-parent, and batched thumbnail lookup.
src/domain/firearm-photos/pipeline.ts Adds sharp pipeline for re-encode, metadata stripping, and derivatives.
src/domain/firearm-photos/constants.ts Centralizes allow-list and size/pixel/count/derivative constants.
src/domain/firearm-photos/tests/validate.test.ts Unit tests for upload validation and batch-size enforcement.
src/domain/firearm-photos/tests/serving.test.ts Integration tests for getServablePhoto authz and variant resolution.
src/domain/firearm-photos/tests/service.test.ts Integration tests for create/setPrimary/reorder/delete/quota/batched lookup.
src/domain/firearm-photos/tests/pipeline.test.ts Unit tests for derivative sizing, EXIF stripping, pixel cap, and format confusion rejection.
src/db/migrations/meta/0014_snapshot.json Drizzle snapshot for migration 0014.
src/db/migrations/meta/_journal.json Registers migration 0014 in the journal.
src/db/migrations/0014_perfect_silver_surfer.sql Creates firearm_photo table + FK + index + check.
src/db/inventory-schema.ts Adds firearmPhoto table schema to inventory DB model.
src/auth/authorize.ts Extends authorizeAndDeleteParent with optional in-transaction pre-delete hook.
package.json Adds sharp dependency and allows install scripts to run for it.
bun.lock Locks new sharp dependency graph.
docker-compose.yml Adds uploads volume and UPLOAD_DIR env for the app container.
.env.example Documents UPLOAD_DIR configuration for local/Docker setups.
app/api/photos/[id]/[variant]/route.ts Adds authenticated photo serving route with pinned Content-Type and nosniff.
app/(app)/firearms/photo-actions.ts Adds server actions for upload/mutations with rate limiting + revalidation.
app/(app)/firearms/page.tsx Loads primary thumbnails in a batched lookup for list rendering.
app/(app)/firearms/firearms-view.tsx Adds a photo thumbnail column to the firearms list/table UI.
app/(app)/firearms/firearm-detail-view.tsx Embeds the detail-view photo gallery section.
app/(app)/firearms/[id]/page.tsx Loads ordered photos for the firearm detail page.
app/(app)/firearms/[id]/firearm-photos.tsx Implements client gallery UI: upload, per-file failures, primary, reorder, caption, delete, empty state.
components/ui/photo-thumbnail.tsx Adds reusable list/search thumbnail UI with fixed footprint placeholder.
e2e/start-test-server.ts Sets an ephemeral UPLOAD_DIR for e2e runs.
e2e/fixtures/user-pool.ts Adds e2e users for photo and thumbnail specs.
e2e/fixtures/not-an-image.txt Adds invalid upload fixture for mixed-validity upload coverage.
e2e/firearm-photos.spec.ts E2E coverage for gallery workflows including mixed-validity uploads and keyboard reorder.
e2e/firearm-list-thumbnails.spec.ts E2E coverage for primary thumbnails and placeholder behavior in list view.
docs/plans/2026-07-09-001-feat-firearm-photo-management-plan.md Adds implementation plan and contracts for the feature.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/domain/firearm-photos/service.ts Outdated
Comment thread src/storage/orphan-sweep.ts Outdated
Comment thread app/api/photos/[id]/[variant]/route.ts Outdated
Comment thread src/domain/firearm-photos/service.ts
Comment thread src/test-support/factories.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/domain/firearm-photos/__tests__/pipeline.test.ts (1)

1-142: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

No happy-path coverage for PNG/WebP/AVIF — only JPEG is exercised.

Every fixture (makeJpegFixture, makeJpegFixtureWithGpsExif) and the format-confusion test use image/jpeg/JPEG bytes. Since ALLOWED_MIME_TYPES includes png, webp, and avif, and given the AVIF format-detection mismatch flagged in constants.ts (sharp reports AVIF as format: "heif", not "avif"), a test asserting processImage succeeds for a real AVIF fixture would have caught that regression before merge.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/firearm-photos/__tests__/pipeline.test.ts` around lines 1 - 142,
Add happy-path coverage in the processImage tests for real PNG, WebP, and AVIF
fixtures, invoking processImage with each corresponding MIME type and asserting
successful original, thumbnail, preview, and dimension outputs. Include an AVIF
fixture and verify format detection accepts sharp’s reported “heif” format,
using the existing fixture helpers or adding format-specific helpers as needed.
🧹 Nitpick comments (3)
src/storage/keys.ts (1)

8-10: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Validate ext before embedding it in a storage key.

Values such as ../../etc/passwd or foo/bar produce path-bearing keys. The current local adapter rejects some escaped paths, but this safety depends on every backend and caller. Use an allowlisted extension/MIME type or reject separators, .., and empty values; verify callers never derive it from an upload filename.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/storage/keys.ts` around lines 8 - 10, Validate the ext argument in
generateKey before constructing the StorageKey: reject empty values, path
separators, and traversal segments such as "..", or enforce an allowlisted
extension/MIME mapping. Ensure callers pass a validated type rather than
deriving it directly from upload filenames, and add coverage for invalid
path-bearing inputs.
src/domain/firearm-photos/pipeline.ts (1)

39-113: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Each upload is decoded 4 separate times (metadata() + 3× reencode).

reencode builds a fresh sharp(bytes, ...) per output instead of decoding once and using .clone(). Per sharp's own docs, .clone() is designed exactly for this: "Cloned instances inherit the input of their parent instance. This allows multiple output Streams and therefore multiple processing pipelines to share a single input" — libvips decodes once and shares the decoded image across clones, whereas separate sharp() calls each pay the full decode cost independently.

The doc comment on reencode (lines 91-96) frames the fresh-instance choice as necessary for "independent and side-effect free" pipelines, but .clone() gives that same independence (each clone can be .resize()'d/.toFormat()'d differently) without the redundant decode — it doesn't introduce shared mutable state across the derivative outputs. Since this runs synchronously on the request thread for up to MAX_FILES_PER_REQUEST (10) files per upload, cutting 4 decodes down to 1 is a meaningful reduction in CPU/latency on this hot path.

♻️ Proposed fix — decode once, clone for each derivative
 export async function processImage(
   bytes: Uint8Array | Buffer,
   mimeType: string,
 ): Promise<ProcessedImage> {
-  const metadata = await sharp(bytes, {
-    limitInputPixels: MAX_INPUT_PIXELS,
-  }).metadata();
+  const pipeline = sharp(bytes, { limitInputPixels: MAX_INPUT_PIXELS });
+  const metadata = await pipeline.metadata();

   ...

   const [original, thumb, preview] = await Promise.all([
-    reencode(bytes, mimeType).toBuffer(),
-    reencode(bytes, mimeType)
+    reencode(pipeline, mimeType).toBuffer(),
+    reencode(pipeline, mimeType)
       .resize({ width: THUMB_MAX_EDGE, height: THUMB_MAX_EDGE, fit: "inside", withoutEnlargement: true })
       .toBuffer(),
-    reencode(bytes, mimeType)
+    reencode(pipeline, mimeType)
       .resize({ width: PREVIEW_MAX_EDGE, height: PREVIEW_MAX_EDGE, fit: "inside", withoutEnlargement: true })
       .toBuffer(),
   ]);
   ...
 }

-function reencode(bytes: Uint8Array | Buffer, mimeType: string): Sharp {
-  const pipeline = sharp(bytes, { limitInputPixels: MAX_INPUT_PIXELS });
+function reencode(pipeline: Sharp, mimeType: string): Sharp {
+  const cloned = pipeline.clone();
   switch (mimeType) {
     case "image/jpeg":
-      return pipeline.jpeg();
+      return cloned.jpeg();
     case "image/png":
-      return pipeline.png();
+      return cloned.png();
     case "image/webp":
-      return pipeline.webp();
+      return cloned.webp();
     case "image/avif":
-      return pipeline.avif();
+      return cloned.avif();
     default:
       throw new Error(`firearm-photos/pipeline: unsupported mime type "${mimeType}"`);
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/firearm-photos/pipeline.ts` around lines 39 - 113, Update
processImage to create one base sharp pipeline, trigger a single decode, and
derive original, thumb, and preview outputs from independent clones of that
pipeline. Replace the current three reencode calls and remove or revise reencode
so format-specific encoding remains applied per clone while resize settings stay
independent; preserve the existing pixel limit, output formats, and returned
dimensions.
app/(app)/firearms/[id]/firearm-photos.tsx (1)

365-372: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Static analysis flags <img> over next/image.

Biome's noImgElement warning applies here, but swapping to next/image for these authenticated, dynamically-sized photo variants would need a custom loader/remote-pattern config for the /api/photos/[id]/[variant] route — the manual width/height reservation already prevents layout shift (R17). Given the added config complexity versus the marginal gain, this is optional rather than a must-fix.

Also applies to: 409-416

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(app)/firearms/[id]/firearm-photos.tsx around lines 365 - 372, Static
analysis flags the native img elements in the primary and secondary photo
rendering blocks, but this is an optional warning rather than a required fix.
Leave the existing img usage unchanged unless choosing to migrate; if migrated,
update both occurrences to next/image and provide a loader or remote-pattern
configuration supporting authenticated dynamic photoVariantUrl routes while
preserving the explicit dimensions and layout behavior.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/`(app)/firearms/[id]/page.tsx:
- Around line 38-50: Wrap the Promise.all block containing listPhotos in the
same NotFoundError catch used for getFirearm, and call notFound() when that
error is caught. Ensure errors from listPhotos during the permission race
produce the page’s expected 404 while preserving existing handling for other
errors.

In `@app/`(app)/firearms/photo-actions.ts:
- Around line 47-78: Raise the Server Actions request body limit in
next.config.ts by configuring experimental.serverActions.bodySizeLimit to
accommodate the maximum allowed upload batch, including up to 15 MB per file and
any permitted number of files. Ensure the setting matches the limits enforced by
uploadPhotosAction and related upload validation.

In `@app/api/photos/`[id]/[variant]/route.ts:
- Around line 37-72: Add a small per-user rate limiter to the GET handler,
placing it after getCurrentUser() confirms authentication and before UUID
validation or getServablePhoto(). Reuse the existing limiter utility and
conventions from the mutation routes, keying the limit by user.id and returning
the established throttling response (including any Retry-After metadata) when
exceeded.

In `@docs/plans/2026-07-09-001-feat-firearm-photo-management-plan.md`:
- Around line 239-241: Update the firearm-photo management plan’s test scenarios
and verification sections to require Testcontainers for integration tests
instead of a caller-provided DATABASE_URL or skipping tests when it is absent.
Ensure the planned tests provision and clean up an isolated database container
for each test suite or run, including the referenced verification guidance.
- Around line 38-49: Update requirement R1 and the storage interface contract to
expose the operations required by orphan sweeping, including existence checks
and listing stored objects/keys (with any needed metadata). Ensure U5 and
related requirements explicitly use these abstraction methods rather than
bypassing the storage service, and keep the contract backend-independent for
filesystem and future S3 adapters.
- Around line 48-49: Update the deletion flow described by R8 and R19 so firearm
photo blob deletion occurs only after the database transaction commits, never
before row deletion. Use a post-commit cleanup hook, or a durable
pending-delete/outbox mechanism for retryable asynchronous cleanup, and ensure
failures remain recoverable without deleting blobs when the transaction rolls
back. Apply the same change to the corresponding sections noted in the review.

In `@e2e/firearm-photos.spec.ts`:
- Around line 20-164: Extend the firearm photo E2E coverage with a second-user
authorization scenario using the existing sharing flow: verify a view-only user
cannot access or delete photos, while an edit-grantee can upload, reorder, and
delete them. Reuse the photo gallery and unique controls from the existing test,
and assert both allowed and denied behaviors for R12/R14 and AE2/AE3.

In `@e2e/start-test-server.ts`:
- Around line 137-142: Retain the path returned by mkdtempSync in e2e server
startup and add teardown logic using rmSync(path, { recursive: true, force: true
}). Invoke this cleanup before every process.exit, including container-start
failure and main().catch paths, using the relevant startup and error-handling
functions.

In `@src/db/inventory-schema.ts`:
- Around line 344-368: Add a partial unique index enforcing at most one primary
photo per firearm, using the firearm photo schema/migration definitions around
firearmPhoto and the isPrimary field. Create the unique index on firearm_id with
a WHERE is_primary predicate, and include the corresponding migration so
existing and future databases enforce this constraint.

In `@src/domain/firearm-photos/constants.ts`:
- Around line 28-51: AVIF files are rejected because sharp reports their format
as “heif” rather than “avif”. Update isAllowedRasterFormat and its callers,
including processImage, to accept AVIF only when metadata identifies HEIF with
AV1 compression or the appropriate media type, while preserving rejection of
unsupported HEIF variants; alternatively remove image/avif from
ALLOWED_MIME_TYPES until AVIF is supported.

In `@src/domain/firearm-photos/service.ts`:
- Around line 118-157: Move image processing and blob writes out of the database
transaction enclosing the batch loop in the firearm photo service. Refactor the
workflow around the batch transaction so each file’s processing and storage
saves occur before the corresponding database insert, with cleanup for
already-written blobs when insertion or later transaction work fails;
alternatively implement the planned per-file isolation to prevent orphaned
storage and reduce transaction duration. Use the existing processImage,
storage.save, and firearmPhoto insert flow as the refactoring points.
- Around line 95-116: Prevent concurrent createPhotos transactions from
bypassing the photo quota and primary-photo invariant. In createPhotos,
serialize operations per firearm by locking the firearm row with SELECT ... FOR
UPDATE or using a serializable transaction before reading existing photos;
alternatively add and handle a database partial unique constraint for primary
photos together with quota-safe locking. Ensure the protected section covers
existing, the MAX_PHOTOS_PER_FIREARM check, nextSortOrder, hasPrimary, and all
inserts.
- Around line 270-294: Update reorderPhotos to validate that orderedPhotoIds
exactly matches the firearm’s current photo-id set before performing updates:
reject duplicates, missing existing IDs, and unknown IDs with the appropriate
validation failure codes. Load the current IDs within the transaction, compare
sets and lengths, surface all applicable validation errors, and only then
execute the sortOrder updates.

In `@src/domain/firearms/__tests__/service.test.ts`:
- Around line 1-11: Make storage tests use a single shared upload root instead
of assigning separate module-scoped temporary directories in each test file.
Update the setup in the firearm photo service/serving tests and the firearms
service test, and ensure the lazy singleton accessed through activeStorageRoot()
and orphan-sweep assertions consistently uses that shared root throughout the
test run.

In `@src/domain/firearms/service.ts`:
- Around line 101-138: Refactor cleanupFirearmPhotoBlobs to only collect and
return the firearm photo storage keys during the transaction, without calling
deletePhotoBlobs. Update deleteFirearm to await authorizeAndDeleteParent, then
delete the collected blobs after it resolves, preserving best-effort failure
handling and ensuring filesystem I/O occurs post-commit.

In `@src/storage/local-fs-adapter.ts`:
- Around line 45-48: Update LocalFsAdapter.save to create upload directories
with mode 0o700 and files with mode 0o600, rather than relying on the process
umask. Also ensure the existing upload root is tightened to 0o700 when
applicable, including handling pre-existing paths safely.

In `@src/storage/orphan-sweep.ts`:
- Around line 38-40: Update the directory read in the orphan-sweep function to
treat only an ENOENT failure as an empty upload directory; rethrow or otherwise
surface all other errors such as permission and I/O failures. Preserve the
existing successful empty-result behavior for a missing directory while ensuring
callers receive non-ENOENT failures.

In `@src/storage/photo-blobs.ts`:
- Around line 10-17: Move the blob deletion performed by
cleanupFirearmPhotoBlobs out of the firearm-delete transaction: collect and
return the affected storage keys while the transaction runs, then invoke the
deletion helper only after the transaction commits; update related documentation
and error handling to reflect the new timing.

---

Outside diff comments:
In `@src/domain/firearm-photos/__tests__/pipeline.test.ts`:
- Around line 1-142: Add happy-path coverage in the processImage tests for real
PNG, WebP, and AVIF fixtures, invoking processImage with each corresponding MIME
type and asserting successful original, thumbnail, preview, and dimension
outputs. Include an AVIF fixture and verify format detection accepts sharp’s
reported “heif” format, using the existing fixture helpers or adding
format-specific helpers as needed.

---

Nitpick comments:
In `@app/`(app)/firearms/[id]/firearm-photos.tsx:
- Around line 365-372: Static analysis flags the native img elements in the
primary and secondary photo rendering blocks, but this is an optional warning
rather than a required fix. Leave the existing img usage unchanged unless
choosing to migrate; if migrated, update both occurrences to next/image and
provide a loader or remote-pattern configuration supporting authenticated
dynamic photoVariantUrl routes while preserving the explicit dimensions and
layout behavior.

In `@src/domain/firearm-photos/pipeline.ts`:
- Around line 39-113: Update processImage to create one base sharp pipeline,
trigger a single decode, and derive original, thumb, and preview outputs from
independent clones of that pipeline. Replace the current three reencode calls
and remove or revise reencode so format-specific encoding remains applied per
clone while resize settings stay independent; preserve the existing pixel limit,
output formats, and returned dimensions.

In `@src/storage/keys.ts`:
- Around line 8-10: Validate the ext argument in generateKey before constructing
the StorageKey: reject empty values, path separators, and traversal segments
such as "..", or enforce an allowlisted extension/MIME mapping. Ensure callers
pass a validated type rather than deriving it directly from upload filenames,
and add coverage for invalid path-bearing inputs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 27cef71d-3164-44d3-9e91-b29e8b60ec6c

📥 Commits

Reviewing files that changed from the base of the PR and between 743df0c and 2c49074.

⛔ Files ignored due to path filters (4)
  • .env.example is excluded by !.env*
  • bun.lock is excluded by !**/*.lock, !bun.lock
  • e2e/fixtures/sample-photo-1.jpg is excluded by !**/*.jpg
  • e2e/fixtures/sample-photo-2.jpg is excluded by !**/*.jpg
📒 Files selected for processing (41)
  • app/(app)/firearms/[id]/firearm-photos.tsx
  • app/(app)/firearms/[id]/page.tsx
  • app/(app)/firearms/firearm-detail-view.tsx
  • app/(app)/firearms/firearms-view.tsx
  • app/(app)/firearms/page.tsx
  • app/(app)/firearms/photo-actions.ts
  • app/api/photos/[id]/[variant]/route.ts
  • components/ui/photo-thumbnail.tsx
  • docker-compose.yml
  • docs/plans/2026-07-09-001-feat-firearm-photo-management-plan.md
  • e2e/firearm-list-thumbnails.spec.ts
  • e2e/firearm-photos.spec.ts
  • e2e/fixtures/not-an-image.txt
  • e2e/fixtures/user-pool.ts
  • e2e/start-test-server.ts
  • package.json
  • src/auth/authorize.ts
  • src/db/inventory-schema.ts
  • src/db/migrations/0014_perfect_silver_surfer.sql
  • src/db/migrations/meta/0014_snapshot.json
  • src/db/migrations/meta/_journal.json
  • src/domain/firearm-photos/__tests__/pipeline.test.ts
  • src/domain/firearm-photos/__tests__/service.test.ts
  • src/domain/firearm-photos/__tests__/serving.test.ts
  • src/domain/firearm-photos/__tests__/validate.test.ts
  • src/domain/firearm-photos/constants.ts
  • src/domain/firearm-photos/pipeline.ts
  • src/domain/firearm-photos/service.ts
  • src/domain/firearm-photos/urls.ts
  • src/domain/firearm-photos/validate.ts
  • src/domain/firearms/__tests__/service.test.ts
  • src/domain/firearms/service.ts
  • src/storage/__tests__/local-fs-adapter.test.ts
  • src/storage/env.ts
  • src/storage/index.ts
  • src/storage/keys.ts
  • src/storage/local-fs-adapter.ts
  • src/storage/orphan-sweep.ts
  • src/storage/photo-blobs.ts
  • src/storage/service.ts
  • src/test-support/factories.ts

Comment thread app/(app)/firearms/[id]/page.tsx
Comment thread app/(app)/firearms/photo-actions.ts
Comment thread app/api/photos/[id]/[variant]/route.ts
Comment thread docs/plans/2026-07-09-001-feat-firearm-photo-management-plan.md
Comment thread docs/plans/2026-07-09-001-feat-firearm-photo-management-plan.md
Comment thread src/domain/firearms/__tests__/service.test.ts
Comment thread src/domain/firearms/service.ts Outdated
Comment thread src/storage/local-fs-adapter.ts Outdated
Comment thread src/storage/orphan-sweep.ts
Comment thread src/storage/photo-blobs.ts Outdated
…ing (#9)

From the comprehensive PR review of #62:
- partial unique index (migration 0015) enforcing one primary photo per firearm
- negative authz tests for delete/setPrimary/reorder/setCaption mutations
- e2e assertions for the serving route's Content-Type + nosniff + unauth status
- log (not swallow) unexpected processImage and orphan-sweep readdir failures
- thread AllowedMimeType through the pipeline; positive-invariant comment

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Comment thread src/domain/firearm-photos/service.ts Fixed
@coderabbitai coderabbitai Bot removed documentation Improvements or additions to documentation priority:medium labels Jul 10, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/domain/firearm-photos/service.ts`:
- Around line 144-148: Update the error logging in the processImage failure
catch block to use a constant format string instead of interpolating firearmId
or input.mimeType. Pass those request-derived values as separate structured
metadata arguments to console.error, preserving the existing context and error
object.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 03003576-b737-4294-a0fe-7bf993973ec3

📥 Commits

Reviewing files that changed from the base of the PR and between 2c49074 and 2cd9ec1.

📒 Files selected for processing (10)
  • e2e/firearm-list-thumbnails.spec.ts
  • src/db/inventory-schema.ts
  • src/db/migrations/0015_sleepy_northstar.sql
  • src/db/migrations/meta/0015_snapshot.json
  • src/db/migrations/meta/_journal.json
  • src/domain/firearm-photos/__tests__/service.test.ts
  • src/domain/firearm-photos/pipeline.ts
  • src/domain/firearm-photos/service.ts
  • src/storage/orphan-sweep.ts
  • src/storage/photo-blobs.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/db/inventory-schema.ts
  • src/storage/photo-blobs.ts
  • e2e/firearm-list-thumbnails.spec.ts
  • src/db/migrations/meta/_journal.json
  • src/domain/firearm-photos/pipeline.ts
  • src/storage/orphan-sweep.ts
  • src/domain/firearm-photos/tests/service.test.ts

Comment thread src/domain/firearm-photos/service.ts Outdated
CodeQL js/tainted-format-string: the processImage-failure log interpolated the
user-supplied mimeType into console.error's format-string position. Move all
dynamic values to a structured argument with a static message.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Second-pass review surfaced issues green CI missed because tests never
exercise a real deployment or a file over ~278 bytes:

- P0: raise Server Action body cap (next.config) — Next defaults to 1MB,
  which rejected every real photo (2-8MB) before the upload action ran.
  Sized 160mb for a full advertised batch (10 x 15MB) + multipart overhead.
- P0: create /data/uploads owned by uid `bun` in the image so the named
  uploads volume mounts writable — first upload otherwise failed EACCES on
  the documented docker compose path (USER bun, root-owned fresh volume).
- P1: delete firearm photo blobs AFTER the delete transaction commits,
  mirroring deletePhoto — pre-commit deletion could strip bytes a
  rolled-back (still-live) row points at, unreclaimable by orphanSweep.
- P1: lock the parent firearm row (.for("update")) in createPhotos so the
  per-firearm quota read + inserts are atomic against a concurrent upload,
  matching updateMagazine — closes the R20 disk-exhaustion race.

Also corrects the photo-blobs doc comment that claimed the firearm-delete
path used safe pre-commit timing.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
@coderabbitai coderabbitai Bot added the documentation Improvements or additions to documentation label Jul 11, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
next.config.ts (1)

6-16: 🧹 Nitpick | 🔵 Trivial

bodySizeLimit is global to every Server Action, not just uploads.

Raising this to 160mb applies to all Server Actions in the app, so any authenticated action now accepts bodies up to 160MB. uploadPhotosAction is protected by uploadLimiter, but other actions gain the larger body ceiling without that per-action bound. The config key/placement is correct for Next 16 (experimental.serverActions.bodySizeLimit, string sizes accepted), so this is an operational hardening note rather than a defect: consider keeping heavy uploads on a dedicated route/action and relying on the per-action rate limiter as the effective backstop.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@next.config.ts` around lines 6 - 16, Keep the Next.js
serverActions.bodySizeLimit configuration valid, but avoid relying on its global
160MB ceiling for every action: move heavy photo uploads to a dedicated route or
action where practical, and ensure uploadPhotosAction continues enforcing
uploadLimiter and the existing file/count limits. Review other Server Actions
for equivalent request-size validation or explicit rejection so they do not
implicitly accept oversized bodies.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@next.config.ts`:
- Around line 6-16: Keep the Next.js serverActions.bodySizeLimit configuration
valid, but avoid relying on its global 160MB ceiling for every action: move
heavy photo uploads to a dedicated route or action where practical, and ensure
uploadPhotosAction continues enforcing uploadLimiter and the existing file/count
limits. Review other Server Actions for equivalent request-size validation or
explicit rejection so they do not implicitly accept oversized bodies.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: f0cefc63-40b4-45fa-9290-0d51053667d7

📥 Commits

Reviewing files that changed from the base of the PR and between e6d9c38 and cb1d076.

📒 Files selected for processing (5)
  • Dockerfile
  • next.config.ts
  • src/domain/firearm-photos/service.ts
  • src/domain/firearms/service.ts
  • src/storage/photo-blobs.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/storage/photo-blobs.ts
  • src/domain/firearm-photos/service.ts

Medium-severity hardening from the second-pass review:

- Add DB CHECK backstops on firearm_photo (migration 0016): mime_type in
  the controlled allow-list, and size_bytes/width/height > 0 — a direct
  insert bypassing the app layer can no longer write a bad MIME (echoed as
  Content-Type) or a degenerate dimension. Mirrors grant_permission_valid.
- Export CreatePhotoErrorCode / PhotoServiceErrorCode as the single source
  of truth for the photo failure vocabulary; the throw sites now type-check
  against it (satisfies) and the upload UI imports it instead of re-deriving
  the set by hand (which was already missing a code).
- Make PHOTO_VARIANTS (urls.ts) the one servable-variant list; the serving
  route and the server-side PhotoVariant derive from it instead of each
  retyping the literal set.
- Drop the redundant, unvalidated CreatePhotoInput.sizeBytes — createPhotos
  derives it from the actual buffer length (the two can no longer drift).
- Replace reencode's runtime-default switch with a compiler-exhaustive
  Record<AllowedMimeType, ...> so a new format fails to typecheck until
  wired, rather than throwing at runtime.

Tests: add the previously-missing negative/edge cases — createPhotos'
processingFailed catch path (allowed-MIME undecodable bytes; survivors stay
gapless, one primary), listPhotos existence-hiding for a stranger, and
reorderPhotos' cross-firearm-id no-op guard.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
@coderabbitai coderabbitai Bot removed shared documentation Improvements or additions to documentation labels Jul 11, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/db/inventory-schema.ts (1)

375-378: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a drift guard for the triplicated MIME allow-list.

The comment correctly flags this SQL literal as a third copy of ALLOWED_MIME_TYPES. Since the serving route echoes these values as Content-Type, silent drift here has a security edge. A one-line assertion test that the DDL literal matches the TS constant makes the drift compile/CI-visible instead of relying on the comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/db/inventory-schema.ts` around lines 375 - 378, Add a one-line assertion
test that extracts the MIME values from the `firearm_photo_mime_type_valid` SQL
check in `inventory-schema.ts` and verifies they exactly match the
`ALLOWED_MIME_TYPES` TypeScript constant, preserving order or comparing
normalized sets consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/db/migrations/0016_bizarre_lyja.sql`:
- Around line 1-4: The migration immediately validates four CHECK constraints
against all existing firearm_photo rows. Update the constraints named
firearm_photo_mime_type_valid, firearm_photo_size_bytes_min,
firearm_photo_width_min, and firearm_photo_height_min to be added as NOT VALID,
add a data-fix step for existing invalid rows, and create a follow-up migration
that runs VALIDATE CONSTRAINT for each constraint.

---

Nitpick comments:
In `@src/db/inventory-schema.ts`:
- Around line 375-378: Add a one-line assertion test that extracts the MIME
values from the `firearm_photo_mime_type_valid` SQL check in
`inventory-schema.ts` and verifies they exactly match the `ALLOWED_MIME_TYPES`
TypeScript constant, preserving order or comparing normalized sets consistently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 5c7eb2c5-5d4e-40af-a78a-f84438fa81a5

📥 Commits

Reviewing files that changed from the base of the PR and between cb1d076 and 27cecbc.

📒 Files selected for processing (12)
  • app/(app)/firearms/[id]/firearm-photos.tsx
  • app/(app)/firearms/photo-actions.ts
  • app/api/photos/[id]/[variant]/route.ts
  • src/db/inventory-schema.ts
  • src/db/migrations/0016_bizarre_lyja.sql
  • src/db/migrations/meta/0016_snapshot.json
  • src/db/migrations/meta/_journal.json
  • src/domain/firearm-photos/__tests__/service.test.ts
  • src/domain/firearm-photos/__tests__/serving.test.ts
  • src/domain/firearm-photos/pipeline.ts
  • src/domain/firearm-photos/service.ts
  • src/domain/firearm-photos/urls.ts
💤 Files with no reviewable changes (1)
  • app/(app)/firearms/photo-actions.ts
✅ Files skipped from review due to trivial changes (1)
  • src/db/migrations/meta/0016_snapshot.json
🚧 Files skipped from review as they are similar to previous changes (8)
  • src/domain/firearm-photos/urls.ts
  • src/db/migrations/meta/_journal.json
  • app/api/photos/[id]/[variant]/route.ts
  • src/domain/firearm-photos/tests/serving.test.ts
  • src/domain/firearm-photos/pipeline.ts
  • src/domain/firearm-photos/tests/service.test.ts
  • app/(app)/firearms/[id]/firearm-photos.tsx
  • src/domain/firearm-photos/service.ts

Comment thread src/db/migrations/0016_bizarre_lyja.sql
- Accept AVIF uploads: sharp reports AVIF's container format as "heif", so
  gating processImage on metadata.format rejected every valid AVIF. Gate on
  the magic-byte mediaType (image/avif) instead — still rejects SVG/format
  confusion. Adds an AVIF end-to-end pipeline test.
- Store upload dirs 0700 / blobs 0600 instead of inheriting the umask, so
  private photos aren't world-readable on a self-hosted UPLOAD_DIR.
- orphanSweep is now best-effort per key: one delete failure no longer
  aborts the whole reclaim run.
- Photo-serving route returns a zero-copy Uint8Array view instead of copying
  every image byte per request.
- Firearm detail page wraps listPhotos in the same NotFoundError -> notFound()
  guard as getFirearm (permission-revoke race -> 404, not 500).
- e2e launcher removes its ephemeral UPLOAD_DIR on all exit paths.
- Test hygiene: factory storageKey matches production shape (flat, extension,
  no separator); photo-blob disk assertions read activeStorageRoot() so they
  don't depend on test-file load order.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
@unclesp1d3r unclesp1d3r self-assigned this Jul 11, 2026
Fully resolves the three deferred follow-ups rather than deferring them.

createPhotos — per-file isolation + processing out of the transaction:
- Decode/re-encode and blob writes now run per file OUTSIDE any transaction,
  so a validation, processing, OR storage failure on one file becomes that
  file's result without aborting the batch or holding a pool connection during
  CPU/IO work. A partial blob write is cleaned up in place.
- Only successful rows insert inside a short transaction that re-authorizes,
  locks the firearm row, and re-checks the quota (race-safe). An optimistic
  unlocked quota check still fails an over-limit batch before any processing.
- If that transaction rolls back, the batch's already-written blobs are
  deleted directly — self-cleaning, not reliant on the orphan sweep.

orphanSweep — in-flight-upload guard:
- Adds an mtime grace period (ORPHAN_MIN_AGE_MS, 1h; injectable via
  minAgeMs) so a blob written just before its row commits is never reclaimed.
  Age is clamped >= 0 so sub-ms mtime skew can't skip a blob at minAgeMs: 0.
- Reports skippedRecentCount.

Tests: orphan-sweep reclaim test pins minAgeMs: 0; adds a grace-period test
proving a recently-written unreferenced blob is spared.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
@unclesp1d3r
unclesp1d3r merged commit 879f25d into main Jul 11, 2026
7 checks passed
@unclesp1d3r
unclesp1d3r deleted the 9-add-firearm-photo-management-with-reusable-uploadstorage-support branch July 11, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add firearm photo management with reusable upload/storage support

3 participants