From 04888c7f643cd09fa22c4c450cb9d53651cf75f2 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Mon, 13 Jul 2026 09:08:41 +0200 Subject: [PATCH 1/4] feat(desktop): private uploads (signed URLs) via a settings toggle 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) --- CLAUDE.md | 26 ++++++++------ index.html | 4 +++ src-tauri/src/lib.rs | 78 +++++++++++++++++++++++++++++++++-------- src-tauri/src/state.rs | 62 ++++++++++++++++++++++++++++++-- src-tauri/src/upload.rs | 32 ++++++++++++++--- src/main.ts | 76 +++++++++++++++++++++++++++++++++++++++ src/styles.css | 64 +++++++++++++++++++++++++++++++++ 7 files changed, 310 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9f91141..d8991cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,11 +52,13 @@ PIXELVAULT_API_BASE=https://api-staging.pixelvault.dev npm run tauri dev - `lib.rs` — Tauri builder + `setup`: Accessory activation policy (no Dock icon), loads the cached session, manages `AppState`, builds the tray, spawns the watcher, registers the Mode B hotkey + the sign-in commands (`sign_in_start` / - `sign_in_complete` / `auth_status` / `sign_out`). Also the **shared `run_upload` + `sign_in_complete` / `auth_status` / `sign_out`) and the settings commands + (`get_settings` / `set_settings`). Also the **shared `run_upload` pipeline** (`upload_and_notify`): it branches on the cached session — signed-in - → keyed ephemeral upload; signed-out → anonymous trial with an atomic - reservation + hard gate. Plus `refresh_counter` / `refresh_recent` / - `refresh_account` / `set_busy` (tray updates, all on the main thread). + → keyed ephemeral upload (honouring the **private-uploads** preference → signed + URL); signed-out → anonymous trial with an atomic reservation + hard gate. Plus + `refresh_counter` / `refresh_recent` / `refresh_account` / `set_busy` (tray + updates, all on the main thread). - `watcher.rs` — **Mode A (passive):** background thread polls the clipboard (`arboard`) every 1.2s, hashes to dedupe, runs the shared pipeline, writes the URL back. Tracks a separate `gated_hash` so a gated image is retried once the @@ -67,12 +69,16 @@ PIXELVAULT_API_BASE=https://api-staging.pixelvault.dev npm run tauri dev api_key}` is stored in the OS keychain (`keyring`); `load_session()` **fails closed** (a real keychain error propagates rather than flattening to "signed out"). `config.rs` holds the shared API base. -- `upload.rs` — RGBA→PNG encode + multipart `POST /v1/images` (keyed = Bearer + - `expires_in`; else anonymous). Returns `UploadError::Unauthorized` on 401/403 so - the caller clears the session + re-prompts. Base from `PIXELVAULT_API_BASE`. -- `state.rs` — `TrialState`: free-upload counter (limit 5) + last 5 URLs in - `/state.json`. Anonymous admission is atomic: `try_reserve` - (compare-exchange) → `commit_reserved` / `release`. +- `upload.rs` — RGBA→PNG encode + multipart `POST /v1/images`. Keyed = Bearer + + `KeyedOptions` (`expires_in`; `visibility=private` + `sign_expires_in` when + private — the server returns the ready-to-paste **signed** URL); else anonymous + (always public). Returns `UploadError::Unauthorized` on 401/403 so the caller + clears the session + re-prompts; a `402` maps to a "private image limit reached" + message. Base from `PIXELVAULT_API_BASE`. +- `state.rs` — `TrialState`: free-upload counter (limit 5) + last 5 URLs + the + **private-uploads** preference (`private_uploads` + `sign_expires_secs`, clamped + to 60s..30d, default 7d) in `/state.json`. Anonymous admission + is atomic: `try_reserve` (compare-exchange) → `commit_reserved` / `release`. - `tray.rs` — menu-bar icon (transparent template glyph) + menu: status, account ("Signed in as …" / "Not signed in"), free-uploads counter, "Recent uploads" click-to-copy, pause/resume, Account & Settings…, quit. diff --git a/index.html b/index.html index 9ed1987..7c84d00 100644 --- a/index.html +++ b/index.html @@ -45,6 +45,10 @@

PixelVault

+ +

Two ways to grab an image — both give you a hosted URL to paste:

diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8be4f29..001b1df 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -106,20 +106,37 @@ pub fn upload_and_notify(app: &AppHandle, png_bytes: Vec) -> Result) -> Result, String> { match current_key(app) { // Signed in → keyed, ephemeral (30d) upload; not part of the free trial. - Some(key) => match upload::upload_png(png_bytes, Some(&key), Some(EPHEMERAL_SECS)) { - Ok(url) => { - app.state::().trial.push_recent(&url); - refresh_recent(app); - notify(app, "Image URL copied", &url); - Ok(Some(url)) - } - // Revoked/expired key — clear the session and prompt re-sign-in. - Err(upload::UploadError::Unauthorized) => { - handle_unauthorized(app); - Ok(None) + // Honour the private-uploads preference (signed URLs) from settings. + Some(key) => { + let (private, sign_secs) = { + let trial = &app.state::().trial; + (trial.private_uploads(), trial.sign_expires_secs()) + }; + let opts = upload::KeyedOptions { + expires_in: Some(EPHEMERAL_SECS), + private, + sign_expires_in: private.then_some(sign_secs), + }; + match upload::upload_png(png_bytes, Some(&key), opts) { + Ok(url) => { + app.state::().trial.push_recent(&url); + refresh_recent(app); + let title = if private { + "Private URL copied" + } else { + "Image URL copied" + }; + notify(app, title, &url); + Ok(Some(url)) + } + // Revoked/expired key — clear the session and prompt re-sign-in. + Err(upload::UploadError::Unauthorized) => { + handle_unauthorized(app); + Ok(None) + } + Err(e) => Err(e.message()), } - Err(e) => Err(e.message()), - }, + } // Signed out → anonymous trial. Reserve a slot atomically; if the free // limit is reached, HARD-gate: stop and prompt sign-in (a trial that // never blocks converts no one). Bypassable by design (client-side). @@ -133,7 +150,7 @@ fn run_upload(app: &AppHandle, png_bytes: Vec) -> Result, Str open_settings(app); return Ok(None); } - match upload::upload_png(png_bytes, None, None) { + match upload::upload_png(png_bytes, None, upload::KeyedOptions::default()) { Ok(url) => { app.state::().trial.commit_reserved(&url); refresh_counter(app); @@ -320,6 +337,35 @@ fn sign_out(app: AppHandle) -> Result<(), String> { Ok(()) } +#[derive(serde::Serialize)] +struct Settings { + private_uploads: bool, + sign_expires_secs: u64, +} + +fn read_settings(app: &AppHandle) -> Settings { + let trial = &app.state::().trial; + Settings { + private_uploads: trial.private_uploads(), + sign_expires_secs: trial.sign_expires_secs(), + } +} + +#[tauri::command] +fn get_settings(app: AppHandle) -> Settings { + read_settings(&app) +} + +/// Persist the upload settings. Returns the stored (clamped) values so the UI +/// reflects exactly what was saved. +#[tauri::command] +fn set_settings(app: AppHandle, private_uploads: bool, sign_expires_secs: u64) -> Settings { + let trial = &app.state::().trial; + trial.set_private_uploads(private_uploads); + trial.set_sign_expires_secs(sign_expires_secs); + read_settings(&app) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -330,7 +376,9 @@ pub fn run() { sign_in_start, sign_in_complete, auth_status, - sign_out + sign_out, + get_settings, + set_settings ]) .setup(|app| { // Menubar-first: no dock icon on macOS. diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 7e66790..1d8878e 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -5,7 +5,7 @@ use std::fs; use std::path::PathBuf; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::Mutex; use serde::{Deserialize, Serialize}; @@ -16,11 +16,28 @@ pub const FREE_UPLOAD_LIMIT: u32 = 5; /// How many recent upload URLs to remember (and show in the tray). pub const RECENT_LIMIT: usize = 5; +/// Default signed-URL lifetime for private uploads: 7 days. +pub const DEFAULT_SIGN_EXPIRES_SECS: u64 = 7 * 24 * 60 * 60; +/// Bounds the API enforces on `sign_expires_in` (60s .. 30d). We clamp locally +/// so a stale/hand-edited state file can't push an out-of-range value. +pub const MIN_SIGN_EXPIRES_SECS: u64 = 60; +pub const MAX_SIGN_EXPIRES_SECS: u64 = 30 * 24 * 60 * 60; + +fn default_sign_expires_secs() -> u64 { + DEFAULT_SIGN_EXPIRES_SECS +} + #[derive(Serialize, Deserialize, Default)] struct Persisted { uploads: u32, #[serde(default)] recent: Vec, + /// Upload signed-in images privately (signed URLs). Off by default. + #[serde(default)] + private_uploads: bool, + /// Signed-URL lifetime, in seconds, for private uploads. + #[serde(default = "default_sign_expires_secs")] + sign_expires_secs: u64, } pub struct TrialState { @@ -28,6 +45,10 @@ pub struct TrialState { uploads: AtomicU32, /// Most-recent-first, capped at `RECENT_LIMIT`. recent: Mutex>, + /// Whether signed-in uploads are made private (signed URLs). + private_uploads: AtomicBool, + /// Signed-URL lifetime (seconds) for private uploads, clamped to bounds. + sign_expires_secs: AtomicU64, } impl TrialState { @@ -42,6 +63,8 @@ impl TrialState { path, uploads: AtomicU32::new(persisted.uploads), recent: Mutex::new(persisted.recent), + private_uploads: AtomicBool::new(persisted.private_uploads), + sign_expires_secs: AtomicU64::new(clamp_sign_expires(persisted.sign_expires_secs)), } } @@ -57,6 +80,29 @@ impl TrialState { self.recent.lock().map(|r| r.clone()).unwrap_or_default() } + /// Whether signed-in uploads should be made private (signed URLs). + pub fn private_uploads(&self) -> bool { + self.private_uploads.load(Ordering::Relaxed) + } + + /// Signed-URL lifetime (seconds) for private uploads. + pub fn sign_expires_secs(&self) -> u64 { + self.sign_expires_secs.load(Ordering::Relaxed) + } + + /// Update the private-uploads preference and persist it. + pub fn set_private_uploads(&self, on: bool) { + self.private_uploads.store(on, Ordering::Relaxed); + self.persist(self.uploads(), self.recent()); + } + + /// Update the signed-URL lifetime (clamped to bounds) and persist it. + pub fn set_sign_expires_secs(&self, secs: u64) { + self.sign_expires_secs + .store(clamp_sign_expires(secs), Ordering::Relaxed); + self.persist(self.uploads(), self.recent()); + } + /// Atomically reserve one anonymous-trial slot. Returns `true` if a slot was /// available (counter incremented), `false` if the free limit is reached. /// A compare-and-swap loop makes admission atomic across the watcher and @@ -121,8 +167,20 @@ impl TrialState { if let Some(parent) = self.path.parent() { let _ = fs::create_dir_all(parent); } - if let Ok(json) = serde_json::to_string(&Persisted { uploads, recent }) { + let snapshot = Persisted { + uploads, + recent, + private_uploads: self.private_uploads(), + sign_expires_secs: self.sign_expires_secs(), + }; + if let Ok(json) = serde_json::to_string(&snapshot) { let _ = fs::write(&self.path, json); } } } + +/// Clamp a signed-URL lifetime to the range the API accepts, so a stale or +/// hand-edited value can never produce a request the server would reject. +fn clamp_sign_expires(secs: u64) -> u64 { + secs.clamp(MIN_SIGN_EXPIRES_SECS, MAX_SIGN_EXPIRES_SECS) +} diff --git a/src-tauri/src/upload.rs b/src-tauri/src/upload.rs index cd0998c..a1879a8 100644 --- a/src-tauri/src/upload.rs +++ b/src-tauri/src/upload.rs @@ -49,15 +49,28 @@ pub fn encode_png(width: u32, height: u32, rgba: Vec) -> Result, Str Ok(out.into_inner()) } +/// Options for a keyed (signed-in) upload. +#[derive(Default)] +pub struct KeyedOptions { + /// Ephemeral image TTL in seconds (when the image itself auto-archives). + pub expires_in: Option, + /// Upload privately (mint a signed URL). Requires a secret key. + pub private: bool, + /// Signed-URL lifetime in seconds when `private` (the pasted link's TTL). + pub sign_expires_in: Option, +} + /// Upload a PNG and return the hosted URL. /// -/// - `api_key`: `Some` → keyed (permanent unless `expires_in` set); `None` → -/// anonymous temporary. -/// - `expires_in`: keyed ephemeral TTL in seconds (ignored when anonymous). +/// - `api_key`: `Some` → keyed; `None` → anonymous temporary (always public). +/// - `opts`: keyed-only knobs (image TTL, private/signed URL). Ignored when +/// anonymous — the server ignores `visibility` without a secret key anyway. +/// +/// For a private upload the returned URL is the **signed** URL, ready to paste. pub fn upload_png( png: Vec, api_key: Option<&str>, - expires_in: Option, + opts: KeyedOptions, ) -> Result { let client = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(30)) @@ -70,9 +83,15 @@ pub fn upload_png( .map_err(|e| UploadError::Failed(e.to_string()))?; let mut form = reqwest::blocking::multipart::Form::new().part("file", part); if api_key.is_some() { - if let Some(secs) = expires_in { + if let Some(secs) = opts.expires_in { form = form.text("expires_in", secs.to_string()); } + if opts.private { + form = form.text("visibility", "private"); + if let Some(secs) = opts.sign_expires_in { + form = form.text("sign_expires_in", secs.to_string()); + } + } } let mut req = client.post(format!("{}/v1/images", api_base())).multipart(form); @@ -88,6 +107,9 @@ pub fn upload_png( } // Friendly, body-free messages for the common cases. let msg = match status.as_u16() { + 402 => { + "Private image limit reached — upgrade for unlimited private images.".to_string() + } 413 => "Image is too large.".to_string(), 429 => "Rate limit reached — try again shortly.".to_string(), s => format!("Upload failed ({s})."), diff --git a/src/main.ts b/src/main.ts index 2f2693d..11c5d8f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,7 +9,21 @@ interface AuthStatus { remaining: number; } +interface Settings { + private_uploads: boolean; + sign_expires_secs: number; +} + const account = document.getElementById("account")!; +const settings = document.getElementById("settings")!; + +/** Signed-URL lifetime choices (seconds), shown in the expiry picker. */ +const EXPIRY_OPTIONS: { label: string; secs: number }[] = [ + { label: "1 hour", secs: 3600 }, + { label: "1 day", secs: 86400 }, + { label: "7 days", secs: 604800 }, + { label: "30 days", secs: 2592000 }, +]; function esc(s: string): string { return s.replace( @@ -30,11 +44,73 @@ async function refresh(): Promise { const status = await invoke("auth_status"); if (status.signed_in && status.email) { renderSignedIn(status.email); + await renderSettings(); } else { renderSignedOut(status.remaining); + // Private uploads require a signed-in secret key — hide the controls. + settings.hidden = true; + settings.innerHTML = ""; } } +/** Render the private-uploads controls (only shown while signed in). */ +async function renderSettings(): Promise { + const current = await invoke("get_settings"); + const opts = EXPIRY_OPTIONS.map( + (o) => + `` + ).join(""); + + settings.innerHTML = ` + +
+ + +
+

+ `; + settings.hidden = false; + + const toggle = settings.querySelector("#private-toggle")!; + const expiryRow = settings.querySelector(".set-expiry")!; + const expiry = settings.querySelector("#expiry")!; + const msg = settings.querySelector("#set-msg")!; + + async function save(): Promise { + try { + await invoke("set_settings", { + privateUploads: toggle.checked, + signExpiresSecs: Number(expiry.value), + }); + setMsg( + msg, + toggle.checked + ? "New uploads will be private." + : "New uploads will be public.", + false + ); + } catch (e) { + setMsg(msg, String(e), true); + } + } + + toggle.addEventListener("change", () => { + expiryRow.hidden = !toggle.checked; + void save(); + }); + expiry.addEventListener("change", () => void save()); +} + function renderSignedOut(remaining: number): void { account.innerHTML = `

Sign in for permanent uploads & history

diff --git a/src/styles.css b/src/styles.css index ad7b8dd..80ce6a2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -181,3 +181,67 @@ button.secondary:hover { .acct-msg.error { color: var(--red); } + +/* Settings card (private uploads) — only shown when signed in. */ +.settings { + margin: 0 0 16px; + padding: 14px 16px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + text-align: left; +} +.set-row { + display: flex; + gap: 10px; + align-items: flex-start; +} +.set-row + .set-row { + margin-top: 12px; +} +.set-row input[type="checkbox"] { + margin-top: 3px; + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; +} +.set-title { + display: block; + font-weight: 600; + color: var(--text-primary); +} +.set-sub { + display: block; + margin-top: 2px; + font-size: 12px; + color: var(--text-secondary); +} +.set-expiry { + align-items: center; + gap: 8px; +} +.set-expiry .set-title { + font-weight: 500; + color: var(--text-secondary); +} +.set-expiry select { + margin-left: auto; + padding: 7px 10px; + color: var(--text-primary); + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 8px; + font: inherit; + cursor: pointer; +} +.set-expiry select:focus { + outline: none; + border-color: var(--accent); +} +.settings .acct-msg { + min-height: 0; +} +.settings .acct-msg:empty { + margin: 0; +} From 7ef907898b496795443a4ef606098db63a446ca4 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Mon, 13 Jul 2026 09:18:14 +0200 Subject: [PATCH 2/4] review fixes: atomic persist, keep private URLs off disk, tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 would default to the + // first option and the next save would silently overwrite it. + const choices = EXPIRY_OPTIONS.some( + (o) => o.secs === current.sign_expires_secs + ) + ? EXPIRY_OPTIONS + : [ + { label: expiryLabel(current.sign_expires_secs), secs: current.sign_expires_secs }, + ...EXPIRY_OPTIONS, + ]; + const opts = choices + .map( + (o) => + `` + ) + .join(""); settings.innerHTML = `