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:
@@ -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!())
|
||||
|
||||
Reference in New Issue
Block a user