feat: harden file sharing and integrity

This commit is contained in:
2026-07-30 14:26:47 +02:00
parent 85606d5580
commit 835eacfc5d
28 changed files with 2714 additions and 102 deletions
+33 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react";
import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, ListFilter, MoveRight, Plus, RefreshCw, Search, Trash2, UploadCloud } from "lucide-react";
import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, ListFilter, MoveRight, Plus, RefreshCw, Search, Share2, Trash2, UploadCloud } from "lucide-react";
import {
Button,
ConfirmDialog,
@@ -48,6 +48,7 @@ import {
"../../api/files";
import { EMPTY_FILES, EMPTY_FOLDERS, EMPTY_SPACES, INTERNAL_DRAG_TYPE } from "./constants";
import { FileConflictDialog, FileContextMenu, FileDialog, FolderTree, RenamePreviewList, TransferFolderSelector } from "./components/FileManagerComponents";
import FileShareDialog from "./components/FileShareDialog";
import type {
ContextMenuState,
DialogKind,
@@ -106,6 +107,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
const canUpload = hasScope(auth, "files:upload");
const canOrganize = hasScope(auth, "files:organize");
const canDelete = hasScope(auth, "files:delete");
const canShare = hasScope(auth, "files:file:share");
const [spaces, setSpaces] = useState<FileSpace[]>(EMPTY_SPACES);
const [activeSpaceId, setActiveSpaceId] = useState("");
const activeSpace = spaces.find((space) => space.id === activeSpaceId) ?? spaces[0] ?? null;
@@ -154,6 +156,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
const [accessExplanationTarget, setAccessExplanationTarget] = useState<FileAccessExplanationTarget | null>(null);
const [resourceAccessExplanation, setResourceAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null);
const [resourceAccessLoading, setResourceAccessLoading] = useState(false);
const [shareDialogFile, setShareDialogFile] = useState<ManagedFile | null>(null);
const { setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState();
const {
dialog,
@@ -245,6 +248,14 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
onOpenFolder: openFolder
});
const selectedFiles = useMemo(() => files.filter((file) => selectedFileIds.has(file.id)), [files, selectedFileIds]);
const shareManageableFile = useMemo(() => {
if (!canShare || selectedFiles.length !== 1 || selectedFolderPaths.size > 0) return null;
const file = selectedFiles[0];
const ownsFile = file.owner_type === "user"
? file.owner_id === auth.user.id
: auth.groups.some((group) => group.id === file.owner_id);
return ownsFile || hasScope(auth, "files:file:admin") ? file : null;
}, [auth, canShare, selectedFiles, selectedFolderPaths]);
const selectedDownloadFileIds = useMemo(() => fileIdsForSelection(files, selectedFileIds, selectedFolderPaths), [files, selectedFileIds, selectedFolderPaths]);
const accessExplainableTarget = useMemo(
() => accessTargetForSelection(selectedFileIds, selectedFolderPaths, activeSpaceId),
@@ -1896,6 +1907,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
<Link2 size={16} aria-hidden="true" /> i18n:govoplan-files.add_space.e4d674d4
</Button>
<Button onClick={() => void downloadSelection()} disabled={busy || activeSpaceIsConnector || !canDownload || selectedDownloadFileIds.length === 0}><Download size={16} aria-hidden="true" /> {downloadLabel}</Button>
<Button onClick={() => shareManageableFile && setShareDialogFile(shareManageableFile)} disabled={busy || activeSpaceIsConnector || !shareManageableFile}><Share2 size={16} aria-hidden="true" /> Manage shares</Button>
<Button onClick={() => openDialog("create-folder", toolbarTarget())} disabled={busy || !activeSpace || activeSpaceIsConnector || !canOrganize}><Plus size={16} aria-hidden="true" /> i18n:govoplan-files.create_folder.97bafaba</Button>
<Button onClick={() => openTransferDialog("move")} disabled={busy || activeSpaceIsConnector || !canOrganize || !hasSelection}><MoveRight size={16} aria-hidden="true" /> i18n:govoplan-files.move.76cdb950</Button>
<Button onClick={() => openTransferDialog("copy")} disabled={busy || activeSpaceIsConnector || !canOrganize || !hasSelection}><Copy size={16} aria-hidden="true" /> i18n:govoplan-files.copy.af74f7c5</Button>
@@ -2307,6 +2319,17 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
</FileDialog>
}
{shareDialogFile &&
<FileShareDialog
settings={settings}
file={shareDialogFile}
tenantId={auth.active_tenant?.id || auth.tenant.id}
onClose={() => setShareDialogFile(null)}
onChanged={() => {
if (activeSpace) void loadSpaceContents(activeSpace, { silent: true });
}} />
}
{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}>
<FileDropZone
@@ -2709,7 +2732,15 @@ function FileSearchRow({
}
function activeCampaignShares(file: ManagedFile) {
return (file.shares ?? []).filter((share) => share.target_type === "campaign" && !share.revoked_at);
return (file.shares ?? []).filter((share) =>
share.target_type === "campaign" && fileShareIsActive(share)
);
}
function fileShareIsActive(share: NonNullable<ManagedFile["shares"]>[number]): boolean {
if (typeof share.active === "boolean") return share.active;
return !share.revoked_at
&& (!share.expires_at || new Date(share.expires_at).getTime() > Date.now());
}
function activeCampaignShareCount(file: ManagedFile): number {