Add safe archive preview and extraction workflows

This commit is contained in:
2026-07-31 02:48:56 +02:00
parent 159a012833
commit b752dea610
20 changed files with 1961 additions and 182 deletions
+5 -5
View File
@@ -18,12 +18,12 @@
"test:file-property-filters": "node scripts/test-file-property-filters-structure.mjs"
},
"peerDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.6",
"@vitejs/plugin-react": "^5.2.0",
"vite": "^7.3.6",
"typescript": "^5.7.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": ">=7.18.2 <8",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"lucide-react": "^1.23.0",
"@govoplan/core-webui": "^0.1.9"
},
+87 -2
View File
@@ -328,6 +328,25 @@ export type ManagedFileSnapshotResponse = {files: ManagedFile[];folders: FileFol
export type FileSpacesResponse = {spaces: FileSpace[];};
export type FileUploadResponse = {files: ManagedFile[];};
export type FileUploadProgress = {loaded: number;total?: number;percentage: number | null;};
export type ArchivePreviewEntry = {
path: string;
kind: "file" | "directory";
size_bytes: number;
compressed_size_bytes?: number | null;
encrypted: boolean;
};
export type ArchivePreviewResponse = {
preview_token: string;
archive_format: string;
entries: ArchivePreviewEntry[];
file_count: number;
directory_count: number;
expanded_size_bytes: number;
compressed_size_bytes: number;
requires_password: boolean;
password_verified: boolean;
expires_at: string;
};
export type FileConnectorFilePayload = {
library_id: string;
path: string;
@@ -556,14 +575,80 @@ options: {
return apiFetch<FileUploadResponse>(settings, "/api/v1/files/upload", { method: "POST", body: form });
}
export function previewArchiveUpload(
settings: ApiSettings,
file: File,
options: {
owner_type: "user" | "group";
owner_id: string;
path?: string;
campaign_id?: string;
password?: string;
})
: Promise<ArchivePreviewResponse> {
const form = new FormData();
form.append("file", file);
form.append("owner_type", options.owner_type);
form.append("owner_id", options.owner_id);
form.append("path", options.path ?? "");
if (options.campaign_id) form.append("campaign_id", options.campaign_id);
if (options.password) form.append("password", options.password);
return apiFetch<ArchivePreviewResponse>(settings, "/api/v1/files/archive-preview", { method: "POST", body: form });
}
export function confirmArchiveUpload(
settings: ApiSettings,
file: File,
options: {
preview_token: string;
selected_paths: string[];
owner_type: "user" | "group";
owner_id: string;
path?: string;
campaign_id?: string;
password?: string;
conflict_strategy?: ConflictStrategy;
conflict_resolutions?: ConflictResolution[];
source_provenance?: FileSourceProvenance;
source_revision?: string;
connector_policy_sources?: FileConnectorPolicySource[];
onProgress?: (progress: FileUploadProgress) => void;
})
: Promise<FileUploadResponse> {
const form = new FormData();
form.append("file", file);
form.append("preview_token", options.preview_token);
form.append("selected_paths_json", JSON.stringify(options.selected_paths));
form.append("owner_type", options.owner_type);
form.append("owner_id", options.owner_id);
form.append("path", options.path ?? "");
if (options.campaign_id) form.append("campaign_id", options.campaign_id);
if (options.password) form.append("password", options.password);
if (options.conflict_strategy) form.append("conflict_strategy", options.conflict_strategy);
if (options.conflict_resolutions?.length) form.append("conflict_resolutions_json", JSON.stringify(options.conflict_resolutions));
if (options.source_provenance) form.append("source_provenance_json", JSON.stringify(options.source_provenance));
if (options.source_revision) form.append("source_revision", options.source_revision);
if (options.connector_policy_sources?.length) form.append("connector_policy_json", JSON.stringify({ sources: options.connector_policy_sources }));
if (options.onProgress) {
return uploadFilesWithProgress(
settings,
form,
options.onProgress,
"/api/v1/files/archive-confirm"
);
}
return apiFetch<FileUploadResponse>(settings, "/api/v1/files/archive-confirm", { method: "POST", body: form });
}
function uploadFilesWithProgress(
settings: ApiSettings,
form: FormData,
onProgress: (progress: FileUploadProgress) => void)
onProgress: (progress: FileUploadProgress) => void,
endpoint = "/api/v1/files/upload")
: Promise<FileUploadResponse> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", apiUrl(settings, "/api/v1/files/upload"));
xhr.open("POST", apiUrl(settings, endpoint));
xhr.withCredentials = true;
for (const [key, value] of authHeaders(settings)) xhr.setRequestHeader(key, value);
const csrf = csrfToken();
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { Folder, Network } from "lucide-react";
import { Link } from "react-router-dom";
import { Link } from "react-router";
import {
DashboardWidgetList,
DismissibleAlert,
+310 -16
View File
@@ -8,6 +8,7 @@ import {
FileDropZone,
FormField,
LoadingIndicator,
PasswordField,
ResourceAccessExplanation,
ToggleSwitch,
hasScope,
@@ -20,6 +21,7 @@ import {
browseFileConnectorProfile,
createFileConnectorSpace,
createFolder,
confirmArchiveUpload,
deleteFolder,
downloadFile,
downloadFilesAsZip,
@@ -29,11 +31,14 @@ import {
listFileConnectorProfiles,
listFileSpaces,
listManagedFileSnapshot,
previewArchiveUpload,
resolveFilePatterns,
syncFileConnectorFile,
transferFiles,
uploadFiles,
virtualFolderResourceId,
type ArchivePreviewEntry,
type ArchivePreviewResponse,
type ConflictResolution,
type ConflictStrategy,
type FileConnectorBrowseItem,
@@ -101,6 +106,7 @@ type FileAccessExplanationTarget = {
const DEFAULT_FILE_LIST_ROW_HEIGHT = 58;
const FILE_LIST_OVERSCAN_ROWS = 8;
const ARCHIVE_FILENAME_PATTERN = /\.(?:zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/i;
export default function FilesPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
const canDownload = hasScope(auth, "files:download");
@@ -132,6 +138,10 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
const [unmatchedCount, setUnmatchedCount] = useState<number | null>(null);
const [unpackZip, setUnpackZip] = useState(false);
const [archiveFile, setArchiveFile] = useState<File | null>(null);
const [archivePreview, setArchivePreview] = useState<ArchivePreviewResponse | null>(null);
const [archivePassword, setArchivePassword] = useState("");
const [selectedArchivePaths, setSelectedArchivePaths] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [uploadActive, setUploadActive] = useState(false);
const [uploadPhase, setUploadPhase] = useState<UploadPhase>("idle");
@@ -501,9 +511,19 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
setNewFolderName("");
setNewFolderError("");
}
if (kind === "upload") {
resetArchiveUploadState();
}
openRawDialog(kind, target);
}
function resetArchiveUploadState() {
setArchiveFile(null);
setArchivePreview(null);
setArchivePassword("");
setSelectedArchivePaths(new Set());
}
function closeDialog() {
closeRawDialog();
setNewFolderError("");
@@ -513,6 +533,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
setConnectorError("");
setConnectorSelectedItem(null);
setConnectorSpaceLabel("");
resetArchiveUploadState();
}
function updateActiveDialogFolder(spaceId: string, folderPath: string) {
@@ -667,6 +688,156 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
async function loadArchivePreview(
file: File,
target: FileActionTarget,
options: { preserveSelection?: boolean } = {}
) {
const targetSpace = findSpace(target.spaceId);
if (!targetSpace || isConnectorSpace(targetSpace)) {
setError(uploadRejectedReason(target));
return;
}
setArchiveFile(file);
setBusy(true);
setUploadActive(true);
setUploadPhase("uploading");
setUploadProgress(null);
setError("");
setMessage("");
try {
const response = await previewArchiveUpload(settings, file, {
owner_type: targetSpace.owner_type,
owner_id: targetSpace.owner_id,
path: target.folderPath,
password: archivePassword || undefined
});
const availableFiles = new Set(
response.entries
.filter((entry) => entry.kind === "file")
.map((entry) => entry.path)
);
setArchivePreview(response);
setSelectedArchivePaths((current) => {
if (!options.preserveSelection) return availableFiles;
const retained = new Set(
Array.from(current).filter((path) => availableFiles.has(path))
);
return retained.size > 0 ? retained : availableFiles;
});
} catch (err) {
setArchivePreview(null);
setSelectedArchivePaths(new Set());
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
setUploadActive(false);
setUploadPhase("idle");
setUploadProgress(null);
}
}
function archiveFilesForEntry(entry: ArchivePreviewEntry): string[] {
if (!archivePreview) return [];
if (entry.kind === "file") return [entry.path];
const prefix = `${entry.path}/`;
return archivePreview.entries
.filter((candidate) => candidate.kind === "file" && candidate.path.startsWith(prefix))
.map((candidate) => candidate.path);
}
function archiveEntryIsSelected(entry: ArchivePreviewEntry): boolean {
const paths = archiveFilesForEntry(entry);
return paths.length > 0 && paths.every((path) => selectedArchivePaths.has(path));
}
function toggleArchiveEntry(entry: ArchivePreviewEntry, selected: boolean) {
const paths = archiveFilesForEntry(entry);
setSelectedArchivePaths((current) => {
const next = new Set(current);
paths.forEach((path) => selected ? next.add(path) : next.delete(path));
return next;
});
}
function toggleAllArchiveFiles(selected: boolean) {
setSelectedArchivePaths(
selected && archivePreview
? new Set(
archivePreview.entries
.filter((entry) => entry.kind === "file")
.map((entry) => entry.path)
)
: new Set()
);
}
async function confirmCurrentArchive() {
const target = currentActionTarget();
const targetSpace = target ? findSpace(target.spaceId) : null;
if (
busy
|| !archiveFile
|| !archivePreview
|| !target
|| !targetSpace
|| isConnectorSpace(targetSpace)
) {
return;
}
if (selectedArchivePaths.size === 0) {
setError("Select at least one archive file to import.");
return;
}
if (archivePreview.requires_password && !archivePassword) {
setError("Enter the archive password before importing.");
return;
}
setBusy(true);
setUploadActive(true);
setUploadPhase("uploading");
setUploadProgress(0);
setError("");
setMessage("Uploading the archive for confirmed extraction.");
try {
const response = await confirmArchiveUpload(settings, archiveFile, {
preview_token: archivePreview.preview_token,
selected_paths: Array.from(selectedArchivePaths),
owner_type: targetSpace.owner_type,
owner_id: targetSpace.owner_id,
path: target.folderPath,
password: archivePassword || undefined,
conflict_strategy: "reject",
onProgress: ({ percentage }) => {
setUploadProgress(percentage);
if (percentage !== null && percentage >= 100) {
setUploadPhase("unpacking");
setMessage("Extracting the selected archive files.");
}
}
});
setUploadPhase("finalizing");
setUploadProgress(100);
const uploadedCount = response.files.length;
const destination = target.folderPath || "i18n:govoplan-files.root.e96857c5";
closeDialog();
setMessage(i18nMessage("i18n:govoplan-files.uploaded_value_file_s_into_value.d0fa052b", {
value0: uploadedCount,
value1: destination
}));
resetTransientState();
await loadSpaceContents(targetSpace, { silent: targetSpace.id !== activeSpaceId });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setMessage("");
} finally {
setBusy(false);
setUploadActive(false);
setUploadPhase("idle");
setUploadProgress(null);
}
}
async function handleFilesUpload(fileList: FileList | File[], options: {conflictStrategy?: ConflictStrategy;conflictResolutions?: ConflictResolution[];bypassConflictDialog?: boolean;target?: FileActionTarget;} = {}) {
const target = options.target ?? currentActionTarget();
const targetSpace = target ? findSpace(target.spaceId) : null;
@@ -676,6 +847,14 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
}
const selected = Array.from(fileList);
if (selected.length === 0) return;
if (unpackZip) {
if (selected.length !== 1 || !ARCHIVE_FILENAME_PATTERN.test(selected[0].name)) {
setError("Choose one ZIP, TAR, TAR.GZ, TAR.BZ2, or TAR.XZ archive to preview and unpack.");
return;
}
await loadArchivePreview(selected[0], target);
return;
}
if (!options.bypassConflictDialog && !unpackZip) {
const conflicts = uploadConflicts(selected, target);
if (conflicts.length > 0) {
@@ -691,7 +870,6 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
return;
}
}
const uploadsZipArchive = unpackZip && selected.some((file) => file.name.toLowerCase().endsWith(".zip"));
setBusy(true);
setUploadActive(true);
setUploadPhase("uploading");
@@ -703,14 +881,13 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
owner_type: targetSpace.owner_type,
owner_id: targetSpace.owner_id,
path: target.folderPath,
unpack_zip: unpackZip,
conflict_strategy: options.conflictStrategy ?? "reject",
conflict_resolutions: options.conflictResolutions,
onProgress: ({ percentage }) => {
setUploadProgress(percentage);
if (percentage !== null && percentage >= 100) {
setUploadPhase(uploadsZipArchive ? "unpacking" : "finalizing");
setMessage(uploadsZipArchive ? "i18n:govoplan-files.unpacking_zip_upload.35019691" : "i18n:govoplan-files.finalizing_upload.bcce936d");
setUploadPhase("finalizing");
setMessage("i18n:govoplan-files.finalizing_upload.bcce936d");
}
}
});
@@ -2332,6 +2509,29 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
{dialog === "upload" &&
<FileDialog title={i18nMessage("i18n:govoplan-files.upload_to_value_value.b83a34b4", { value0: activeDialogSpace?.label || "i18n:govoplan-files.files.6ce6c512", value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" })} onClose={closeDialog}>
{!archivePreview &&
<>
<ToggleSwitch
label="Preview and unpack archive"
checked={unpackZip}
onChange={setUnpackZip}
disabled={busy} />
{unpackZip &&
<p className="form-help archive-upload-help">
Supports ZIP, TAR, TAR.GZ, TAR.BZ2, and TAR.XZ. Nothing is written until you review and confirm the archive contents.
</p>
}
<div className="field-block">
<FieldLabel className="form-label" help="i18n:govoplan-files.choose_the_destination_folder_before_selecting_o.a4922d75">i18n:govoplan-files.destination_folder.f8ccb63b</FieldLabel>
<TransferFolderSelector
space={activeDialogSpace}
nodes={activeDialogTarget ? buildFolderTree(filesBySpace[activeDialogTarget.spaceId] ?? EMPTY_FILES, foldersBySpace[activeDialogTarget.spaceId] ?? EMPTY_FOLDERS) : []}
selectedFolder={activeDialogTarget?.folderPath || ""}
disabled={busy || !activeDialogSpace}
onSelect={(folderPath) => activeDialogTarget && updateActiveDialogFolder(activeDialogTarget.spaceId, folderPath)} />
</div>
<FileDropZone
disabled={busy || !activeDialogTarget || !activeDialogSpace || isConnectorSpace(activeDialogSpace) || !canUpload}
busy={uploadActive}
@@ -2341,21 +2541,115 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
note={`Files are uploaded into ${activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5"}.`}
onRejectedDrop={(reason) => setError(reason === "disabled" ? uploadRejectedReason(activeDialogTarget) : "The browser did not provide readable file data for this drop. Use the file picker, or drag local files from a file manager that exposes file contents to the browser.")}
onFiles={(files) => handleFilesUpload(files, { target: activeDialogTarget || undefined })} />
<ToggleSwitch label="i18n:govoplan-files.unpack_zip_uploads.256fead4" checked={unpackZip} onChange={setUnpackZip} disabled={busy} />
<div className="field-block">
<FieldLabel className="form-label" help="i18n:govoplan-files.choose_the_destination_folder_before_selecting_o.a4922d75">i18n:govoplan-files.destination_folder.f8ccb63b</FieldLabel>
<TransferFolderSelector
space={activeDialogSpace}
nodes={activeDialogTarget ? buildFolderTree(filesBySpace[activeDialogTarget.spaceId] ?? EMPTY_FILES, foldersBySpace[activeDialogTarget.spaceId] ?? EMPTY_FOLDERS) : []}
selectedFolder={activeDialogTarget?.folderPath || ""}
disabled={busy || !activeDialogSpace}
onSelect={(folderPath) => activeDialogTarget && updateActiveDialogFolder(activeDialogTarget.spaceId, folderPath)} />
</div>
<div className="button-row compact-actions align-end">
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
</div>
</>
}
{archivePreview && archiveFile &&
<div className="archive-preview">
<div className="archive-preview-summary">
<div>
<strong>{archiveFile.name}</strong>
<span>{archivePreview.archive_format.toUpperCase()} · {formatBytes(archivePreview.compressed_size_bytes)} compressed</span>
</div>
<div>
<strong>{archivePreview.file_count} files</strong>
<span>{formatBytes(archivePreview.expanded_size_bytes)} expanded · {archivePreview.directory_count} folders</span>
</div>
<div>
<strong>Destination</strong>
<span>{activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5"}</span>
</div>
</div>
{archivePreview.requires_password &&
<FormField
label="Archive password"
help="The password stays in this dialog and is sent only while inspecting or importing this archive.">
<PasswordField
value={archivePassword}
onValueChange={setArchivePassword}
disabled={busy}
autoComplete="off" />
</FormField>
}
<div className="archive-selection-toolbar">
<label>
<input
type="checkbox"
checked={selectedArchivePaths.size === archivePreview.file_count && archivePreview.file_count > 0}
onChange={(event) => toggleAllArchiveFiles(event.target.checked)}
disabled={busy || archivePreview.file_count === 0} />
<span>Select all files</span>
</label>
<span>{selectedArchivePaths.size} of {archivePreview.file_count} selected</span>
</div>
<div className="archive-entry-list" role="list" aria-label="Archive contents">
{archivePreview.entries.map((entry) => {
const selectableFiles = archiveFilesForEntry(entry);
const depth = Math.max(0, entry.path.split("/").length - 1);
return (
<label
key={`${entry.kind}:${entry.path}`}
className={`archive-entry-row ${entry.kind === "directory" ? "is-directory" : ""}`}
style={{ paddingLeft: `${12 + depth * 18}px` }}>
<input
type="checkbox"
checked={archiveEntryIsSelected(entry)}
onChange={(event) => toggleArchiveEntry(entry, event.target.checked)}
disabled={busy || selectableFiles.length === 0} />
{entry.kind === "directory" ?
<Folder size={16} aria-hidden="true" /> :
<File size={16} aria-hidden="true" />
}
<span className="archive-entry-path">{entry.path.split("/").at(-1)}</span>
<span className="archive-entry-size">{entry.kind === "file" ? formatBytes(entry.size_bytes) : `${selectableFiles.length} files`}</span>
</label>);
})}
</div>
<p className="form-help">
Preview expires {formatDate(archivePreview.expires_at)}. The original archive is uploaded again only when you confirm.
</p>
<div className="button-row compact-actions archive-preview-actions">
<Button
onClick={() => {
resetArchiveUploadState();
setError("");
}}
disabled={busy}>
Choose another file
</Button>
{archivePreview.requires_password &&
<Button
onClick={() => activeDialogTarget && void loadArchivePreview(archiveFile, activeDialogTarget, { preserveSelection: true })}
disabled={busy || !archivePassword}>
<RefreshCw size={15} aria-hidden="true" /> Verify password
</Button>
}
<span className="archive-preview-action-spacer" />
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
<Button
variant="primary"
onClick={() => void confirmCurrentArchive()}
disabled={
busy
|| selectedArchivePaths.size === 0
|| archivePreview.requires_password && !archivePassword
}>
Import selected
</Button>
</div>
</div>
}
</FileDialog>
}
+130
View File
@@ -288,6 +288,132 @@
width: min(820px, 100%);
}
.file-dialog:has(.archive-preview) {
width: min(860px, 100%);
}
.archive-upload-help {
margin: -8px 0 0;
}
.archive-preview {
display: grid;
min-width: 0;
gap: 16px;
}
.archive-preview-summary {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr) minmax(0, 1fr);
gap: 1px;
overflow: hidden;
border: var(--border-line);
border-radius: 6px;
background: var(--line);
}
.archive-preview-summary > div {
display: grid;
min-width: 0;
gap: 4px;
padding: 11px 12px;
background: var(--panel-soft);
}
.archive-preview-summary strong,
.archive-preview-summary span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.archive-preview-summary span {
color: var(--muted);
font-size: var(--font-size-sm);
}
.archive-selection-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.archive-selection-toolbar label,
.archive-entry-row {
display: flex;
align-items: center;
gap: 9px;
}
.archive-selection-toolbar label {
font-weight: 700;
}
.archive-selection-toolbar > span,
.archive-entry-size {
color: var(--muted);
font-size: var(--font-size-sm);
}
.archive-entry-list {
display: grid;
align-content: start;
max-height: min(380px, 42vh);
overflow: auto;
scrollbar-gutter: stable;
border: var(--border-line);
border-radius: 6px;
background: var(--surface);
}
.archive-entry-row {
min-width: 0;
min-height: 38px;
padding-top: 7px;
padding-right: 12px;
padding-bottom: 7px;
border-bottom: var(--border-line);
cursor: pointer;
}
.archive-entry-row:last-child {
border-bottom: 0;
}
.archive-entry-row:hover {
background: var(--hover-tint);
}
.archive-entry-row.is-directory {
background: var(--panel-soft);
font-weight: 700;
}
.archive-entry-row.is-directory:hover {
background: var(--primary-soft);
}
.archive-entry-path {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.archive-entry-size {
flex: 0 0 auto;
margin-left: auto;
}
.archive-preview-actions {
align-items: center;
}
.archive-preview-action-spacer {
flex: 1 1 auto;
}
.file-dialog:has(.file-share-dialog-content) {
width: min(1080px, 100%);
}
@@ -312,6 +438,10 @@
}
@media (max-width: 620px) {
.archive-preview-summary {
grid-template-columns: minmax(0, 1fr);
}
.file-share-form {
grid-template-columns: minmax(0, 1fr);
}