Release Archive Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
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()}`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user