feat(desktop): private uploads (signed URLs) via a settings toggle#7
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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) toPOST /v1/imagesand 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
state.jsonand clamped to the API's 60s–30d bounds.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_hashguard. Fixes applied:persist()snapshots all fields under asave_lockand writes via temp-file +rename, so a settings save overlapping an in-flight upload can't tearstate.jsonor drop a field.state.json, and dropped from memory on sign-out / session loss.set_settings; off-grid expiry values are represented in the picker so they're not silently downgraded.Defaultgave a 0s (→60s) link TTL on a fresh install — a manualDefaultfixes 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, andtsc && vite buildall green.🤖 Generated with Claude Code