Skip to content

feat(desktop): private uploads (signed URLs) via a settings toggle#7

Merged
facundofarias merged 4 commits into
mainfrom
feat/private-uploads
Jul 13, 2026
Merged

feat(desktop): private uploads (signed URLs) via a settings toggle#7
facundofarias merged 4 commits into
mainfrom
feat/private-uploads

Conversation

@facundofarias

Copy link
Copy Markdown
Member

What

Adds private uploads to the desktop app. When signed in and the new Private uploads toggle is on, the app sends visibility=private (+ sign_expires_in) to POST /v1/images and puts the returned signed URL on the clipboard. Off by default; anonymous uploads stay public.

Depends on the backend enabler (PixelVault API PR #114, already deployed).

UX

  • A Settings card appears only when signed in: a "Private uploads" toggle (off by default) + a link-expiry picker (1h / 1d / 7d default / 30d), persisted in state.json and clamped to the API's 60s–30d bounds.
  • Private uploads notify "Private link copied · expires in Nd" rather than echoing the raw signed URL (it's on the clipboard + in the tray).

Privacy / correctness (post-council)

Ran a two-reviewer council (correctness + security). Security PASS on the privacy boundary; the session-expiry-mid-flight → public-downgrade threat was explicitly exercised and is closed by the existing gated_hash guard. Fixes applied:

  • Atomic persistencepersist() snapshots all fields under a save_lock and writes via temp-file + rename, so a settings save overlapping an in-flight upload can't tear state.json or drop a field.
  • Signed URLs never touch disk — private URLs are bearer capabilities, so they're kept in memory for the tray's click-to-copy but excluded from state.json, and dropped from memory on sign-out / session loss.
  • Single write for set_settings; off-grid expiry values are represented in the picker so they're not silently downgraded.
  • Bug caught by new tests: the derived Default gave a 0s (→60s) link TTL on a fresh install — a manual Default fixes it to 7 days.

Tests

Adds the repo's first unit tests (state.rs): clamp bounds, defaults, prefs round-trip, private-never-persisted, forget-private, backward compat. cargo test (6 passing), cargo check, cargo clippy, and tsc && vite build all green.

🤖 Generated with Claude Code

facundofarias and others added 4 commits July 13, 2026 09:08
Signed-in uploads can now be made private: the app sends
`visibility=private` (+ `sign_expires_in`) to POST /v1/images and puts
the returned signed URL on the clipboard.

- Settings card (shown only when signed in): a "Private uploads" toggle
  (off by default) + a link-expiry picker (1h/1d/7d/30d, default 7d),
  persisted in state.json and clamped to the API's 60s..30d bounds.
- upload.rs: KeyedOptions carries visibility/sign_expires_in; a 402 maps
  to a friendly "private image limit reached" message.
- Anonymous uploads stay public (visibility ignored without a secret key).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Applied the two-reviewer council findings:

- H1 (correctness): persist() now snapshots all fields fresh under a
  save_lock and writes via temp-file + rename, so a settings save
  overlapping an in-flight upload can't tear state.json or drop a field.
- Security-1 (medium): private (signed) URLs are bearer capabilities, so
  they're kept in memory for the tray but NEVER written to state.json, and
  are dropped from memory on sign-out / session loss.
- Security-3 (low): private uploads no longer put the raw signed URL in the
  notification body — it shows "Private link copied · expires in 7 days".
- M1: set_settings collapses to one combined setter (single write).
- L2 (UI): the expiry <select> always represents the stored value, so an
  off-grid (hand-edited) lifetime isn't silently downgraded on the next save.
- Bug caught by the new tests: Persisted's derived Default gave a 0s (→60s
  clamped) link TTL on a fresh install; a manual Default fixes it to 7 days.

Adds the repo's first unit tests (state.rs): clamp bounds, defaults,
prefs round-trip, private-never-persisted, forget-private, back-compat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two issues surfaced while testing the private-uploads flow locally:

- The settings window re-rendered on every focus regain, wiping a
  half-typed email / verification code. Now it reconciles auth state on
  focus but only re-renders when signed-in/out actually changed, so an
  in-progress sign-in survives an app switch.
- auth::store() could fail with errSecDuplicateItem ("item already exists")
  when a keychain entry was already present (a partial earlier sign-in, or
  an unsigned dev rebuild whose signature no longer matches the item ACL).
  It now deletes any prior entry before setting, so re-sign-in always
  creates a fresh item owned by the current binary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ocus race

Second council (correctness + security) on the delta. Security found a
HIGH the upload-path redaction missed:

- HIGH: clicking a private entry in the tray's "Recent uploads" put the
  raw signed (bearer) URL in the notification body — the same Notification
  Center exposure the upload path avoids. Now redacts to "Private link
  copied · Paste to share" (URL still goes to the clipboard). The tray
  label also stripped the ?token=… query and marks private links (🔒), so
  the signing token never shows in the menu either.
- MEDIUM: state.json is now written 0600 (owner-only) — it's a
  privacy-aware file (upload history + prefs).
- LOW: refreshOnFocus defers to the initial render (no redundant re-render
  on an early focus event).

Adds lib.rs tests: short_label strips the token + marks private; human_duration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@facundofarias
facundofarias merged commit dc5bc32 into main Jul 13, 2026
2 checks passed
@facundofarias
facundofarias deleted the feat/private-uploads branch July 13, 2026 07:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant