202 lines
7.1 KiB
TypeScript
202 lines
7.1 KiB
TypeScript
import { useRef, useState } from "react";
|
|
import { downloadBlob } from "../../archive/downloads";
|
|
import { errorMessage } from "../../archive/errors";
|
|
import { ARCHIVE_LIMITS, formatBytes } from "../../archive/limits";
|
|
import { createArchive } from "../../archive/service";
|
|
import type { CreateFormat } from "../../archive/types";
|
|
import { StatusMessage } from "./StatusMessage";
|
|
|
|
export function CreateWorkspace() {
|
|
const [files, setFiles] = useState<File[]>([]);
|
|
const [format, setFormat] = useState<CreateFormat>("zip");
|
|
const [name, setName] = useState("archive");
|
|
const [encryption, setEncryption] = useState<
|
|
"none" | "aes-256" | "zipcrypto"
|
|
>("none");
|
|
const [password, setPassword] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [status, setStatus] = useState<string>();
|
|
const [error, setError] = useState<string>();
|
|
const controller = useRef<AbortController | undefined>(undefined);
|
|
const total = files.reduce((sum, file) => sum + file.size, 0);
|
|
|
|
async function build() {
|
|
controller.current?.abort();
|
|
const next = new AbortController();
|
|
controller.current = next;
|
|
setBusy(true);
|
|
setError(undefined);
|
|
try {
|
|
const blob = await createArchive(
|
|
files,
|
|
format,
|
|
next.signal,
|
|
(progress) =>
|
|
setStatus(
|
|
`${progress.stage}: ${progress.completed} / ${progress.total}`,
|
|
),
|
|
format === "zip" && encryption !== "none"
|
|
? { password, method: encryption }
|
|
: undefined,
|
|
);
|
|
const extension = format === "tar.gz" ? "tar.gz" : format;
|
|
downloadBlob(blob, `${name.trim() || "archive"}.${extension}`);
|
|
setStatus(
|
|
encryption === "none" || format !== "zip"
|
|
? `Created deterministic ${format.toLocaleUpperCase("en-US")} locally.`
|
|
: `Created encrypted ${format.toLocaleUpperCase("en-US")} locally; random salt makes encrypted bytes intentionally non-deterministic.`,
|
|
);
|
|
if (encryption !== "none") setPassword("");
|
|
} catch (reason) {
|
|
if (!next.signal.aborted) setError(errorMessage(reason));
|
|
} finally {
|
|
if (controller.current === next) setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="panel workspace-panel" aria-labelledby="create-heading">
|
|
<div className="panel-heading">
|
|
<div>
|
|
<p className="eyebrow">Reproducible output</p>
|
|
<h2 id="create-heading">Create an archive</h2>
|
|
</div>
|
|
</div>
|
|
<div className="create-grid">
|
|
<div className="form-stack">
|
|
<label className="field">
|
|
<span>Files</span>
|
|
<input
|
|
type="file"
|
|
multiple
|
|
onChange={(event) => {
|
|
setFiles([...(event.currentTarget.files ?? [])]);
|
|
event.currentTarget.value = "";
|
|
}}
|
|
/>
|
|
</label>
|
|
<div className="field-grid">
|
|
<label className="field">
|
|
<span>Output name</span>
|
|
<input
|
|
value={name}
|
|
onChange={(event) => setName(event.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span>Format</span>
|
|
<select
|
|
value={format}
|
|
onChange={(event) =>
|
|
setFormat(event.target.value as CreateFormat)
|
|
}
|
|
>
|
|
<option value="zip">ZIP (DEFLATE)</option>
|
|
<option value="tar">TAR / PAX</option>
|
|
<option value="tar.gz">tar.gz</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<p className="policy-note">
|
|
Files are sorted by normalized path. Timestamps, owner IDs and
|
|
permissions are normalized; ZIP compression uses a fixed
|
|
implementation and level.
|
|
</p>
|
|
{format === "zip" ? (
|
|
<div className="field-grid">
|
|
<label className="field">
|
|
<span>Encryption</span>
|
|
<select
|
|
value={encryption}
|
|
onChange={(event) =>
|
|
setEncryption(
|
|
event.target.value as "none" | "aes-256" | "zipcrypto",
|
|
)
|
|
}
|
|
>
|
|
<option value="none">None</option>
|
|
<option value="aes-256">AES-256 (recommended)</option>
|
|
<option value="zipcrypto">ZipCrypto (legacy, weak)</option>
|
|
</select>
|
|
</label>
|
|
{encryption !== "none" ? (
|
|
<label className="field">
|
|
<span>Password (memory only)</span>
|
|
<input
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={password}
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
/>
|
|
</label>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{format === "zip" && encryption === "zipcrypto" ? (
|
|
<p className="policy-note" role="note">
|
|
ZipCrypto is offered only for compatibility and is not secure
|
|
against modern password attacks. Prefer AES-256.
|
|
</p>
|
|
) : null}
|
|
<StatusMessage error={error} status={status} />
|
|
<div className="button-row">
|
|
<button
|
|
className="primary-button"
|
|
type="button"
|
|
disabled={
|
|
!files.length ||
|
|
busy ||
|
|
total > ARCHIVE_LIMITS.maxCreateBytes ||
|
|
(format === "zip" && encryption !== "none" && !password)
|
|
}
|
|
onClick={() => void build()}
|
|
>
|
|
Create & download
|
|
</button>
|
|
{busy ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
controller.current?.abort();
|
|
setStatus("Operation cancelled.");
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
disabled={!files.length || busy}
|
|
onClick={() => setFiles([])}
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<aside className="file-selection-summary">
|
|
<h3>Selection</h3>
|
|
<p>
|
|
<strong>{files.length.toLocaleString()}</strong> files ·{" "}
|
|
<strong>{formatBytes(total)}</strong>
|
|
</p>
|
|
<p className="policy-note">
|
|
Limits: {ARCHIVE_LIMITS.maxCreateFiles.toLocaleString()} files and{" "}
|
|
{formatBytes(ARCHIVE_LIMITS.maxCreateBytes)} total input.
|
|
</p>
|
|
<ul>
|
|
{files.slice(0, 30).map((file, index) => (
|
|
<li key={`${file.name}-${file.size}-${index}`}>
|
|
<span>{file.webkitRelativePath || file.name}</span>
|
|
<span>{formatBytes(file.size)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{files.length > 30 ? (
|
|
<p className="policy-note">…and {files.length - 30} more</p>
|
|
) : null}
|
|
</aside>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|