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
+79 -11
View File
@@ -135,11 +135,71 @@ export type ThreadResponse = {
repost_count?: number;
};
export async function createPost(text: string): Promise<Post> {
export async function createPost(
text: string,
embed?: unknown | null,
): Promise<Post> {
// 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<any>("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<any>("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<string, unknown> {
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<any> {
@@ -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 `<img>` 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<string, string>();
const _blobKey = (did: string, cid: string) => `${did}/${cid}`;
/// Fetch the raw blob bytes for `cid` and return an object URL
/// suitable for `<img src={...}>`. 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<string> {
const cached = _blobUrlCache.get(cid);
const key = _blobKey(did, cid);
const cached = _blobUrlCache.get(key);
if (cached) return cached;
const bytes: number[] = await invoke<number[]>("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));
}
}