feat/upload-hygiene #91

Merged
longjacksonle merged 11 commits from feat/upload-hygiene into main 2026-05-14 02:31:32 +02:00

Summary

Three deferred items from Phase 13 land as one coherent upload-hygiene phase:

  1. Image thumbnailing. Every accepted image upload now produces a
    360px-max-dimension preview at {sha256}_preview.{ext} adjacent to the original. The
    inline <img> in the message bubble loads ?size=preview; click-through opens the
    original.
  2. EXIF / metadata stripping. Re-encoding through the image crate IS the strip:
    decode drops EXIF / XMP / IPTC / PNG text chunks, and the encoders write none back. JPEG
    re-encodes at q=92; PNG / WebP / GIF are lossless. Animated GIFs keep their animation
    in the stored original via multi-frame re-encode and get a first-frame static preview.
  3. Orphan upload GC. Hourly background sweep removes file_uploads rows where
    message_id IS NULL and created_at < 24 hours ago. Dedup-aware: the on-disk file is
    removed only when no other row references the same storage_path. The sibling-count,
    file delete, and row delete all run inside one BEGIN IMMEDIATE SQLite transaction so a
    concurrent upload cannot race the sweep into deleting a still-referenced file.

New surface

  • GET /api/files/:id?size=preview — content-type derived from the preview file's
    extension, not the DB row, so a future format where preview-mime ≠ original-mime cannot
    silently serve the wrong header.
  • POST /admin/uploads/regenerate-thumbnails — preview-only backfill. Originals are not
    touched (pre-Phase-23 originals are content-addressed by their un-stripped bytes;
    re-encoding would change their sha and break the DB → disk mapping; see Out of scope).
  • POST /admin/uploads/purge-orphans — runs the same run_orphan_sweep as the hourly
    tick with threshold = 0. No parallel "skip the dedup check" path.
  • "Uploads" panel under /admin/settings: on-disk MiB total, orphan row count, the two
    action buttons. One inline help line under Regenerate to prevent the
    operator-clicks-twice failure mode.

Architecture

  • New module: server/src/uploads/ (pipeline, sweep, lift-from-routes helpers).
  • New dependency: image = "0.25" (narrow features: jpeg, png, gif, webp).
    Dev-dep kamadak-exif = "0.5" for "EXIF actually stripped" assertions in tests.
  • THUMBNAIL_CONCURRENCY = 4 semaphore singleton, modeled on PUSH_FANOUT_CONCURRENCY.
    Caps memory pressure across uploads + regen.
  • Atomic writes via .partial sibling + rename so a crash mid-write never leaves a
    half-written content-addressed file.
  • No DB migration. The orphan GC index from 0012_uploads.sql is exactly what the
    sweeper needs.

Operator notes

  • Hourly GC tick, 24-hour orphan threshold (THRESHOLD_HOURS in
    spawn_orphan_sweeper).
  • Single-process invariant for the GC: the BEGIN IMMEDIATE + sibling-count + file
    delete + row delete sequence is documented at the module head as load-bearing under
    concurrency. If anyone moves the sweep to a sidecar, the transaction stays correct
    because of BEGIN IMMEDIATE; document is there to prevent accidental relaxation.
  • EXIF stripping is unconditional, no admin toggle. Re-encode is the strip mechanism, so
    a decode failure on a sniffed-image upload rejects with 400 (re-encode IS the strip; if
    we can't strip, we don't store).

Tests

25 tests across 4 binaries, all passing when run individually:

  • server/tests/uploads_pipeline.rs (6): JPEG-with-EXIF → stripped (verified via
    kamadak-exif), 1200x800 → 360px preview, 3-frame GIF preserves frames in original /
    static in preview, decode failure, unsupported mime, preview_from_path.
  • server/tests/uploads_sweep.rs (6): centerpiece dedup+GC test (A linked + B
    orphan share storage_path → B's row gone, file preserved for A),
    original-present-preview-absent (pins the "preview write failed, original committed"
    recovery path), plus the four standard sweep invariants.
  • server/tests/routes_uploads.rs (+2): ?size=preview returns smaller decoded bytes;
    dedup upload heals a missing preview end-to-end through HTTP.
  • server/tests/admin_uploads.rs (4, new): purge happy path, regenerate happy path,
    anon → 303 to /login, non-admin → 403.

Drive-by fixture fixes (NOT phase 23 feature work)

Two small fixes to test infrastructure surfaced while wiring up the new tests. Both were
latent failures on main:

  • server/tests/routes_uploads.rs chat-pool migration list stopped at 0019;
    insert_message_quoted needs the quote_id column from 0020. Added
    0020_quote_reply.sql and 0021_enclave_invitations_enclave_idx.sql. Unblocks the
    pre-existing send_message_with_attachment_renders_inline_image failure that was
    happening on main before any of this work.
  • The hand-typed 1x1 TINY_PNG byte literal is rejected by image::ImageReader::decode
    in image 0.25. Replaced with one generated by PngEncoder at first use. The
    bytes-equality assertion in the round-trip test is relaxed to semantic round-trip
    (decode-and-check-dimensions) since Phase 23 re-encodes on upload.

Known pre-existing test failures (NOT introduced by this PR)

server/tests/email_digest_dispatch.rs (and a few others) fail to compile because they
construct AppState without the bg field added in a later phase. This is unrelated
AppState-shape drift, surfaced when running the full test binary set. Reviewers running
cargo test will see these failures; they exist on main without this PR and should be
addressed separately as test debt.

Out of scope

  • Retroactive EXIF stripping of pre-Phase-23 originals. The Regenerate action is
    preview-only by design: pre-Phase-23 originals are content-addressed by un-stripped
    bytes; re-encoding now would change their sha and break the DB → disk mapping. A
    separate operator action that re-hashes and migrates rows could fill this gap.
  • Audio / video / unified media pipeline. Image-only.

Test plan

  • Upload an iPhone JPEG with GPS EXIF: confirm download → exiftool shows no GPS /
    camera fields.
  • Upload a large image (~5 MB): inline render fetches the smaller preview (Network
    tab); click opens the original.
  • Upload an animated GIF: inline render is static first-frame; click opens the
    animated original.
  • Admin /admin/settings: Uploads panel shows MiB total + orphan count.
  • Admin Purge orphans now: count drops; rerun loads with ?purged=N flash.
  • Admin Regenerate thumbnails: delete a _preview file via shell first, then
    click; file reappears.

🤖 Generated with Claude Code


Task 8 verification status

  • just check: Green. Two phase-23 clippy warnings fixed (explicit_auto_deref in
    sweep.rs); committed as b959eee. Remaining 22 clippy warnings are pre-existing in other
    code.
  • just test: ⚠️ Single compile failure in email_digest_dispatch.rs (pre-existing,
    missing bg field). All 25 new tests pass individually in their own binaries — verified
    by running each test binary in isolation.
  • just verify: ⚠️ Timeout. The release-build cold-compile inside the container takes
    ~32s, exceeding the recipe's hardcoded 30s health-check window. Verified manually that
    the binary builds, server starts, and /login returns HTTP 200 with a .
  • Manual UI smoke: Not performed (no browser in this environment). Test-plan checklist
    included in the PR body for you to walk.
## Summary Three deferred items from Phase 13 land as one coherent upload-hygiene phase: 1. **Image thumbnailing.** Every accepted image upload now produces a 360px-max-dimension preview at `{sha256}_preview.{ext}` adjacent to the original. The inline `<img>` in the message bubble loads `?size=preview`; click-through opens the original. 2. **EXIF / metadata stripping.** Re-encoding through the `image` crate IS the strip: decode drops EXIF / XMP / IPTC / PNG text chunks, and the encoders write none back. JPEG re-encodes at q=92; PNG / WebP / GIF are lossless. Animated GIFs keep their animation in the stored original via multi-frame re-encode and get a first-frame static preview. 3. **Orphan upload GC.** Hourly background sweep removes `file_uploads` rows where `message_id IS NULL` and `created_at < 24 hours ago`. Dedup-aware: the on-disk file is removed only when no other row references the same `storage_path`. The sibling-count, file delete, and row delete all run inside one `BEGIN IMMEDIATE` SQLite transaction so a concurrent upload cannot race the sweep into deleting a still-referenced file. ## New surface - `GET /api/files/:id?size=preview` — content-type derived from the preview file's extension, not the DB row, so a future format where preview-mime ≠ original-mime cannot silently serve the wrong header. - `POST /admin/uploads/regenerate-thumbnails` — preview-only backfill. Originals are not touched (pre-Phase-23 originals are content-addressed by their un-stripped bytes; re-encoding would change their sha and break the DB → disk mapping; see Out of scope). - `POST /admin/uploads/purge-orphans` — runs the same `run_orphan_sweep` as the hourly tick with threshold = 0. No parallel "skip the dedup check" path. - "Uploads" panel under `/admin/settings`: on-disk MiB total, orphan row count, the two action buttons. One inline help line under Regenerate to prevent the operator-clicks-twice failure mode. ## Architecture - New module: `server/src/uploads/` (pipeline, sweep, lift-from-routes helpers). - New dependency: `image = "0.25"` (narrow features: `jpeg`, `png`, `gif`, `webp`). Dev-dep `kamadak-exif = "0.5"` for "EXIF actually stripped" assertions in tests. - `THUMBNAIL_CONCURRENCY = 4` semaphore singleton, modeled on `PUSH_FANOUT_CONCURRENCY`. Caps memory pressure across uploads + regen. - Atomic writes via `.partial` sibling + rename so a crash mid-write never leaves a half-written content-addressed file. - **No DB migration.** The orphan GC index from `0012_uploads.sql` is exactly what the sweeper needs. ## Operator notes - Hourly GC tick, 24-hour orphan threshold (`THRESHOLD_HOURS` in `spawn_orphan_sweeper`). - Single-process invariant for the GC: the `BEGIN IMMEDIATE` + sibling-count + file delete + row delete sequence is documented at the module head as load-bearing under concurrency. If anyone moves the sweep to a sidecar, the transaction stays correct because of `BEGIN IMMEDIATE`; document is there to prevent accidental relaxation. - EXIF stripping is unconditional, no admin toggle. Re-encode is the strip mechanism, so a decode failure on a sniffed-image upload rejects with 400 (re-encode IS the strip; if we can't strip, we don't store). ## Tests 25 tests across 4 binaries, all passing when run individually: - `server/tests/uploads_pipeline.rs` (6): JPEG-with-EXIF → stripped (verified via kamadak-exif), 1200x800 → 360px preview, 3-frame GIF preserves frames in original / static in preview, decode failure, unsupported mime, `preview_from_path`. - `server/tests/uploads_sweep.rs` (6): **centerpiece dedup+GC test** (A linked + B orphan share storage_path → B's row gone, file preserved for A), **original-present-preview-absent** (pins the "preview write failed, original committed" recovery path), plus the four standard sweep invariants. - `server/tests/routes_uploads.rs` (+2): `?size=preview` returns smaller decoded bytes; dedup upload heals a missing preview end-to-end through HTTP. - `server/tests/admin_uploads.rs` (4, new): purge happy path, regenerate happy path, anon → 303 to /login, non-admin → 403. ## Drive-by fixture fixes (NOT phase 23 feature work) Two small fixes to test infrastructure surfaced while wiring up the new tests. Both were latent failures on `main`: - `server/tests/routes_uploads.rs` chat-pool migration list stopped at `0019`; `insert_message_quoted` needs the `quote_id` column from `0020`. Added `0020_quote_reply.sql` and `0021_enclave_invitations_enclave_idx.sql`. Unblocks the pre-existing `send_message_with_attachment_renders_inline_image` failure that was happening on `main` before any of this work. - The hand-typed 1x1 `TINY_PNG` byte literal is rejected by `image::ImageReader::decode` in image 0.25. Replaced with one generated by `PngEncoder` at first use. The bytes-equality assertion in the round-trip test is relaxed to semantic round-trip (decode-and-check-dimensions) since Phase 23 re-encodes on upload. ## Known pre-existing test failures (NOT introduced by this PR) `server/tests/email_digest_dispatch.rs` (and a few others) fail to compile because they construct `AppState` without the `bg` field added in a later phase. This is unrelated AppState-shape drift, surfaced when running the full test binary set. Reviewers running `cargo test` will see these failures; they exist on `main` without this PR and should be addressed separately as test debt. ## Out of scope - Retroactive EXIF stripping of pre-Phase-23 originals. The Regenerate action is preview-only by design: pre-Phase-23 originals are content-addressed by un-stripped bytes; re-encoding now would change their sha and break the DB → disk mapping. A separate operator action that re-hashes and migrates rows could fill this gap. - Audio / video / unified media pipeline. Image-only. ## Test plan - [ ] Upload an iPhone JPEG with GPS EXIF: confirm download → `exiftool` shows no GPS / camera fields. - [ ] Upload a large image (~5 MB): inline render fetches the smaller preview (Network tab); click opens the original. - [ ] Upload an animated GIF: inline render is static first-frame; click opens the animated original. - [ ] Admin `/admin/settings`: Uploads panel shows MiB total + orphan count. - [ ] Admin `Purge orphans now`: count drops; rerun loads with `?purged=N` flash. - [ ] Admin `Regenerate thumbnails`: delete a `_preview` file via shell first, then click; file reappears. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Task 8 verification status - just check: ✅ Green. Two phase-23 clippy warnings fixed (explicit_auto_deref in sweep.rs); committed as b959eee. Remaining 22 clippy warnings are pre-existing in other code. - just test: ⚠️ Single compile failure in email_digest_dispatch.rs (pre-existing, missing bg field). All 25 new tests pass individually in their own binaries — verified by running each test binary in isolation. - just verify: ⚠️ Timeout. The release-build cold-compile inside the container takes ~32s, exceeding the recipe's hardcoded 30s health-check window. Verified manually that the binary builds, server starts, and /login returns HTTP 200 with a <form>. - Manual UI smoke: Not performed (no browser in this environment). Test-plan checklist included in the PR body for you to walk.
Covers image thumbnailing (360px preview), EXIF/metadata stripping via
re-encode, and orphan GC with dedup-aware file deletion. Includes full
architecture, file map, task breakdown, and deployment notes.
Adds the `image` crate (narrow features: jpeg/png/gif/webp) for the upcoming thumbnail + EXIF-strip pipeline, plus `kamadak-exif` as a dev-dep used only by tests to verify metadata was actually stripped. Creates the server/src/uploads/ module with a process-wide Semaphore singleton (THUMBNAIL_CONCURRENCY = 4) modeled on PUSH_FANOUT_CONCURRENCY. Pipeline and sweep modules are stubs for the next two tasks; the constant lives in mod.rs so future tuning has one obvious home.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single `process_image(tmp_path, mime)` entry point. Decodes once via `image::ImageReader`, then re-encodes both a stripped original and a 360px-max-dimension preview. Re-encoding through the `image` crate IS the metadata strip; decode discards EXIF / XMP / IPTC / PNG text chunks, and the encoders never write them back. JPEG re-encodes at q=92; PNG and WebP are lossless via the `ImageEncoder` trait. Animated GIFs take a separate path that re-encodes all frames (preserving animation in the stored original) and thumbnails frame 0 for the static preview, matching the discussion decision that the inline render is a still and the click-through animates. Loop count is forced to `Repeat::Infinite` since image 0.25's `into_frames` does not surface the source loop count; the privacy gain from re-encoding outweighs that one-bit deviation for the rare finite-loop GIF. PipelineError variants are `Decode`, `Encode`, `UnsupportedMime`, `Io`; decode failures route to a 400 at the HTTP layer in Task 3.

Note: the plan's Architecture section body said "collapse to first-frame static in both buffers" but the parenthetical and our design discussion said "original keeps its animation". This commit follows the discussion intent. The plan body should be amended in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Architecture section body said "Animated GIFs collapse to first-frame static in both buffers" which contradicted the parenthetical "the original GIF on click, which keeps its animation" and the design-discussion decision. Reworded so the body matches: original keeps animation via multi-frame re-encode (which is what does the strip), preview is first-frame static.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /api/upload now branches on the sniffed MIME after the existing `infer` allowlist check. Image MIMEs (jpeg/png/gif/webp) go through the new pipeline: acquire a permit from the THUMBNAIL_CONCURRENCY=4 semaphore, run `pipeline::process_image` on `tokio::task::spawn_blocking` (it is CPU- and sync-IO-bound), and on success write the stripped re-encoded original to `{sha256}.{ext}` and the preview to `{sha256}_preview.{ext}` via `write_atomic` (`.partial` sibling + rename). PDFs continue through the unchanged hash-the-temp-file + rename path.

Decode failures map to a 400 with `"image could not be decoded"` (re-encode IS the strip; if we cannot strip, we do not store). Other pipeline errors map to 500.

The sha256 is computed over the STRIPPED bytes so two users uploading the same photo with different camera-of-origin metadata dedup correctly. On a dedup hit the original is not rewritten; a missing preview is healed in-place so a prior failed preview write does not leave that row preview-less forever.

The DB row's `mime_type` and `size_bytes` are taken from `ProcessedImage` (the pipeline output), not the inferred multipart MIME or the raw byte count. For static formats this is observably identical; surfacing the pipeline values keeps the contract clean and makes the source-of-truth obvious to a future reader.

Three private helpers live in this file for now: `sha256_bytes`, `preview_storage_name`, `write_atomic`. `preview_storage_name` will lift to `crate::uploads` when Task 4 (the serve route) adds the second caller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /api/files/:id now reads an optional `?size=preview` query parameter. When set and the row is an image MIME, the handler resolves the preview path via `crate::uploads::preview_storage_name`, checks the preview file exists on disk, and serves it. Missing preview falls through to the original.

The preview response's Content-Type is derived from `crate::uploads::mime_for_storage_path(&preview_name)` (extension → MIME mapping), NOT from the DB row's `mime_type`. For every supported format today the two are observably identical; this structural separation future-proofs against a format where preview-mime ≠ original-mime (e.g. HEIC → JPEG transcode), which would otherwise quietly serve the preview with the wrong header. If the mapping returns None for a supported-image preview (it should not for any path we ever write), a warn is logged and the row mime is used as a safety net.

Content-Length for the preview is the on-disk size from the metadata stat we already perform for the existence check, so the response advertises the actual bytes being sent rather than the original's row.size_bytes.

`preview_storage_name` was a private helper in routes/uploads.rs; lifted to crate::uploads now that the serve route is the second caller. `mime_for_storage_path` is new in the same module so the path/MIME helpers live together.

Template: image attachments render `<img src="{{ a.url }}?size=preview">` inside the existing `<a href="{{ a.url }}">` link, so the inline render fetches the smaller preview while click-through opens the original. PDF/file branch unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`uploads::sweep::run_orphan_sweep(state, threshold_hours)` selects rows from `file_uploads` where `message_id IS NULL` and `created_at < datetime('now', '-N hours')`, then processes each candidate inside its own SQLite transaction.

Each per-row transaction opens with `BEGIN IMMEDIATE TRANSACTION`, which acquires SQLite's write lock immediately. The sibling-count (`SELECT COUNT(*) ... WHERE storage_path = ? AND id <> ?`), the filesystem delete of the original and preview files, and the row delete all run inside that critical section, in that order. The IMMEDIATE keyword is load-bearing: with deferred BEGIN, a concurrent upload could insert a sibling row between our COUNT and our DELETE and we would delete a still-referenced file. IMMEDIATE blocks that upload at its INSERT until we commit. The file delete happens BEFORE the row delete so a file-delete failure rolls back both for the next sweep to retry; today the scheduler is single-process, but the transaction makes a future sidecar correct under concurrency.

`remove_if_exists` treats `NotFound` as success and propagates everything else. The missing-preview-but-present-original case (the Task 3 "preview write failed, original committed" recovery path) goes through this branch cleanly.

`spawn_orphan_sweeper` in main.rs models on `spawn_digest_sender`: hourly `tokio::time::interval`, skip the immediate fire, log-and-continue on error, info-log only when something happened (`rows_deleted > 0 || errors > 0`) so idle deployments stay quiet. Threshold is 24 hours.

DB helpers added to db/uploads.rs: `select_orphans_older_than` (batch read), `count_uploads_sharing_path` and `delete_upload_row` (both take `&mut SqliteConnection` so the sweeper can share its transaction handle).

Plan amended in Task 7's test list with one bullet for the original-present-preview-absent case to pin the recovery path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three additions to /admin/settings:

1. "Regenerate thumbnails" walks every image upload, generates the preview for any row whose `_preview` file is missing on disk. Originals are not touched (pre-Phase-23 originals are content-addressed by un-stripped bytes; re-encoding would change their sha and break the DB→disk mapping). Uses the same `THUMBNAIL_CONCURRENCY` semaphore as the upload pipeline and runs the decode/encode on `spawn_blocking` so it does not pin async workers. Returns `303 → /admin/settings?regenerated=N`.

2. "Purge orphans now" calls `uploads::sweep::run_orphan_sweep(state, 0)`. Same function the hourly tick uses; the only difference is the threshold. The dedup-aware `BEGIN IMMEDIATE` transaction, the file-before-row delete order, and the missing-file-is-success treatment all apply. A separate "operator clicked the button, they meant it" function that skipped the dedup check would be the embarrassing-in-prod bug; this path explicitly avoids it. Returns `303 → /admin/settings?purged=N`.

3. Uploads panel renders two stats (on-disk size in MiB, orphan row count) and the two action buttons. Pre-formatted MiB string is computed in the handler since Askama cannot do the i64→f64 cast inline.

`pipeline::preview_from_path` is a new pub fn that takes a disk path + mime and returns just preview bytes; the regen action uses it. `write_atomic` lifted from `routes/uploads.rs` to `crate::uploads` so both the upload handler and the regen action share one atomic-write implementation. Three DB helpers added: `sum_size_bytes`, `count_orphans`, `list_image_uploads`. `SettingsPage` view gained `uploads_total_display`, `uploads_orphan_count`, and two `Option<i64>` flash fields read from the post-redirect query string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an inline help line under the "Regenerate thumbnails" button: "May take several minutes on large deployments. Wait for the page to reload; do not click again." Prevents the operator-clicks-twice failure mode for free, no JS needed.

Adds a known-limitation bullet to the plan's Out-of-scope section: retroactive EXIF stripping of pre-Phase-23 originals. The Regenerate action is preview-only by design because pre-Phase-23 originals are content-addressed by their un-stripped bytes; re-encoding them now would change their sha and break the DB→disk mapping. Documents the trade-off honestly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four test binaries covering the Phase 23 surface:

server/tests/uploads_pipeline.rs (6 tests):
- JPEG with synthetic EXIF GPS → re-encode strips both the stored original and the preview (verified via kamadak-exif, the dev-dep added in Task 1).
- 1200x800 PNG → preview is 360x240 (longer side capped, aspect preserved).
- 3-frame animated GIF → original preserves all 3 frames, preview is 1-frame static, matching the design discussion's "original animates on click" decision.
- Corrupt bytes after the PNG magic → PipelineError::Decode (the policy that maps to 400 at the HTTP layer).
- application/pdf → PipelineError::UnsupportedMime.
- preview_from_path emits a 360-capped preview (covers the admin regenerate path).

server/tests/uploads_sweep.rs (6 tests):
- CENTERPIECE: A linked + B orphan sharing storage_path, sweep removes B's row and keeps the file because A still references it. This is the dedup-aware-GC bug that would have been embarrassing in prod; it now has a regression test.
- Original present, preview absent (Task 5 amendment): pins the Task 3 "preview write failed, original committed" recovery path. Sweep removes the row without surfacing the missing preview as an error.
- Younger-than-threshold orphan survives.
- Linked row 100 days old is never touched.
- Missing original file is treated as success.
- Preview file is removed alongside the original.

server/tests/routes_uploads.rs (9 tests, 2 new):
- ?size=preview returns smaller bytes than the original, and the decoded preview's longer side is 360.
- Dedup upload heals a missing preview: alice uploads, preview deleted from disk, bob uploads identical content, preview is restored. End-to-end exercise of the Task 3 heal path.
- The hand-typed 1x1 TINY_PNG fixture is replaced with one generated by `image::PngEncoder` at first use, since the original byte literal is rejected by `image::ImageReader::decode` (image 0.25 is strict about IDAT payloads). The "round-trips uploaded bytes" test now asserts semantic round-trip (decodes as a 1x1 PNG) instead of byte equality, since Phase 23 re-encodes on upload.
- Migrations 0020 (quote_reply) and 0021 (enclave_invitations index) added to the chat-pool list; without them, insert_message_quoted fails because the test pool lacks the quote_id column. This was a pre-existing failure in `send_message_with_attachment_renders_inline_image` on main, surfaced by my added tests running in the same binary.

server/tests/admin_uploads.rs (4 tests, new file):
- Admin POST /admin/uploads/purge-orphans removes a 25-hour-old orphan (303 redirect on success, row gone).
- Admin POST /admin/uploads/regenerate-thumbnails creates a missing preview file for a seeded row.
- Anonymous POST to either admin endpoint → 303 to /login.
- Authenticated non-admin user → 403.

Production-side refactor: `uploads::sweep::run_orphan_sweep` now takes `&SqlitePool` instead of `&AppState` (the function only used `state.chat`). Tests no longer have to construct a full AppState just to run a sweep. main::spawn_orphan_sweeper and routes::admin::post_purge_orphans pass `&state.chat` instead of `&state`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chore(uploads): clippy + cargo fmt fixes for phase 23
Some checks failed
Check / clippy + fmt + tests (pull_request) Failing after 17s
b959eeed6b
Two phase-23 clippy warnings fixed (`explicit_auto_deref` in sweep.rs: `&mut *conn` → `&mut conn` at the two call sites where the DB helpers take `&mut SqliteConnection` and `PoolConnection` deref-coerces). All remaining clippy warnings come from pre-existing code outside this phase's scope.

Trailing `cargo fmt` pass on the seven files touched by phase 23. Pure whitespace; no semantic changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
longjacksonle deleted branch feat/upload-hygiene 2026-05-14 02:31:33 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!91
No description provided.