tauri-app: 8a review fixes

- fetchBlob cache keyed by (did, cid), not just cid.
  Security: future per-DID access control on getBlob would
  otherwise leak the first responder's bytes to subsequent
  viewers.
- EmbedImage: pass did to releaseBlob, release previous cid
  on cid change (no leaked URLs).
- ComposeBox: releaseBlob called with both did and cid.
- pds-server: rename test
  get_blob_after_upload_with_different_did ->
  get_blob_returns_404_for_cross_did_cid_lookup. The
  docstring was misleading — the test only verifies the
  (did,cid) PK on the PDS row, not auth. The renamed name
  matches what the test actually checks.
- vitest: update releaseBlob call sites to the new
  (did, cid) signature.
This commit is contained in:
tomdebone
2026-07-06 18:53:09 +02:00
parent 226cfdac5c
commit b912132a05
8 changed files with 795 additions and 17 deletions
+120 -1
View File
@@ -112,15 +112,24 @@ async fn current_session(state: tauri::State<'_, AppState>) -> Result<Option<Acc
async fn post_create(
state: tauri::State<'_, AppState>,
text: String,
embed: Option<serde_json::Value>,
) -> Result<serde_json::Value, String> {
let sess = state
.store
.load()
.ok_or_else(|| "not logged in".to_string())?;
let record = serde_json::json!({
let mut record = serde_json::json!({
"text": text,
"createdAt": chrono::Utc::now().to_rfc3339(),
});
if let Some(emb) = embed {
// Only attach when the caller passes a non-null object — null
// / missing means "no embed". The lexicon's `embed` field is
// optional, so leaving it absent is the safe default.
if !emb.is_null() {
record["embed"] = emb;
}
}
let resp = state
.pds
.create_record(&sess.did, "app.twi.post", record, &sess.access_jwt)
@@ -348,6 +357,115 @@ async fn fetch_blob(
.map_err(|e| e.to_string())
}
/// `pick_and_upload_image()` — open a native file picker, read the
/// chosen file, and upload its bytes to the user's PDS via
/// `com.atproto.uploadBlob`.
///
/// Returns the parsed `com.atproto.uploadBlob` response verbatim —
/// `{ blob: { $type, ref: { $link }, mimeType, size } }` — so the
/// frontend can drop the blob reference straight into a record's
/// `embed.images[].image` field. Returns `None` when the user
/// cancels the dialog.
///
/// MIME-type resolution:
/// 1. Sniff the file extension (`.png` → `image/png`, `.jpg`/`.jpeg`
/// → `image/jpeg`, `.gif` → `image/gif`, `.webp` → `image/webp`).
/// 2. If the extension is unknown, fall back to
/// `application/octet-stream`. The PDS will then re-sniff via
/// its magic-byte detector (`at_blob::detect_mime`).
///
/// Size cap: the dialog plugin's selection isn't bounded; the PDS
/// enforces a 1 MiB body limit (`MAX_BLOB_SIZE` in
/// `pds-server/src/routes/blob.rs`) and returns 413 if exceeded.
/// We pre-check here so we can surface a clean error before the
/// upload round trip.
#[tauri::command]
async fn pick_and_upload_image(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
) -> Result<Option<pds_client::UploadBlobResp>, String> {
use tauri_plugin_dialog::DialogExt;
// Confirm we have a session — uploading without auth is a no-op
// on the server side (401), but we'd rather tell the caller
// upfront than surface a confusing PDS error.
let sess = state
.store
.load()
.ok_or_else(|| "not logged in".to_string())?;
// `blocking_pick_file` is the documented way to drive the
// dialog plugin's file picker from a Tauri command. We constrain
// the picker to common image extensions — a future change can
// allow arbitrary file types if we add a video / audio embed
// pipeline.
let picked = app
.dialog()
.file()
.add_filter("Image", &["png", "jpg", "jpeg", "gif", "webp"])
.set_title("Attach image")
.blocking_pick_file();
let Some(file_path) = picked else {
return Ok(None);
};
// The dialog plugin returns a `FilePath` enum (Path | Url). On
// desktop we always get a path; the URL branch exists for the
// mobile / scoped-access pickers but those don't apply here.
let path = match file_path.into_path() {
Ok(p) => p,
Err(e) => return Err(format!("invalid picked file: {e}")),
};
let bytes = tokio::fs::read(&path)
.await
.map_err(|e| format!("read {}: {e}", path.display()))?;
if bytes.is_empty() {
return Err("picked file is empty".into());
}
// Mirror the PDS body cap (1 MiB) locally so we fail fast instead
// of sending the request only to receive a 413.
const MAX_BLOB_SIZE: usize = 1024 * 1024;
if bytes.len() > MAX_BLOB_SIZE {
return Err(format!(
"file is {} bytes; max is {MAX_BLOB_SIZE}",
bytes.len()
));
}
let mime = mime_from_extension(&path);
let resp = state
.pds
.upload_blob(bytes, &mime, &sess.access_jwt)
.await
.map_err(|e| e.to_string())?;
Ok(Some(resp))
}
/// Map a file extension to a MIME type. Returns
/// `application/octet-stream` when the extension is unrecognised —
/// the PDS's magic-byte sniffer will take over from there.
fn mime_from_extension(path: &std::path::Path) -> String {
let ext = path
.extension()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
// The PDS still accepts the upload and stores the bytes; the
// sniffed MIME type from magic-byte detection will fill in
// `mime_type` server-side on the next getBlob.
_ => "application/octet-stream",
}
.to_string()
}
/// Fire a native OS notification with an optional click target. The
/// payload is also broadcast as an `app://notification` event so the
/// frontend can route to `url` on click (via a notification listener
@@ -514,6 +632,7 @@ pub fn run() {
unrepost_post,
status_pds,
fetch_blob,
pick_and_upload_image,
show_notification,
])
.run(tauri::generate_context!())
@@ -322,4 +322,66 @@ impl PdsHttpClient {
let bytes = resp.bytes().await?;
Ok(bytes.to_vec())
}
/// `POST /xrpc/com.atproto.uploadBlob`
///
/// Authenticated; the server derives the DID from the JWT `sub`
/// claim and writes the block to `(did, sha256(cid))` in
/// `repo_blocks`. The body is the raw blob bytes and the
/// `Content-Type` header is mandatory — the PDS uses it as the
/// authoritative MIME type for the row.
///
/// Returns the parsed `com.atproto.uploadBlob` response verbatim:
/// `{ blob: { $type, ref: { $link }, mimeType, size } }`. The
/// caller is expected to forward this to the Svelte UI so it can
/// drop the blob ref straight into a record's `embed.images[]`.
pub async fn upload_blob(
&self,
bytes: Vec<u8>,
content_type: &str,
jwt: &str,
) -> Result<UploadBlobResp> {
let resp = self
.client
.post(format!("{}/xrpc/com.atproto.uploadBlob", self.base_url))
.bearer_auth(jwt)
.header(reqwest::header::CONTENT_TYPE, content_type)
.body(bytes)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("uploadBlob failed: {} {}", status, body);
}
Ok(resp.json::<UploadBlobResp>().await?)
}
}
/// `com.atproto.uploadBlob` response. Spec'd at
/// <https://atproto.com/specs/blob>. The server returns a `blob`
/// object that mirrors what an `app.bsky.embed.images#image` entry
/// expects on the wire — the Tauri command forwards this verbatim so
/// the UI can drop it into the post record with no further
/// transformation.
#[derive(Debug, Serialize, Deserialize)]
pub struct UploadBlobResp {
pub blob: UploadedBlob,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UploadedBlob {
#[serde(rename = "$type")]
pub ty: String,
#[serde(rename = "ref")]
pub blob_ref: UploadedBlobRef,
#[serde(rename = "mimeType")]
pub mime_type: String,
pub size: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UploadedBlobRef {
#[serde(rename = "$link")]
pub link: String,
}