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