358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
import { useMemo, useRef, useState } from "react";
|
|
import { downloadBlob, outputStem } from "../../archive/downloads";
|
|
import { errorMessage } from "../../archive/errors";
|
|
import { ARCHIVE_LIMITS, formatBytes } from "../../archive/limits";
|
|
import { previewEntry } from "../../archive/preview";
|
|
import {
|
|
createSafeSelectionZip,
|
|
inspectArchive,
|
|
reportJson,
|
|
} from "../../archive/service";
|
|
import type {
|
|
ArchiveDocument,
|
|
ArchiveEntryRecord,
|
|
PreviewResult,
|
|
ProgressUpdate,
|
|
} from "../../archive/types";
|
|
import { ArchiveSummaryView } from "./ArchiveSummaryView";
|
|
import { PreviewPane } from "./PreviewPane";
|
|
import { StatusMessage } from "./StatusMessage";
|
|
|
|
type EntryFilter = "all" | "safe" | "blocked";
|
|
|
|
export function InspectWorkspace() {
|
|
const [document, setDocument] = useState<ArchiveDocument>();
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [query, setQuery] = useState("");
|
|
const [filter, setFilter] = useState<EntryFilter>("all");
|
|
const [busy, setBusy] = useState(false);
|
|
const [status, setStatus] = useState<string>();
|
|
const [error, setError] = useState<string>();
|
|
const [activeEntry, setActiveEntry] = useState<ArchiveEntryRecord>();
|
|
const [preview, setPreview] = useState<PreviewResult>();
|
|
const [previewBusy, setPreviewBusy] = useState(false);
|
|
const [previewError, setPreviewError] = useState<string>();
|
|
const operation = useRef<AbortController | undefined>(undefined);
|
|
const generation = useRef(0);
|
|
|
|
const visible = useMemo(() => {
|
|
if (!document) return [];
|
|
const normalizedQuery = query.trim().toLocaleLowerCase("en-US");
|
|
return document.entries
|
|
.filter(
|
|
(entry) =>
|
|
!normalizedQuery ||
|
|
entry.path.toLocaleLowerCase("en-US").includes(normalizedQuery),
|
|
)
|
|
.filter(
|
|
(entry) =>
|
|
filter === "all" ||
|
|
(filter === "safe"
|
|
? entry.extractable
|
|
: !entry.extractable && entry.kind !== "directory"),
|
|
)
|
|
.slice(0, ARCHIVE_LIMITS.maxDisplayedEntries);
|
|
}, [document, filter, query]);
|
|
|
|
async function openFile(file?: File) {
|
|
if (!file) return;
|
|
operation.current?.abort();
|
|
const controller = new AbortController();
|
|
operation.current = controller;
|
|
const current = ++generation.current;
|
|
setBusy(true);
|
|
setPreviewBusy(false);
|
|
setError(undefined);
|
|
setStatus(`Inspecting ${file.name}…`);
|
|
try {
|
|
const next = await inspectArchive(file, controller.signal);
|
|
if (current !== generation.current) return;
|
|
setDocument(next);
|
|
setSelected(new Set());
|
|
setActiveEntry(undefined);
|
|
setPreview(undefined);
|
|
setPreviewError(undefined);
|
|
setStatus(
|
|
`Inspected ${next.entries.length.toLocaleString()} entries locally.`,
|
|
);
|
|
} catch (reason) {
|
|
if (controller.signal.aborted) return;
|
|
if (current === generation.current) setError(errorMessage(reason));
|
|
} finally {
|
|
if (current === generation.current) setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function showPreview(entry: ArchiveEntryRecord) {
|
|
if (!document) return;
|
|
operation.current?.abort();
|
|
const controller = new AbortController();
|
|
operation.current = controller;
|
|
const current = ++generation.current;
|
|
setActiveEntry(entry);
|
|
setPreview(undefined);
|
|
setPreviewBusy(true);
|
|
setPreviewError(undefined);
|
|
try {
|
|
const next = await previewEntry(document, entry, controller.signal);
|
|
if (current === generation.current) setPreview(next);
|
|
} catch (reason) {
|
|
if (!controller.signal.aborted && current === generation.current)
|
|
setPreviewError(errorMessage(reason));
|
|
} finally {
|
|
if (current === generation.current) setPreviewBusy(false);
|
|
}
|
|
}
|
|
|
|
async function extractSelection() {
|
|
if (!document) return;
|
|
operation.current?.abort();
|
|
const controller = new AbortController();
|
|
operation.current = controller;
|
|
const current = ++generation.current;
|
|
setBusy(true);
|
|
setPreviewBusy(false);
|
|
setError(undefined);
|
|
try {
|
|
const blob = await createSafeSelectionZip(
|
|
document,
|
|
[...selected],
|
|
controller.signal,
|
|
progressStatus(setStatus),
|
|
);
|
|
if (current !== generation.current) return;
|
|
downloadBlob(blob, `${outputStem(document.name)}-safe-selection.zip`);
|
|
setStatus(
|
|
`Verified and repackaged ${selected.size} safe files into a new ZIP.`,
|
|
);
|
|
} catch (reason) {
|
|
if (!controller.signal.aborted && current === generation.current)
|
|
setError(errorMessage(reason));
|
|
} finally {
|
|
if (current === generation.current) setBusy(false);
|
|
}
|
|
}
|
|
|
|
function toggle(entry: ArchiveEntryRecord) {
|
|
setSelected((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(entry.id)) next.delete(entry.id);
|
|
else next.add(entry.id);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function cancel() {
|
|
generation.current += 1;
|
|
operation.current?.abort(
|
|
new DOMException("Operation cancelled.", "AbortError"),
|
|
);
|
|
setBusy(false);
|
|
setPreviewBusy(false);
|
|
setStatus("Operation cancelled.");
|
|
}
|
|
|
|
const safeVisible = visible.filter((entry) => entry.extractable);
|
|
return (
|
|
<section
|
|
className="panel workspace-panel"
|
|
aria-labelledby="inspect-heading"
|
|
>
|
|
<div className="panel-heading">
|
|
<div>
|
|
<p className="eyebrow">ZIP · TAR · gzip</p>
|
|
<h2 id="inspect-heading">Inspect and safely repackage</h2>
|
|
</div>
|
|
<div className="button-row">
|
|
<label className="button primary-button">
|
|
Choose archive
|
|
<input
|
|
className="visually-hidden"
|
|
type="file"
|
|
accept=".zip,.tar,.tgz,.gz,application/zip,application/gzip,application/x-tar"
|
|
onChange={(event) => {
|
|
const file = event.currentTarget.files?.[0];
|
|
event.currentTarget.value = "";
|
|
void openFile(file);
|
|
}}
|
|
/>
|
|
</label>
|
|
{busy || previewBusy ? (
|
|
<button type="button" onClick={cancel}>
|
|
Cancel
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<p className="policy-note">
|
|
Encrypted and multipart ZIPs, nested expansion, symlinks, hardlinks,
|
|
devices and FIFOs are intentionally unsupported.
|
|
</p>
|
|
<StatusMessage error={error} status={status} />
|
|
{document ? (
|
|
<ArchiveSummaryView document={document} />
|
|
) : (
|
|
<div className="drop-prompt">
|
|
<strong>Open a local archive</strong>
|
|
<span>
|
|
It is inspected in this browser under fixed path, count, size and
|
|
ratio limits.
|
|
</span>
|
|
</div>
|
|
)}
|
|
{document ? (
|
|
<div className="inspect-layout">
|
|
<div className="inventory-pane">
|
|
<div className="inventory-toolbar">
|
|
<label className="field">
|
|
<span>Find path</span>
|
|
<input
|
|
type="search"
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Search entries"
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span>Show</span>
|
|
<select
|
|
value={filter}
|
|
onChange={(event) =>
|
|
setFilter(event.target.value as EntryFilter)
|
|
}
|
|
>
|
|
<option value="all">All entries</option>
|
|
<option value="safe">Safe files</option>
|
|
<option value="blocked">Blocked</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div className="selection-toolbar">
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setSelected(new Set(safeVisible.map((entry) => entry.id)))
|
|
}
|
|
disabled={!safeVisible.length}
|
|
>
|
|
Select visible safe
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelected(new Set())}
|
|
disabled={!selected.size}
|
|
>
|
|
Clear selection
|
|
</button>
|
|
<span>{selected.size} selected</span>
|
|
</div>
|
|
<div className="table-scroll">
|
|
<table className="entry-table">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">
|
|
<span className="visually-hidden">Select</span>
|
|
</th>
|
|
<th scope="col">Path</th>
|
|
<th scope="col">Kind</th>
|
|
<th scope="col">Size</th>
|
|
<th scope="col">State</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{visible.map((entry) => (
|
|
<tr
|
|
key={entry.id}
|
|
className={
|
|
!entry.extractable && entry.kind !== "directory"
|
|
? "blocked-row"
|
|
: undefined
|
|
}
|
|
>
|
|
<td>
|
|
<input
|
|
type="checkbox"
|
|
aria-label={`Select ${entry.path}`}
|
|
checked={selected.has(entry.id)}
|
|
disabled={!entry.extractable || busy}
|
|
onChange={() => toggle(entry)}
|
|
/>
|
|
</td>
|
|
<th scope="row">
|
|
<button
|
|
className="path-button"
|
|
type="button"
|
|
disabled={!entry.extractable}
|
|
onClick={() => void showPreview(entry)}
|
|
>
|
|
{entry.path}
|
|
</button>
|
|
</th>
|
|
<td>{entry.kind}</td>
|
|
<td>{formatBytes(entry.size)}</td>
|
|
<td
|
|
title={entry.issues
|
|
.map((issue) => issue.message)
|
|
.join(" ")}
|
|
>
|
|
{entry.extractable
|
|
? "Safe file"
|
|
: entry.kind === "directory"
|
|
? "Directory"
|
|
: (entry.issues[0]?.message ?? "Blocked")}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{document.entries.length > ARCHIVE_LIMITS.maxDisplayedEntries ? (
|
|
<p className="policy-note">
|
|
Display capped at{" "}
|
|
{ARCHIVE_LIMITS.maxDisplayedEntries.toLocaleString()} entries;
|
|
use search to narrow the inventory.
|
|
</p>
|
|
) : null}
|
|
<div className="button-row action-row">
|
|
<button
|
|
className="primary-button"
|
|
type="button"
|
|
onClick={() => void extractSelection()}
|
|
disabled={!selected.size || busy}
|
|
>
|
|
Verify & download safe ZIP
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
downloadBlob(
|
|
new Blob([reportJson(document)], {
|
|
type: "application/json",
|
|
}),
|
|
`${outputStem(document.name)}-archive-report.json`,
|
|
)
|
|
}
|
|
>
|
|
Download report
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<PreviewPane
|
|
entry={activeEntry}
|
|
preview={preview}
|
|
busy={previewBusy}
|
|
error={previewError}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function progressStatus(setStatus: (value: string) => void) {
|
|
return (update: ProgressUpdate) =>
|
|
setStatus(
|
|
`${update.stage}: ${update.completed.toLocaleString()} / ${update.total.toLocaleString()}`,
|
|
);
|
|
}
|