diff --git a/.vite/vitest/results.json b/.vite/vitest/results.json new file mode 100644 index 0000000..fa16518 --- /dev/null +++ b/.vite/vitest/results.json @@ -0,0 +1 @@ +{"version":"2.1.9","results":[[":crates/tauri-app/src/lib/api/client.test.ts",{"duration":31.70154099999999,"failed":false}],[":crates/tauri-app/src/lib/utils/localstorage.test.ts",{"duration":6.449833000000012,"failed":false}]]} \ No newline at end of file diff --git a/crates/pds-server/tests/blob_integration.rs b/crates/pds-server/tests/blob_integration.rs index b8120ba..bff6921 100644 --- a/crates/pds-server/tests/blob_integration.rs +++ b/crates/pds-server/tests/blob_integration.rs @@ -402,3 +402,186 @@ async fn get_blob_returns_detected_mime_type() { "getBlob should serve the stored mime type, not the octet-stream default" ); } + +// -- Phase 8: full upload+get round-trip + cross-DID security ---------------- + +/// End-to-end round-trip: +/// 1. User A uploads a small JPEG-shaped payload. +/// 2. The `com.atproto.uploadBlob` response carries the correct +/// CID, size, and MIME type. +/// 3. `com.atproto.sync.getBlob?did=A&cid=...` returns the same +/// bytes with the stored MIME type. +/// +/// This is the test the Tauri `pickAndUploadImage` / +/// `EmbedImage` flow depends on — it pins the wire shape end to +/// end so changes to either side show up here before they reach +/// the desktop client. +#[tokio::test] +async fn upload_then_get_blob_round_trip() { + if !wait_for_pds().await { + eprintln!("pds not running, skipping"); + return; + } + let (c, did, jwt) = fresh_user("rtrip").await; + seed_any_record(&c, &did, &jwt).await; + + // Use a tiny JPEG-shaped payload (SOI/EOI markers) so the + // magic-byte sniffer classifies it as `image/jpeg` server-side + // if the upload header is stripped — we always send + // `Content-Type: image/jpeg` here, so it doesn't matter, but + // it's the closest "real image" we can fit in a few bytes. + let payload: Vec = vec![0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]; + let expected_cid = blob_cid(&payload).to_string(); + + let up: Value = c + .post(format!("{}/xrpc/com.atproto.uploadBlob", PDS_URL)) + .bearer_auth(&jwt) + .header("Content-Type", "image/jpeg") + .body(payload.clone()) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + let returned_cid = up["blob"]["ref"]["$link"].as_str().unwrap(); + let returned_size = up["blob"]["size"].as_u64().unwrap(); + let returned_mime = up["blob"]["mimeType"].as_str().unwrap(); + assert_eq!(returned_cid, expected_cid, "upload CID mismatch"); + assert_eq!(returned_size, payload.len() as u64); + assert_eq!(returned_mime, "image/jpeg"); + + let get = c + .get(format!( + "{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}", + PDS_URL, did, returned_cid + )) + .send() + .await + .unwrap(); + assert_eq!(get.status().as_u16(), 200); + let ct = get + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let bytes = get.bytes().await.unwrap(); + assert_eq!(bytes.as_ref(), payload.as_slice()); + assert_eq!(ct, "image/jpeg"); +} + +/// Cross-DID isolation check. +/// +/// Setup: +/// * User A uploads a blob (gets a row at `(did_A, cid)` in +/// `repo_blocks`). +/// * User B (an unrelated account) uploads a different blob +/// (gets a row at `(did_B, cid_B)`). +/// +/// Security property under test: User B must not be able to read +/// User A's blob by requesting `did=A&cid=cid_A`. The PDS keys +/// `repo_blocks` by `(did, cid)`, so the row is simply not visible +/// to User B's query — the endpoint should respond with a +/// `RepoNotFound` (because User A has rows but B's request with +/// `did=B` resolves to B's own repo) or `BlobNotFound` (if A had +/// zero rows), both of which are 400-class errors. The exact code +/// depends on which DID we send: sending `did=A` returns the blob +/// (the API is unauthenticated by spec), sending `did=B` against +/// A's CID returns 400. The test verifies the latter — that +/// *cross-DID CID guessing* doesn't work. +#[tokio::test] +async fn get_blob_returns_404_for_cross_did_cid_lookup() { + if !wait_for_pds().await { + eprintln!("pds not running, skipping"); + return; + } + + // User A: uploads one blob. + let (ca, did_a, jwt_a) = fresh_user("isoa").await; + seed_any_record(&ca, &did_a, &jwt_a).await; + let payload_a: Vec = b"alice's private blob \xff\xd8\xff\xd9".to_vec(); + let up_a: Value = ca + .post(format!("{}/xrpc/com.atproto.uploadBlob", PDS_URL)) + .bearer_auth(&jwt_a) + .header("Content-Type", "image/jpeg") + .body(payload_a.clone()) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let cid_a = up_a["blob"]["ref"]["$link"].as_str().unwrap().to_string(); + + // User B: completely separate account. We only need B to have + // at least one row so the `repo_blocks` "is the repo empty?" + // gate doesn't trip on B's behalf — but the gate checks the + // DID in the query string, so as long as B exists B's DID + // passes its own gate. We seed a post anyway to mirror the + // happy-path precondition. + let (cb, did_b, jwt_b) = fresh_user("isob").await; + seed_any_record(&cb, &did_b, &jwt_b).await; + + // 1. User B's own getBlob request for their DID works (sanity). + let own = cb + .get(format!( + "{}/xrpc/com.atproto.sync.getBlob?did={}&cid=not-a-cid", + PDS_URL, did_b + )) + .send() + .await + .unwrap(); + // The CID is invalid, but the `did` gate must pass because B + // has rows. We expect a 4xx, not a 5xx. + assert!( + (400..500).contains(&own.status().as_u16()), + "B's own bad-cid request should return 4xx, got {}", + own.status() + ); + + // 2. User B cannot read User A's blob by sending `did=B&cid=cid_A`. + // `(did_B, cid_A)` is not a row in `repo_blocks`, so the lookup + // returns no bytes and the PDS responds with `BlobNotFound`. + let cross = cb + .get(format!( + "{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}", + PDS_URL, did_b, cid_a + )) + .send() + .await + .unwrap(); + assert!( + !cross.status().is_success(), + "cross-DID getBlob must NOT succeed; got {}", + cross.status() + ); + let cross_body: Value = cross.json().await.unwrap(); + // The exact error is `BlobNotFound` (because `(did_B, cid_A)` + // is absent from `repo_blocks`). We accept any 4xx with a + // JSON envelope so the test isn't tightly coupled to the + // error code — but we log it for visibility. + let cross_err = cross_body["error"].as_str().unwrap_or(""); + assert!( + cross_err == "BlobNotFound" || cross_err == "InvalidRequest", + "expected BlobNotFound/InvalidRequest for cross-DID access, got {cross_err}" + ); + + // 3. Sanity check: User A's own getBlob for their own CID still + // returns the bytes (the test would be misleading if we + // accidentally broke the happy path). + let alice_own = ca + .get(format!( + "{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}", + PDS_URL, did_a, cid_a + )) + .send() + .await + .unwrap(); + assert_eq!(alice_own.status().as_u16(), 200); + assert_eq!( + alice_own.bytes().await.unwrap().as_ref(), + payload_a.as_slice() + ); +} diff --git a/crates/tauri-app/src-tauri/src/lib.rs b/crates/tauri-app/src-tauri/src/lib.rs index c4bed31..1b438d5 100644 --- a/crates/tauri-app/src-tauri/src/lib.rs +++ b/crates/tauri-app/src-tauri/src/lib.rs @@ -112,15 +112,24 @@ async fn current_session(state: tauri::State<'_, AppState>) -> Result, text: String, + embed: Option, ) -> Result { 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, 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!()) diff --git a/crates/tauri-app/src-tauri/src/pds_client.rs b/crates/tauri-app/src-tauri/src/pds_client.rs index 4dabe1a..dadee8f 100644 --- a/crates/tauri-app/src-tauri/src/pds_client.rs +++ b/crates/tauri-app/src-tauri/src/pds_client.rs @@ -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, + content_type: &str, + jwt: &str, + ) -> Result { + 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::().await?) + } +} + +/// `com.atproto.uploadBlob` response. Spec'd at +/// . 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, } diff --git a/crates/tauri-app/src/lib/api/client.test.ts b/crates/tauri-app/src/lib/api/client.test.ts new file mode 100644 index 0000000..d4f9c6f --- /dev/null +++ b/crates/tauri-app/src/lib/api/client.test.ts @@ -0,0 +1,192 @@ +// Unit tests for the image-blob fetch pipeline in `client.ts`. +// We mock `@tauri-apps/api/core` so we don't need a running Tauri +// shell for the test, and we mock `URL.createObjectURL` / +// `URL.revokeObjectURL` so the test doesn't depend on a browser +// implementation (vitest's jsdom doesn't always wire these up). +// +// The tests cover: +// * the happy path — a successful `fetch_blob` invoke produces an +// object URL and the cache is populated so a second call is a no-op; +// * the cached path — a second `fetchBlob` for the same CID does not +// call `invoke` again; +// * the empty-blob error path — an empty payload throws; +// * `releaseBlob` / `clearBlobCache` keep the cache honest. +// +// Run with: +// npm test +// (or `npx vitest run src/lib/api/client.test.ts`) + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const invokeMock = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})); + +// Capture object URLs so the test can assert the cache is wired up. +// `createObjectURL` returns a fresh URL per call; tests should rely +// on the spy to know what URL was returned. +const createdUrls: string[] = []; +let _urlCounter = 0; + +beforeEach(() => { + invokeMock.mockReset(); + createdUrls.length = 0; + _urlCounter = 0; + + // `URL.createObjectURL` exists in jsdom (vitest default) but + // returns the empty string — stub it so the test sees real-ish + // URLs and we can spot leaks. + vi.stubGlobal( + "URL", + class { + static createObjectURL(_blob: Blob): string { + const u = `blob:mock/${++_urlCounter}`; + createdUrls.push(u); + return u; + } + static revokeObjectURL(url: string): void { + const i = createdUrls.indexOf(url); + if (i >= 0) createdUrls.splice(i, 1); + } + }, + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("fetchBlob", () => { + it("invokes the fetch_blob Tauri command and returns an object URL", async () => { + const { fetchBlob, clearBlobCache } = await import("./client"); + clearBlobCache(); + + const cid = "bafyreifake"; + const fakeBytes = new Uint8Array([1, 2, 3, 4]); + invokeMock.mockResolvedValueOnce(Array.from(fakeBytes)); + + const url = await fetchBlob("did:plc:alice", cid); + + expect(url).toMatch(/^blob:mock\/\d+$/); + expect(createdUrls).toContain(url); + expect(invokeMock).toHaveBeenCalledTimes(1); + expect(invokeMock).toHaveBeenCalledWith("fetch_blob", { + did: "did:plc:alice", + cid, + }); + }); + + it("caches the object URL — second call for the same cid is a no-op", async () => { + const { fetchBlob, clearBlobCache } = await import("./client"); + clearBlobCache(); + + const cid = "bafyreicached"; + invokeMock.mockResolvedValueOnce(Array.from(new Uint8Array([9, 9, 9]))); + + const first = await fetchBlob("did:plc:bob", cid); + const second = await fetchBlob("did:plc:bob", cid); + + expect(second).toBe(first); + // Only the first call should have round-tripped through the + // Tauri shell; the cache hit short-circuits everything else. + expect(invokeMock).toHaveBeenCalledTimes(1); + }); + + it("rejects on an empty payload from the Tauri shell", async () => { + const { fetchBlob, clearBlobCache } = await import("./client"); + clearBlobCache(); + + invokeMock.mockResolvedValueOnce([]); + await expect( + fetchBlob("did:plc:carol", "bafyreifake"), + ).rejects.toThrow(/empty blob/); + }); + + it("propagates a Tauri-side error verbatim", async () => { + const { fetchBlob, clearBlobCache } = await import("./client"); + clearBlobCache(); + + invokeMock.mockRejectedValueOnce(new Error("sync.getBlob failed: 400")); + await expect( + fetchBlob("did:plc:dave", "bafyreifake"), + ).rejects.toThrow(/sync\.getBlob failed: 400/); + }); +}); + +describe("releaseBlob / clearBlobCache", () => { + it("drops the cached URL on releaseBlob", async () => { + const { fetchBlob, releaseBlob, clearBlobCache } = await import("./client"); + clearBlobCache(); + + invokeMock.mockResolvedValueOnce(Array.from(new Uint8Array([1]))); + const cid = "bafyreirel"; + const url = await fetchBlob("did:plc:e", cid); + expect(createdUrls).toContain(url); + + releaseBlob("did:plc:e", cid); + expect(createdUrls).not.toContain(url); + }); + + it("clearBlobCache evicts every cached URL", async () => { + const { fetchBlob, clearBlobCache } = await import("./client"); + clearBlobCache(); + + invokeMock.mockResolvedValueOnce(Array.from(new Uint8Array([1]))); + invokeMock.mockResolvedValueOnce(Array.from(new Uint8Array([2]))); + + await fetchBlob("did:plc:f", "bafyreia"); + await fetchBlob("did:plc:f", "bafyreib"); + expect(createdUrls).toHaveLength(2); + + clearBlobCache(); + expect(createdUrls).toHaveLength(0); + }); +}); + +describe("makeImagesEmbed", () => { + it("wraps a blob reference in an app.bsky.embed.images embed", async () => { + const { makeImagesEmbed } = await import("./client"); + const blob = { cid: "bafyreicid", mimeType: "image/png", size: 42 }; + const e = makeImagesEmbed(blob) as any; + expect(e.$type).toBe("app.bsky.embed.images"); + expect(e.images).toHaveLength(1); + expect(e.images[0].image.$type).toBe("blob"); + expect(e.images[0].image.ref.$link).toBe("bafyreicid"); + expect(e.images[0].image.mimeType).toBe("image/png"); + expect(e.images[0].image.size).toBe(42); + }); +}); + +describe("pickAndUploadImage", () => { + it("returns null when the user cancels the picker", async () => { + const { pickAndUploadImage } = await import("./client"); + invokeMock.mockResolvedValueOnce(null); + const r = await pickAndUploadImage(); + expect(r).toBeNull(); + // `invoke` is called with the command name and an empty + // argument bag (the Tauri runtime serialises the JS-side + // options, and `pick_and_upload_image` takes none). + expect(invokeMock).toHaveBeenCalledTimes(1); + expect(invokeMock.mock.calls[0][0]).toBe("pick_and_upload_image"); + }); + + it("returns the parsed {cid, mimeType, size} on success", async () => { + const { pickAndUploadImage } = await import("./client"); + invokeMock.mockResolvedValueOnce({ + blob: { + $type: "blob", + ref: { $link: "bafyblob" }, + mimeType: "image/jpeg", + size: 1234, + }, + }); + const r = await pickAndUploadImage(); + expect(r).toEqual({ + cid: "bafyblob", + mimeType: "image/jpeg", + size: 1234, + }); + }); +}); \ No newline at end of file diff --git a/crates/tauri-app/src/lib/api/client.ts b/crates/tauri-app/src/lib/api/client.ts index 6d50ade..d5b2da9 100644 --- a/crates/tauri-app/src/lib/api/client.ts +++ b/crates/tauri-app/src/lib/api/client.ts @@ -135,11 +135,71 @@ export type ThreadResponse = { repost_count?: number; }; -export async function createPost(text: string): Promise { +export async function createPost( + text: string, + embed?: unknown | null, +): Promise { // The Rust post_create command returns a different shape (uri+cid // only), but we keep the call simple: it gives us the cid we need // to show the "ok" toast. - return await invoke("post_create", { text }); + // `embed` is forwarded verbatim; the caller is responsible for + // shaping it as an `app.bsky.embed.images` / `.external` / etc. + // record. Pass `null` or `undefined` to omit. + return await invoke("post_create", { + text, + embed: embed ?? null, + }); +} + +/// `com.atproto.uploadBlob` wrapped with a file picker. Opens a +/// native OS dialog (via `tauri-plugin-dialog`), uploads the chosen +/// file to the user's PDS, and returns the parsed blob reference. +/// +/// Returns `null` when the user cancels the picker. The Tauri shell +/// enforces a 1 MiB cap (matching the PDS's `MAX_BLOB_SIZE`) and +/// surfaces other errors via the rejected promise. +export async function pickAndUploadImage(): Promise<{ + cid: string; + mimeType: string; + size: number; +} | null> { + const r = await invoke<{ + blob: { + $type: string; + ref: { $link: string }; + mimeType: string; + size: number; + }; + } | null>("pick_and_upload_image"); + if (!r) return null; + return { + cid: r.blob.ref.$link, + mimeType: r.blob.mimeType, + size: r.blob.size, + }; +} + +/// Helper that builds the `app.bsky.embed.images` embed for a single +/// image blob, ready to drop into a post record. +export function makeImagesEmbed(blob: { + cid: string; + mimeType: string; + size: number; +}): Record { + return { + $type: "app.bsky.embed.images", + images: [ + { + alt: "", + image: { + $type: "blob", + ref: { $link: blob.cid }, + mimeType: blob.mimeType, + size: blob.size, + }, + }, + ], + }; } export async function describeServer(): Promise { @@ -294,23 +354,31 @@ export async function listenNotification( // frontend never has to know the PDS URL. // // We cache the *resolved object URL* (not the raw bytes) keyed -// by CID, because: -// * the blob bytes are addressed by content hash, so the same -// CID always resolves to the same bytes regardless of DID; +// by `(did, cid)`, NOT just `cid`: +// * the blob *bytes* are addressed by content hash, so the same +// CID *usually* resolves to the same bytes regardless of DID; +// * BUT in the atproto sync spec, `com.atproto.sync.getBlob` is +// intentionally unauthenticated and keyed by `(did, cid)` on +// the server. A future change to a per-DID access control +// model would make the bytes differ per DID — caching by CID +// alone would then leak the first responder's bytes to every +// subsequent viewer. // * the `` element takes an object URL, not raw bytes, so // handing the URL straight back to the caller saves a // Blob/URL.createObjectURL call per render. const _blobUrlCache = new Map(); +const _blobKey = (did: string, cid: string) => `${did}/${cid}`; /// Fetch the raw blob bytes for `cid` and return an object URL /// suitable for ``. Caches the URL in-process so /// navigating the timeline doesn't re-download already-seen -/// images. +/// images. Keyed by `(did, cid)` for the security reason above. export async function fetchBlob( did: string, cid: string, ): Promise { - const cached = _blobUrlCache.get(cid); + const key = _blobKey(did, cid); + const cached = _blobUrlCache.get(key); if (cached) return cached; const bytes: number[] = await invoke("fetch_blob", { did, @@ -322,7 +390,7 @@ export async function fetchBlob( } const blob = new Blob([u8]); const url = URL.createObjectURL(blob); - _blobUrlCache.set(cid, url); + _blobUrlCache.set(key, url); return url; } @@ -331,11 +399,11 @@ export async function fetchBlob( /// underlying Blob. For a Tauri WebView with at most a few /// dozen visible images the OS cleans up anyway, but explicit /// revocation makes long sessions friendlier on memory. -export function releaseBlob(cid: string): void { - const url = _blobUrlCache.get(cid); +export function releaseBlob(did: string, cid: string): void { + const url = _blobUrlCache.get(_blobKey(did, cid)); if (url) { URL.revokeObjectURL(url); - _blobUrlCache.delete(cid); + _blobUrlCache.delete(_blobKey(did, cid)); } } diff --git a/crates/tauri-app/src/lib/components/ComposeBox.svelte b/crates/tauri-app/src/lib/components/ComposeBox.svelte index 39857ca..a8d93f6 100644 --- a/crates/tauri-app/src/lib/components/ComposeBox.svelte +++ b/crates/tauri-app/src/lib/components/ComposeBox.svelte @@ -1,12 +1,41 @@