Skip to content
Open
63 changes: 43 additions & 20 deletions apps/mobile/src/features/files/ZipArchiveView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ARCHIVE_ENTRY_MAX_BYTES,
ARCHIVE_MAX_BYTES,
boundedZip,
rangedZip,
} from "@t3tools/client-runtime/zip/bounded-zip";
import type { EnvironmentId } from "@t3tools/contracts";
import { filePreviewDelimiter, parseDelimitedPreview } from "@t3tools/shared/delimitedPreview";
Expand Down Expand Up @@ -132,36 +133,51 @@ async function readArchiveBody(
return bytes.buffer;
}

async function openArchive(bytes: ArrayBuffer): Promise<ZipArchive> {
let files: Awaited<ReturnType<typeof boundedZip>>;
try {
files = await boundedZip(bytes);
} catch (cause) {
throw new ZipFailureError({
failure: {
title: "Couldn't browse this ZIP",
detail: `${errorMessage(cause, "It may be incomplete or damaged.")} ${SAVE_ZIP_HINT}`,
retry: false,
},
});
}
function toArchive(
files: ReadonlyMap<string, { readonly size: number; readonly directory: boolean }>,
read: (path: string) => Promise<Uint8Array<ArrayBuffer>>,
): ZipArchive {
const entries = [...files].map(([path, file]): ZipEntry => {
const name = path.split("/").findLast(Boolean) ?? path;
const dot = name.lastIndexOf(".");
return {
path,
name,
isDirectory: file.directory,
sizeBytes: file.bytes.byteLength,
sizeBytes: file.size,
mimeType:
(dot > 0 ? hostPreviewMimeTypeFromExtension(name.slice(dot).toLowerCase()) : null) ??
"application/octet-stream",
bytes: file.bytes,
read: () => read(path),
};
});
return { entries, fileCount: entries.filter((entry) => !entry.isDirectory).length };
}

async function openArchive(bytes: ArrayBuffer): Promise<ZipArchive> {
let files: Awaited<ReturnType<typeof boundedZip>>;
try {
files = await boundedZip(bytes);
} catch (cause) {
throw new ZipFailureError({
failure: {
title: "Couldn't browse this ZIP",
detail: `${errorMessage(cause, "It may be incomplete or damaged.")} ${SAVE_ZIP_HINT}`,
retry: false,
},
});
}
return toArchive(
new Map(
[...files].map(([path, file]) => [
path,
{ size: file.bytes.byteLength, directory: file.directory },
]),
),
async (path) => files.get(path)!.bytes,
);
}

type ArchiveState =
| { readonly status: "loading"; readonly receivedBytes: number; readonly timeLeft: string | null }
| { readonly status: "ready"; readonly archive: ZipArchive }
Expand Down Expand Up @@ -199,6 +215,13 @@ function useZipArchive(input: {
return openArchive(await file.arrayBuffer());
}
const url = (await refresh()) ?? uri;
// Archives the range reader can't list are downloaded and read whole below.
const ranged = await rangedZip(url, fetch, ARCHIVE_MAX_BYTES, abort.signal).catch(() => null);
// Asset URLs expire, so each member read re-mints one; the revision check still applies.
if (ranged)
return toArchive(ranged.entries, async (path) =>
ranged.read(path, (await refresh().catch(() => null)) ?? url),
);
const response = await fetch(url, { signal: abort.signal });
if (!response.ok || !response.body) throw new ZipFailureError({ failure: NETWORK });
const bytes = await readArchiveBody(response.body, (receivedBytes) =>
Expand Down Expand Up @@ -558,7 +581,7 @@ async function addZipEntryToDraft(input: {
}) {
const { entry, signal } = input;
const file = await writeGeneratedAttachmentFile({
bytes: entry.bytes,
bytes: await entry.read(),
attachment: { name: entry.name, mimeType: entry.mimeType },
});
try {
Expand Down Expand Up @@ -628,9 +651,9 @@ function ZipEntryView(props: {
const controller = new AbortController();
let release: (() => void) | undefined;
void (async (): Promise<EntryContent> => {
if (isText) return { status: "text", ...decodeFilePreviewText(entry.bytes) };
if (isText) return { status: "text", ...decodeFilePreviewText(await entry.read()) };
const file = await writeGeneratedAttachmentFile({
bytes: entry.bytes,
bytes: await entry.read(),
attachment: { name: entry.name, mimeType },
});
if (controller.signal.aborted) {
Expand Down Expand Up @@ -679,9 +702,9 @@ function ZipEntryView(props: {
);
};
const save = () =>
run("save", (signal) =>
run("save", async (signal) =>
shareGeneratedAttachment({
bytes: entry.bytes,
bytes: await entry.read(),
attachment: { name: entry.name, mimeType },
signal,
}),
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/files/zipTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export interface ZipEntry {
readonly isDirectory: boolean;
readonly sizeBytes: number;
readonly mimeType: string;
readonly bytes: Uint8Array<ArrayBuffer>;
readonly read: () => Promise<Uint8Array<ArrayBuffer>>;
}

export interface ZipTreeNode {
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ describe("browser API CORS", () => {
expect(allowedHeaders.has(ORCHESTRATION_PROTOCOL_HEADER)).toBe(true);
expect(allowedHeaders.has("authorization")).toBe(true);
expect(allowedHeaders.has("dpop")).toBe(true);

// Range readers validate offsets and revision from these response headers.
const asset = await handler(
new Request("https://backend.example/api/environment", {
headers: { origin: "https://app.t3.codes" },
}),
);
expect(asset.headers.get("access-control-expose-headers")).toBe("content-range,etag");
} finally {
await dispose();
}
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ export const layerBrowserApiCors = Layer.unwrap(
: {}),
allowedMethods: browserApiCorsAllowedMethods,
allowedHeaders: browserApiCorsAllowedHeaders,
// Cross-origin previews validate byte ranges against the size and revision.
exposedHeaders: ["content-range", "etag"],
maxAge: 600,
});
return HttpRouter.middleware(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,13 +156,9 @@ describe("bounded binary previews", () => {
worker.addEventListener("error", reject, { once: true });
worker.postMessage(input, []);
});
const src = URL.createObjectURL(new Blob([bytes]));
try {
const result = await request({
type: "open",
kind: "archive",
name: "pax.tar",
bytes: bytes.buffer,
});
const result = await request({ type: "open", kind: "archive", name: "pax.tar", src });
expect(result.type).toBe("archive");
if (result.type !== "archive") throw new Error("Expected archive");
expect(result.entries.map((entry) => [entry.path, entry.size, entry.modified])).toEqual([
Expand Down Expand Up @@ -208,12 +204,14 @@ describe("bounded binary previews", () => {
new Uint8Array(1024),
]).arrayBuffer(),
);
const skippedSrc = URL.createObjectURL(new Blob([unresolved]));
const skipped = await request({
type: "open",
kind: "archive",
name: "links.tar",
bytes: unresolved.buffer,
src: skippedSrc,
});
URL.revokeObjectURL(skippedSrc);
if (skipped.type !== "archive") throw new Error("Expected archive");
expect(skipped.entries.map((entry) => entry.path)).toEqual([
"later.txt",
Expand All @@ -226,6 +224,7 @@ describe("bounded binary previews", () => {
});
} finally {
worker.terminate();
URL.revokeObjectURL(src);
}
});

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/files/AttachmentFilePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ export function AttachmentFilePreview(props: {
name={props.name}
kind={kind}
onRetry={() => setRevision((value) => value + 1)}
refreshSrc={refresh}
/>
) : kind === "font" ? (
<Suspense fallback={<FileSurfaceLoading />}>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/files/BinaryFilePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export function BinaryFilePreview(props: {
readonly name: string;
readonly kind: "archive" | "sqlite";
readonly onRetry?: () => void;
readonly refreshSrc?: () => Promise<string | null>;
}) {
return (
<Suspense fallback={<FileSurfaceLoading />}>
Expand Down
28 changes: 13 additions & 15 deletions apps/web/src/components/files/BinaryViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import {
type BinaryWorkerResult,
} from "./binaryPreviewProtocol";
import { PreviewImage } from "./PreviewImage";
import { readBinaryPreview } from "./readBinaryPreview";

const SourcePreview = lazy(() => import("./ReadOnlySourcePreview"));
type Archive = Extract<BinaryWorkerResult, { type: "archive" }>;
Expand Down Expand Up @@ -85,8 +84,9 @@ export default function BinaryViewer(props: {
readonly name: string;
readonly kind: "archive" | "sqlite";
readonly onRetry?: () => void;
/** Re-mints an expiring asset URL; ZIP members are fetched from it after the archive opens. */
readonly refreshSrc?: () => Promise<string | null>;
}) {
const [sizeBytes, setSizeBytes] = useState<number | null>(null);
const [archive, setArchive] = useState<Archive | null>(null);
const [database, setDatabase] = useState<Database | null>(null);
const [rows, setRows] = useState<Rows | null>(null);
Expand All @@ -97,6 +97,7 @@ export default function BinaryViewer(props: {
const [page, setPage] = useState(0);
const [busy, setBusy] = useState(true);
const [error, setError] = useState<string | null>(null);
const sizeBytes = archive?.sizeBytes ?? database?.sizeBytes ?? null;
const workerRef = useRef<Worker | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const send = (request: BinaryWorkerRequest) => {
Expand All @@ -108,7 +109,7 @@ export default function BinaryViewer(props: {
setBusy(false);
workerRef.current?.terminate();
workerRef.current = null;
}, 15_000);
}, 30_000);
workerRef.current?.postMessage(request);
};
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the binary fetch, archive or database worker, message subscription, and request timeout for this file.
Expand Down Expand Up @@ -152,16 +153,7 @@ export default function BinaryViewer(props: {
}
}
});
void readBinaryPreview(props.src, controller.signal)
.then((bytes) => {
if (!controller.signal.aborted) {
setSizeBytes(bytes.byteLength);
worker.postMessage({ type: "open", name: props.name, kind: props.kind, bytes }, [bytes]);
}
})
.catch((cause: unknown) =>
fail(cause instanceof Error ? cause.message : "Could not read this file."),
);
worker.postMessage({ type: "open", name: props.name, kind: props.kind, src: props.src }, []);
return () => {
controller.abort();
worker.terminate();
Expand Down Expand Up @@ -283,7 +275,7 @@ export default function BinaryViewer(props: {
) : busy ? (
<FileSurfaceLoading />
) : selectedPath ? (
entry ? (
entry?.path === selectedPath ? (
<ArchiveFile key={entry.path} path={entry.path} bytes={entry.bytes} />
) : null
) : archive ? (
Expand Down Expand Up @@ -325,7 +317,13 @@ export default function BinaryViewer(props: {
else {
setSelectedPath(item.path);
setEntry(null);
send({ type: "entry", path: item.path });
// Hide the list now so a second entry can't be opened mid-refresh.
setBusy(true);
void (props.refreshSrc?.() ?? Promise.resolve(null))
.catch(() => null)
.then((src) =>
send({ type: "entry", path: item.path, ...(src ? { src } : {}) }),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
}}
>
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/files/binaryPreview.worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ it.each([ARCHIVE_ENTRY_MAX_BYTES, ARCHIVE_ENTRY_MAX_BYTES + 1])(
"self",
Object.assign(worker, { postMessage: (result: BinaryWorkerResult) => receive(result) }),
);
vi.stubGlobal("fetch", async () => new Response(bytes));
try {
vi.resetModules();
await import("./binaryPreview.worker");
Expand All @@ -63,9 +64,10 @@ it.each([ARCHIVE_ENTRY_MAX_BYTES, ARCHIVE_ENTRY_MAX_BYTES + 1])(
});
using copies = vi.spyOn(Uint8Array.prototype, "slice");
expect(
await request({ type: "open", kind: "archive", name: "mixed.tar", bytes: bytes.buffer }),
await request({ type: "open", kind: "archive", name: "mixed.tar", src: "mixed.tar" }),
).toEqual({
type: "archive",
sizeBytes: bytes.length,
entries: entries.map((entry) => ({
path: entry.path,
size: contents(entry).length,
Expand Down
Loading
Loading