From e869be7c3d53062f7f85276b901af5d416879ac9 Mon Sep 17 00:00:00 2001 From: huhn511 Date: Tue, 23 Jun 2026 19:02:23 +0200 Subject: [PATCH] feat: encrypted-file vault, sandbox-safe IO, share links, folder upload - vault: per-user, in-pod encrypted index mapping each encrypted file to the passphrase that opens it, searchable behind a new Vault dialog. Stored as .vault.enc + sidecar (AES-GCM under the session passphrase); never leaves the pod in plaintext and there is no central DB. - download/clipboard: route through saveBlob()/copyText() helpers that fall back for the Mind shell's sandboxed iframe, where a[download] and the async Clipboard API are blocked. - share: encrypted files grant/revoke the ciphertext + sidecar together and surface an in-app viewer link (?src=) that prompts for the passphrase; FilePreview can now render any pod URL read-only and anonymously. - rename: lock the file extension (edit the base name only) so a rename can't change the format; move the .enc.json sidecar alongside encrypted renames. - upload: drop files onto a folder row/tile to upload into that folder. - crumbs: icon + heading redesign; image thumbnails in list view. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/drive/file/[...path]/page.tsx | 11 +- src/components/DriveBrowser.tsx | 254 +++++++++++++++++++------- src/components/FilePreview.tsx | 160 +++++++++------- src/components/ShareDialog.tsx | 79 ++++++-- src/components/VaultDialog.tsx | 220 ++++++++++++++++++++++ src/lib/clipboard.ts | 38 ++++ src/lib/download.ts | 31 ++++ src/lib/filename.ts | 22 +++ src/lib/solid/file-ops.ts | 46 +++++ src/lib/solid/vault.ts | 126 +++++++++++++ 10 files changed, 831 insertions(+), 156 deletions(-) create mode 100644 src/components/VaultDialog.tsx create mode 100644 src/lib/clipboard.ts create mode 100644 src/lib/download.ts create mode 100644 src/lib/filename.ts create mode 100644 src/lib/solid/file-ops.ts create mode 100644 src/lib/solid/vault.ts diff --git a/src/app/drive/file/[...path]/page.tsx b/src/app/drive/file/[...path]/page.tsx index f38be2c..7e03928 100644 --- a/src/app/drive/file/[...path]/page.tsx +++ b/src/app/drive/file/[...path]/page.tsx @@ -1,6 +1,13 @@ import FilePreview from "@/components/FilePreview"; -export default async function FilePage({ params }: { params: Promise<{ path: string[] }> }) { +export default async function FilePage({ + params, + searchParams, +}: { + params: Promise<{ path: string[] }>; + searchParams: Promise<{ src?: string }>; +}) { const { path } = await params; - return ; + const { src } = await searchParams; + return ; } diff --git a/src/components/DriveBrowser.tsx b/src/components/DriveBrowser.tsx index 56465fc..4cce371 100644 --- a/src/components/DriveBrowser.tsx +++ b/src/components/DriveBrowser.tsx @@ -2,9 +2,12 @@ import { Button, Checkbox, Input, ToggleGroup, ToggleGroupItem } from "@mind-studio/ui"; import { + ChevronRight, Download, Folder, FolderPlus, + HardDrive, + KeyRound, LayoutGrid, List as ListIcon, Pencil, @@ -19,7 +22,10 @@ import { toast } from "sonner"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import PassphraseDialog from "@/components/PassphraseDialog"; import { ImageThumbnail, isImageName } from "@/components/Thumbnail"; +import VaultDialog from "@/components/VaultDialog"; import { driveRootFor, normalizeSegment } from "@/lib/config"; +import { saveBlob } from "@/lib/download"; +import { splitExt } from "@/lib/filename"; import { ensureSession, isEmbedded, rememberSignedOutPath } from "@/lib/solid/auth"; import { currentIdentity, isBrokered, signalReady } from "@/lib/solid/broker"; import { @@ -30,16 +36,17 @@ import { setSessionPassphrase, sidecarUrlFor, } from "@/lib/solid/crypto"; +import { renameFile } from "@/lib/solid/file-ops"; import { guessContentType, mkdir, type PodEntry, podFetch, readdir, - rename, rmrf, writeFileBlob, } from "@/lib/solid/pod-fs"; +import { addVaultEntries, isVaultName, type VaultEntry } from "@/lib/solid/vault"; type State = | { kind: "booting" } @@ -76,8 +83,10 @@ export default function DriveBrowser({ pathSegments }: { pathSegments: string[] const [encryptUploads, setEncryptUploads] = useState(false); const [passphrasePrompt, setPassphrasePrompt] = useState(null); + const [vaultOpen, setVaultOpen] = useState(false); /** * Sticky upload-error banner. Lives outside `state` so the `finally`-block * refresh() in uploadFiles doesn't wipe it. Cleared on the next successful @@ -160,14 +169,21 @@ export default function DriveBrowser({ pathSegments }: { pathSegments: string[] }, [refresh]); const uploadFiles = useCallback( - async (files: File[]) => { + async (files: File[], targetUrl?: string) => { if (state.kind !== "ready" || files.length === 0) return; + // targetUrl lets a drop onto a folder row upload INTO that folder; it + // defaults to the folder currently being browsed. + const containerUrl = targetUrl ?? state.containerUrl; if (encryptUploads && !getSessionPassphrase()) { // First encrypted upload of this session β€” collect a passphrase // before doing anything. The confirm-required flow doubles as a // typo guard since there's no recovery. - setPassphrasePrompt({ pendingFiles: files, confirmRequired: true }); + setPassphrasePrompt({ + pendingFiles: files, + targetUrl: containerUrl, + confirmRequired: true, + }); return; } @@ -179,6 +195,7 @@ export default function DriveBrowser({ pathSegments }: { pathSegments: string[] setUploadProgress(startProgress); setUploadError(null); let failed = false; + const vaultEntries: VaultEntry[] = []; try { for (let i = 0; i < files.length; i++) { const f = files[i]; @@ -186,15 +203,21 @@ export default function DriveBrowser({ pathSegments }: { pathSegments: string[] const passphrase = getSessionPassphrase(); if (!passphrase) throw new Error("Missing session passphrase"); const { ciphertext, sidecar } = await encryptFile(passphrase, f, f.name); - const encUrl = state.containerUrl + encodeURIComponent(f.name) + ".enc"; + const encUrl = containerUrl + encodeURIComponent(f.name) + ".enc"; const metaUrl = sidecarUrlFor(encUrl); await writeFileBlob(encUrl, ciphertext, "application/octet-stream"); const sidecarBlob = new Blob([JSON.stringify(sidecar)], { type: "application/json", }); await writeFileBlob(metaUrl, sidecarBlob, "application/json"); + vaultEntries.push({ + fileUrl: encUrl, + name: f.name, + passphrase, + addedAt: new Date().toISOString(), + }); } else { - const url = state.containerUrl + encodeURIComponent(f.name); + const url = containerUrl + encodeURIComponent(f.name); const contentType = f.type || guessContentType(f.name); await writeFileBlob(url, f, contentType); } @@ -202,6 +225,19 @@ export default function DriveBrowser({ pathSegments }: { pathSegments: string[] prev.map((p, idx) => (idx === i ? { ...p, done: p.total } : p)), ); } + // Record encrypted uploads in the in-pod vault so their passphrase is + // recoverable and they're searchable there. Best-effort: a vault write + // failure must never fail the upload itself. + if (vaultEntries.length > 0) { + const passphrase = getSessionPassphrase(); + if (passphrase) { + try { + await addVaultEntries(state.driveRoot, passphrase, vaultEntries); + } catch (err) { + console.warn("vault update failed", err); + } + } + } } catch (e) { failed = true; // Sticky banner β€” survives the refresh() in finally, unlike the @@ -241,11 +277,7 @@ export default function DriveBrowser({ pathSegments }: { pathSegments: string[] return ( - + setVaultOpen(true)} /> {uploadProgress.length > 0 ? : null} {uploadError ? ( @@ -285,10 +318,11 @@ export default function DriveBrowser({ pathSegments }: { pathSegments: string[] ) : null} uploadFiles(files, url)} viewMode={viewMode} searchQuery={searchQuery} /> @@ -300,14 +334,17 @@ export default function DriveBrowser({ pathSegments }: { pathSegments: string[] confirmRequired={passphrasePrompt.confirmRequired} onSubmit={(p) => { setSessionPassphrase(p); - const files = passphrasePrompt.pendingFiles; + const { pendingFiles, targetUrl } = passphrasePrompt; setPassphrasePrompt(null); // re-trigger upload now that we have a passphrase - void uploadFiles(files); + void uploadFiles(pendingFiles, targetUrl); }} onCancel={() => setPassphrasePrompt(null)} /> ) : null} + {vaultOpen ? ( + setVaultOpen(false)} /> + ) : null} ); } @@ -320,11 +357,12 @@ function safeDecode(s: string): string { } } -function hideEncryptedSidecars(entries: PodEntry[]): PodEntry[] { - // The .enc.json sidecars are an implementation detail; surfacing them in - // the listing would be confusing. The matching .enc rows already carry - // a πŸ”’ in their icon. - return entries.filter((e) => !e.name.endsWith(".enc.json")); +function hideInternalFiles(entries: PodEntry[]): PodEntry[] { + // The .enc.json sidecars and the encrypted vault file are implementation + // details; surfacing them in the listing would be confusing. The matching + // .enc rows already carry a πŸ”’ in their icon; the vault lives behind its + // own dialog. + return entries.filter((e) => !e.name.endsWith(".enc.json") && !isVaultName(safeDecode(e.name))); } function Shell({ children }: { children: React.ReactNode }) { @@ -379,15 +417,7 @@ function ErrorPanel({ message, onRetry }: { message: string; onRetry: () => void ); } -function Crumbs({ - driveRoot, - containerUrl, - pathSegments, -}: { - driveRoot: string; - containerUrl: string; - pathSegments: string[]; -}) { +function Crumbs({ pathSegments }: { pathSegments: string[] }) { const crumbs = useMemo(() => { const acc: { label: string; href: string }[] = [{ label: "My Drive", href: "/drive" }]; for (let i = 0; i < pathSegments.length; i++) { @@ -400,28 +430,34 @@ function Crumbs({ return acc; }, [pathSegments]); + const trail = crumbs.slice(0, -1); + const current = crumbs[crumbs.length - 1]; + const atRoot = trail.length === 0; + return ( -
- + ) : null} +
+ + {atRoot ? : } + +

{current.label}

+
); } @@ -436,6 +472,7 @@ function Toolbar({ onSearchChange, encryptUploads, onEncryptUploadsChange, + onOpenVault, }: { containerUrl: string; onChanged: () => void; @@ -446,6 +483,7 @@ function Toolbar({ onSearchChange: (q: string) => void; encryptUploads: boolean; onEncryptUploadsChange: (v: boolean) => void; + onOpenVault: () => void; }) { const fileInputRef = useRef(null); const [creatingFolder, setCreatingFolder] = useState(false); @@ -531,6 +569,15 @@ function Toolbar({ /> πŸ”’ Encrypt uploads +
void; + onUploadToFolder: (containerUrl: string, files: File[]) => void; viewMode: ViewMode; searchQuery: string; }) { @@ -680,6 +729,7 @@ function Listing({ driveRoot={driveRoot} containerUrl={containerUrl} onChanged={onChanged} + onUploadToFolder={onUploadToFolder} /> ))} @@ -694,27 +744,71 @@ function Listing({ driveRoot={driveRoot} containerUrl={containerUrl} onChanged={onChanged} + onUploadToFolder={onUploadToFolder} /> ))} ); } +/** + * Drag-and-drop handlers that let a folder row/tile accept files dropped onto + * it, uploading INTO that folder. We stop propagation so the page-level + * Dropzone (which uploads to the current folder) doesn't also fire. Non-folder + * entries opt out, letting the event bubble to the page Dropzone. + */ +function useFolderDrop( + isFolder: boolean, + folderUrl: string, + onUploadToFolder: (containerUrl: string, files: File[]) => void, +) { + const [over, setOver] = useState(false); + if (!isFolder) return { over: false, handlers: {} as Record }; + return { + over, + handlers: { + onDragOver: (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setOver(true); + }, + onDragLeave: (e: React.DragEvent) => { + if (e.currentTarget === e.target) setOver(false); + }, + onDrop: (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setOver(false); + if (e.dataTransfer.files.length > 0) { + onUploadToFolder(folderUrl, Array.from(e.dataTransfer.files)); + } + }, + }, + }; +} + function Tile({ entry, driveRoot, onChanged, + onUploadToFolder, }: { entry: PodEntry; driveRoot: string; containerUrl: string; onChanged: () => void; + onUploadToFolder: (containerUrl: string, files: File[]) => void; }) { const relPath = entry.url.slice(driveRoot.length); const href = entry.kind === "container" ? "/drive/" + relPath.replace(/\/$/, "") : "/drive/file/" + relPath; const [busy, setBusy] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); + const { over: dropOver, handlers: dropHandlers } = useFolderDrop( + entry.kind === "container", + entry.url, + onUploadToFolder, + ); async function doDelete() { setBusy(true); try { @@ -734,7 +828,10 @@ function Tile({ const isContainer = entry.kind === "container"; return (
  • @@ -797,20 +894,34 @@ function Row({ entry, driveRoot, onChanged, + onUploadToFolder, }: { entry: PodEntry; driveRoot: string; containerUrl: string; onChanged: () => void; + onUploadToFolder: (containerUrl: string, files: File[]) => void; }) { const relPath = entry.url.slice(driveRoot.length); const href = entry.kind === "container" ? "/drive/" + relPath.replace(/\/$/, "") : "/drive/file/" + relPath; + const isContainer = entry.kind === "container"; + // The user-facing name (decoded; encrypted files show their original name). + const displayedName = isEncryptedName(safeDecode(entry.name)) + ? originalNameFromEnc(safeDecode(entry.name)) + : safeDecode(entry.name); + // Renames edit the base only; the extension is locked so the format can't change. + const { base, ext } = splitExt(displayedName); const [busy, setBusy] = useState(false); const [renaming, setRenaming] = useState(false); - const [newName, setNewName] = useState(entry.name); + const [newBase, setNewBase] = useState(base); const [confirmOpen, setConfirmOpen] = useState(false); - const isContainer = entry.kind === "container"; + const { over: dropOver, handlers: dropHandlers } = useFolderDrop( + isContainer, + entry.url, + onUploadToFolder, + ); + const isImage = entry.kind === "resource" && isImageName(safeDecode(entry.name)); async function doDelete() { setBusy(true); @@ -835,14 +946,7 @@ function Row({ const res = await podFetch()(entry.url); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = entry.name; - document.body.appendChild(a); - a.click(); - a.remove(); - setTimeout(() => URL.revokeObjectURL(url), 1000); + saveBlob(blob, safeDecode(entry.name)); } catch (err) { toast.error(`Download failed: ${String(err)}`); } finally { @@ -852,10 +956,10 @@ function Row({ async function onRenameSubmit(e: React.FormEvent) { e.preventDefault(); - const trimmed = newName.trim(); - if (!trimmed || trimmed === entry.name) { + const nextDisplay = newBase.trim() + ext; + if (!newBase.trim() || nextDisplay === displayedName) { setRenaming(false); - setNewName(entry.name); + setNewBase(base); return; } if (entry.kind === "container") { @@ -865,9 +969,7 @@ function Row({ } setBusy(true); try { - const parent = entry.url.slice(0, entry.url.lastIndexOf("/") + 1); - const target = parent + encodeURIComponent(trimmed); - await rename(entry.url, target); + await renameFile({ fromUrl: entry.url, newDisplayName: nextDisplay }); setRenaming(false); onChanged(); } catch (err) { @@ -878,18 +980,32 @@ function Row({ } return ( -
  • - +
  • + {isImage ? ( + + + + ) : ( + + )} {renaming ? (
    setNewName(e.target.value)} + value={newBase} + onChange={(e) => setNewBase(e.target.value)} className="h-8 flex-1" data-testid={`rename-input-${entry.name}`} /> + {ext ? ( + {ext} + ) : null} @@ -899,7 +1015,7 @@ function Row({ variant="outline" onClick={() => { setRenaming(false); - setNewName(entry.name); + setNewBase(base); }} > Cancel diff --git a/src/components/FilePreview.tsx b/src/components/FilePreview.tsx index 81cc4be..0633dcf 100644 --- a/src/components/FilePreview.tsx +++ b/src/components/FilePreview.tsx @@ -10,6 +10,8 @@ import { ConfirmDialog } from "@/components/ConfirmDialog"; import PassphraseDialog from "@/components/PassphraseDialog"; import ShareDialog from "@/components/ShareDialog"; import { driveRootFor, normalizeSegment } from "@/lib/config"; +import { saveBlob } from "@/lib/download"; +import { splitExt } from "@/lib/filename"; import { ensureSession, rememberSignedOutPath } from "@/lib/solid/auth"; import { currentIdentity } from "@/lib/solid/broker"; import { @@ -21,7 +23,8 @@ import { setSessionPassphrase, sidecarUrlFor, } from "@/lib/solid/crypto"; -import { guessContentType, readFileBlob, rename, unlink } from "@/lib/solid/pod-fs"; +import { renameFile } from "@/lib/solid/file-ops"; +import { guessContentType, readFileBlob, unlink } from "@/lib/solid/pod-fs"; type State = | { kind: "booting" } @@ -48,20 +51,36 @@ type State = decryptedName?: string; }; -export default function FilePreview({ pathSegments }: { pathSegments: string[] }) { +export default function FilePreview({ + pathSegments, + srcUrl, +}: { + pathSegments: string[]; + /** + * Absolute pod URL of a file to view, from a `?src=` share link. When set, + * the viewer reads THIS resource (which may live in someone else's pod) + * instead of deriving the path from the signed-in user's own drive, and does + * not require a session β€” public resources fetch anonymously. The header is + * read-only in this mode (no rename/share/delete of a file you don't own). + */ + srcUrl?: string; +}) { const [state, setState] = useState({ kind: "booting" }); const load = useCallback(async () => { // Identity is brokered-first: inside the Mind shell it's the shell's webId + // workspace pod root (no local session); standalone it's the OIDC session. const id = currentIdentity(); - if (!id) { + if (!srcUrl && !id) { setState({ kind: "signed-out" }); return; } - const driveRoot = driveRootFor(id.podRoot); - const fileUrl = driveRoot + pathSegments.map(normalizeSegment).join("/"); - const leaf = safeDecode(pathSegments[pathSegments.length - 1] ?? ""); + const driveRoot = id ? driveRootFor(id.podRoot) : ""; + const fileUrl = srcUrl ?? driveRoot + pathSegments.map(normalizeSegment).join("/"); + const webId = id?.webId ?? ""; + const leaf = safeDecode( + srcUrl ? (srcUrl.split("/").pop() ?? "") : (pathSegments[pathSegments.length - 1] ?? ""), + ); try { const blob = await readFileBlob(fileUrl); if (isEncryptedName(leaf)) { @@ -76,7 +95,7 @@ export default function FilePreview({ pathSegments }: { pathSegments: string[] } ciphertext: blob, fileUrl, driveRoot, - webId: id.webId, + webId, }); return; } @@ -84,7 +103,7 @@ export default function FilePreview({ pathSegments }: { pathSegments: string[] } const plaintext = await decryptFile(passphrase, blob, sidecar); setState({ kind: "ready", - webId: id.webId, + webId, driveRoot, fileUrl, blob: plaintext, @@ -99,7 +118,7 @@ export default function FilePreview({ pathSegments }: { pathSegments: string[] } ciphertext: blob, fileUrl, driveRoot, - webId: id.webId, + webId, error: String(e), }); } @@ -108,7 +127,7 @@ export default function FilePreview({ pathSegments }: { pathSegments: string[] } const contentType = blob.type || guessContentType(leaf); setState({ kind: "ready", - webId: id.webId, + webId, driveRoot, fileUrl, blob, @@ -118,7 +137,7 @@ export default function FilePreview({ pathSegments }: { pathSegments: string[] } } catch (e) { setState({ kind: "error", message: String(e) }); } - }, [pathSegments]); + }, [pathSegments, srcUrl]); useEffect(() => { let cancelled = false; @@ -194,7 +213,7 @@ export default function FilePreview({ pathSegments }: { pathSegments: string[] } return ( -
    +
    ); @@ -258,41 +277,36 @@ function Crumbs({ pathSegments }: { pathSegments: string[] }) { function Header({ state, pathSegments, + readOnly = false, }: { state: Extract; pathSegments: string[]; onChanged: () => void; + /** True when viewing via a `?src=` share link β€” hide owner-only actions. */ + readOnly?: boolean; }) { const router = useRouter(); const urlLeaf = safeDecode(pathSegments[pathSegments.length - 1] ?? ""); const fileName = state.decryptedName ?? urlLeaf; const isEnc = !!state.decryptedName; + // Renames edit the base name only; the extension is locked so a rename can't + // change the file format. + const { base, ext } = splitExt(fileName); const [busy, setBusy] = useState(false); const [renaming, setRenaming] = useState(false); - const [newName, setNewName] = useState(fileName); + const [newBase, setNewBase] = useState(base); const [sharing, setSharing] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); - async function onDownload() { - setBusy(true); - try { - const url = URL.createObjectURL(state.blob); - const a = document.createElement("a"); - a.href = url; - a.download = fileName; - document.body.appendChild(a); - a.click(); - a.remove(); - setTimeout(() => URL.revokeObjectURL(url), 1000); - } finally { - setBusy(false); - } + function onDownload() { + saveBlob(state.blob, fileName); } async function doDelete() { setBusy(true); try { await unlink(state.fileUrl); + if (isEnc) await unlink(sidecarUrlFor(state.fileUrl)).catch(() => {}); const parentSegments = pathSegments.slice(0, -1); const parentHref = parentSegments.length === 0 @@ -307,21 +321,20 @@ function Header({ async function onRenameSubmit(e: React.FormEvent) { e.preventDefault(); - const trimmed = newName.trim(); - if (!trimmed || trimmed === fileName) { + const nextDisplay = newBase.trim() + ext; + if (!newBase.trim() || nextDisplay === fileName) { setRenaming(false); - setNewName(fileName); + setNewBase(base); return; } setBusy(true); try { - const parent = state.fileUrl.slice(0, state.fileUrl.lastIndexOf("/") + 1); - const target = parent + encodeURIComponent(trimmed); - await rename(state.fileUrl, target); + await renameFile({ fromUrl: state.fileUrl, newDisplayName: nextDisplay }); + const onPodLeaf = isEnc ? `${nextDisplay}.enc` : nextDisplay; const parentSegments = pathSegments.slice(0, -1); const href = "/drive/file/" + - [...parentSegments.map(normalizeSegment), encodeURIComponent(trimmed)].join("/"); + [...parentSegments.map(normalizeSegment), encodeURIComponent(onPodLeaf)].join("/"); router.replace(href); } catch (err) { toast.error(`Rename failed: ${String(err)}`); @@ -329,6 +342,13 @@ function Header({ } } + // Encrypted shares hand the recipient the in-app viewer link (which prompts + // for the passphrase) plus access to the sidecar, not the raw ciphertext URL. + const viewerShareLink = + typeof window !== "undefined" + ? `${window.location.origin}${window.location.pathname}?src=${encodeURIComponent(state.fileUrl)}` + : undefined; + return (
    @@ -337,11 +357,14 @@ function Header({ setNewName(e.target.value)} + value={newBase} + onChange={(e) => setNewBase(e.target.value)} className="h-9 flex-1" data-testid="file-rename-input" /> + {ext ? ( + {ext} + ) : null} @@ -351,7 +374,7 @@ function Header({ variant="outline" onClick={() => { setRenaming(false); - setNewName(fileName); + setNewBase(base); }} > Cancel @@ -373,34 +396,38 @@ function Header({ - - - + {!readOnly ? ( + <> + + + + + ) : null}
    ) : null} {sharing ? ( @@ -408,6 +435,9 @@ function Header({ resourceUrl={state.fileUrl} resourceName={fileName} onClose={() => setSharing(false)} + isEncrypted={isEnc} + sidecarUrl={isEnc ? sidecarUrlFor(state.fileUrl) : undefined} + shareLink={isEnc ? viewerShareLink : undefined} /> ) : null} void; + /** + * For encrypted files: the `.enc.json` sidecar. Access grants/revocations are + * applied to it alongside the ciphertext, so a recipient can fetch what they + * need to decrypt. Omitted for plaintext files. + */ + sidecarUrl?: string; + /** + * The URL surfaced as the public "shareable link". For encrypted files this + * is the in-app viewer URL (which prompts for the passphrase) rather than the + * raw ciphertext URL. Defaults to {@link resourceUrl}. + */ + shareLink?: string; + isEncrypted?: boolean; }) { + // Encrypted files share both the ciphertext and its sidecar. + const targets = isEncrypted && sidecarUrl ? [resourceUrl, sidecarUrl] : [resourceUrl]; + const linkToShare = shareLink ?? resourceUrl; + const linkRef = useRef(null); const [tab, setTab] = useState("webid"); const [webId, setWebId] = useState(""); const [grantedWebIds, setGrantedWebIds] = useState([]); @@ -62,7 +83,7 @@ export default function ShareDialog({ } setBusy(true); try { - await setAgentRead(resourceUrl, trimmed, true); + for (const t of targets) await setAgentRead(t, trimmed, true); const probe = await getAgentAccess(resourceUrl, trimmed); if (probe?.read) { setGrantedWebIds((prev) => (prev.includes(trimmed) ? prev : [...prev, trimmed])); @@ -83,7 +104,7 @@ export default function ShareDialog({ setConfirmation(null); setBusy(true); try { - await setAgentRead(resourceUrl, target, false); + for (const t of targets) await setAgentRead(t, target, false); setGrantedWebIds((prev) => prev.filter((w) => w !== target)); setConfirmation(`Revoked ${target}`); } catch (err) { @@ -98,7 +119,7 @@ export default function ShareDialog({ setConfirmation(null); setBusy(true); try { - await setPublicRead(resourceUrl, next); + for (const t of targets) await setPublicRead(t, next); const probe = await getPublicAccess(resourceUrl); setPublicReadState(Boolean(probe?.read)); setConfirmation(next ? "Now publicly readable" : "Public access revoked"); @@ -110,12 +131,18 @@ export default function ShareDialog({ } async function onCopyLink() { - try { - await navigator.clipboard.writeText(resourceUrl); + setError(null); + const ok = await copyText(linkToShare); + if (ok) { setCopyMsg("Copied!"); setTimeout(() => setCopyMsg(null), 2000); - } catch (err) { - setError(`Copy failed: ${String(err)}`); + } else { + // Both the Clipboard API and execCommand are blocked β€” leave the link + // selected so the user can copy it by hand. + linkRef.current?.focus(); + linkRef.current?.select(); + setCopyMsg("Press ⌘C / Ctrl+C to copy"); + setTimeout(() => setCopyMsg(null), 4000); } } @@ -218,18 +245,30 @@ export default function ShareDialog({

    Shareable URL

    -

    - {resourceUrl} -

    - +
    + e.currentTarget.select()} + className="h-8 flex-1 font-mono text-xs" + data-testid="share-public-url" + /> + +
    + {isEncrypted ? ( +

    + This file is encrypted. The link opens it in the viewer β€” the recipient + needs the passphrase you set to decrypt it. +

    + ) : null}
    ) : null}
  • diff --git a/src/components/VaultDialog.tsx b/src/components/VaultDialog.tsx new file mode 100644 index 0000000..656ea00 --- /dev/null +++ b/src/components/VaultDialog.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Input, + Label, +} from "@mind-studio/ui"; +import { Copy, ExternalLink, Eye, EyeOff } from "lucide-react"; +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; +import { copyText } from "@/lib/clipboard"; +import { getSessionPassphrase, setSessionPassphrase } from "@/lib/solid/crypto"; +import { loadVault, searchVaultEntries, type VaultEntry } from "@/lib/solid/vault"; + +/** + * The passphrase vault: an in-pod, encrypted index of the files you've + * encrypted, linking each to the passphrase that opens it. Unlocked with the + * same passphrase used to encrypt this session's files; if one is already held + * in memory the vault opens straight away. + */ +export default function VaultDialog({ + driveRoot, + onClose, +}: { + driveRoot: string; + onClose: () => void; +}) { + const [unlocked, setUnlocked] = useState(false); + const [pass, setPass] = useState(""); + const [entries, setEntries] = useState([]); + const [query, setQuery] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [revealed, setRevealed] = useState>(new Set()); + + const open = useCallback( + async (passphrase: string) => { + setBusy(true); + setError(null); + try { + const data = await loadVault(driveRoot, passphrase); + setEntries(data.entries.slice().reverse()); // newest first + setUnlocked(true); + setSessionPassphrase(passphrase); + } catch { + setError("Wrong passphrase, or the vault couldn't be opened."); + } finally { + setBusy(false); + } + }, + [driveRoot], + ); + + // If a passphrase is already in memory (e.g. after an encrypted upload), open + // the vault without re-prompting. + useEffect(() => { + const existing = getSessionPassphrase(); + if (existing) void open(existing); + }, [open]); + + const filtered = searchVaultEntries(entries, query); + + function toggleReveal(fileUrl: string) { + setRevealed((prev) => { + const next = new Set(prev); + if (next.has(fileUrl)) next.delete(fileUrl); + else next.add(fileUrl); + return next; + }); + } + + return ( + { + if (!o) onClose(); + }} + > + + +

    + Vault +

    + Encrypted files + + Passphrases for the files you've encrypted β€” stored encrypted in your own pod, never on + our servers. + +
    + + {!unlocked ? ( + { + e.preventDefault(); + if (pass.trim()) void open(pass); + }} + className="space-y-3" + > + + setPass(e.target.value)} + placeholder="Your encryption passphrase" + data-testid="vault-passphrase" + /> + + {error ? ( +

    + {error} +

    + ) : null} + + ) : ( +
    + setQuery(e.target.value)} + placeholder="Search vault…" + data-testid="vault-search" + /> + {filtered.length === 0 ? ( +

    + {entries.length === 0 + ? "No encrypted files yet. Turn on β€œπŸ”’ Encrypt uploads” and upload a file." + : "No entries match your search."} +

    + ) : ( +
      + {filtered.map((entry) => ( + toggleReveal(entry.fileUrl)} + onOpenFile={onClose} + /> + ))} +
    + )} +
    + )} +
    +
    + ); +} + +function VaultRow({ + entry, + driveRoot, + revealed, + onToggle, + onOpenFile, +}: { + entry: VaultEntry; + driveRoot: string; + revealed: boolean; + onToggle: () => void; + onOpenFile: () => void; +}) { + const rel = entry.fileUrl.startsWith(driveRoot) ? entry.fileUrl.slice(driveRoot.length) : null; + const href = rel ? `/drive/file/${rel}` : null; + const [copied, setCopied] = useState(false); + + async function onCopy() { + const ok = await copyText(entry.passphrase); + if (ok) { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + } + + return ( +
  • +
    + πŸ”’ {entry.name} + {href ? ( + + Open + + ) : null} +
    +

    + added {new Date(entry.addedAt).toLocaleString()} +

    +
    + + {revealed ? entry.passphrase : "β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’"} + + + +
    +
  • + ); +} diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts new file mode 100644 index 0000000..90a115a --- /dev/null +++ b/src/lib/clipboard.ts @@ -0,0 +1,38 @@ +"use client"; + +/** + * Copy text to the clipboard, resilient to the embedding frame's permissions + * policy. Inside the Mind shell, Drive runs in an iframe where the async + * Clipboard API (`navigator.clipboard.writeText`) is blocked by a + * `clipboard-write` permissions policy and throws NotAllowedError. The legacy + * `document.execCommand("copy")` path is NOT gated by that policy, so we fall + * back to it. Returns false only when both paths fail β€” callers should then + * leave the text selected for a manual copy. + */ +export async function copyText(text: string): Promise { + try { + if (navigator.clipboard?.writeText && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + /* clipboard API blocked by the frame β€” fall through to execCommand */ + } + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.position = "fixed"; + ta.style.top = "0"; + ta.style.left = "0"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + const ok = document.execCommand("copy"); + ta.remove(); + return ok; + } catch { + return false; + } +} diff --git a/src/lib/download.ts b/src/lib/download.ts new file mode 100644 index 0000000..02039af --- /dev/null +++ b/src/lib/download.ts @@ -0,0 +1,31 @@ +"use client"; + +import { isEmbedded } from "@/lib/solid/auth"; + +/** + * Save a Blob to the user's device. Standalone, this is the usual + * create-anchor-and-click. But inside the Mind shell, Drive runs in a + * sandboxed iframe where `` is silently blocked (no + * `allow-downloads`) β€” the click does nothing and the file never saves. There + * we open the blob in a new top-level tab instead, which escapes the sandbox so + * the user can view/save it. The object URL is revoked on a delay so the new + * tab has time to load it. + */ +export function saveBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + + if (isEmbedded()) { + window.open(url, "_blank", "noopener"); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + return; + } + + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 10_000); +} diff --git a/src/lib/filename.ts b/src/lib/filename.ts new file mode 100644 index 0000000..eac13c9 --- /dev/null +++ b/src/lib/filename.ts @@ -0,0 +1,22 @@ +/** + * Filename helpers shared by the browser and the file viewer. The key rule + * (issue: renaming must not let you change the format): renames edit the base + * name only; the final extension is locked. `splitExt` is how we keep them apart. + */ + +/** + * Split a filename into its base and its final extension (extension INCLUDES + * the leading dot, or is "" when there is none). Dotfiles (".env") and + * extensionless names return an empty extension so the whole thing stays + * editable. + * + * "photo.png" -> { base: "photo", ext: ".png" } + * "a.tar.gz" -> { base: "a.tar", ext: ".gz" } // only the last ext locks + * "README" -> { base: "README", ext: "" } + * ".env" -> { base: ".env", ext: "" } + */ +export function splitExt(name: string): { base: string; ext: string } { + const dot = name.lastIndexOf("."); + if (dot <= 0) return { base: name, ext: "" }; + return { base: name.slice(0, dot), ext: name.slice(dot) }; +} diff --git a/src/lib/solid/file-ops.ts b/src/lib/solid/file-ops.ts new file mode 100644 index 0000000..a74f081 --- /dev/null +++ b/src/lib/solid/file-ops.ts @@ -0,0 +1,46 @@ +"use client"; + +import { type EncryptedSidecar, isEncryptedName, sidecarUrlFor } from "@/lib/solid/crypto"; +import { readFileBlob, rename, unlink, writeFileBlob } from "@/lib/solid/pod-fs"; + +/** + * Rename a file, keeping its extension. The caller passes the new user-facing + * name WITH its (unchanged) extension, e.g. "holiday.png" β€” the UI locks the + * extension so the format can't be changed by a rename. + * + * For encrypted files (`.enc`) the on-pod leaf keeps its `.enc` suffix, + * and the `.enc.json` sidecar is moved alongside with its `originalName` + * updated β€” otherwise a rename would orphan the sidecar and the viewer would + * keep showing the old name. Returns the new resource URL. + */ +export async function renameFile(params: { + fromUrl: string; + /** New user-facing name WITH extension, e.g. "holiday.png" (never the `.enc`). */ + newDisplayName: string; +}): Promise { + const { fromUrl, newDisplayName } = params; + const parent = fromUrl.slice(0, fromUrl.lastIndexOf("/") + 1); + const enc = isEncryptedName(fromUrl); + const onPodLeaf = enc ? `${newDisplayName}.enc` : newDisplayName; + const toUrl = parent + encodeURIComponent(onPodLeaf); + if (toUrl === fromUrl) return fromUrl; + + if (enc) { + const fromSidecar = sidecarUrlFor(fromUrl); + const toSidecar = sidecarUrlFor(toUrl); + const sidecarBlob = await readFileBlob(fromSidecar); + const sidecar = JSON.parse(await sidecarBlob.text()) as EncryptedSidecar; + sidecar.originalName = newDisplayName; + await writeFileBlob( + toSidecar, + new Blob([JSON.stringify(sidecar)], { type: "application/json" }), + "application/json", + ); + await rename(fromUrl, toUrl); + await unlink(fromSidecar).catch(() => {}); + return toUrl; + } + + await rename(fromUrl, toUrl); + return toUrl; +} diff --git a/src/lib/solid/vault.ts b/src/lib/solid/vault.ts new file mode 100644 index 0000000..9919050 --- /dev/null +++ b/src/lib/solid/vault.ts @@ -0,0 +1,126 @@ +"use client"; + +/** + * Passphrase vault β€” a per-user, in-pod, encrypted index of the encrypted + * files in this drive. When you upload a file with encryption on, an entry is + * recorded here linking the file to the passphrase that unlocks it, so you can + * later search your vault and recover which passphrase opens which file. + * + * Privacy: the vault NEVER leaves the pod in plaintext. It is stored as a + * single encrypted resource using the same AES-GCM envelope as file uploads + * (see {@link encryptFile}), wrapped under the user's session passphrase β€” the + * same passphrase used to encrypt files this session. There is no central DB + * (privacy invariant #2): the vault lives only in the user's own pod. + * + * On the pod: + * .vault.enc β€” ciphertext of the vault JSON + * .vault.enc.json β€” the AES-GCM sidecar (iv, wrapped key, …) + * + * Both are hidden from the drive listing (the `.enc.json` suffix is filtered + * already; the `.vault.enc` file is filtered explicitly in DriveBrowser). + */ + +import { decryptFile, type EncryptedSidecar, encryptFile, sidecarUrlFor } from "@/lib/solid/crypto"; +import { readFileBlob, writeFileBlob } from "@/lib/solid/pod-fs"; + +export const VAULT_BASENAME = ".vault.enc"; + +export type VaultEntry = { + /** The `.enc` resource URL on the pod. */ + fileUrl: string; + /** Original (decrypted) filename, e.g. "photo.png". */ + name: string; + /** Passphrase that decrypts this file. */ + passphrase: string; + /** ISO timestamp the entry was recorded. */ + addedAt: string; +}; + +export type VaultData = { v: 1; entries: VaultEntry[] }; + +const EMPTY: VaultData = { v: 1, entries: [] }; + +export function vaultUrlFor(driveRoot: string): string { + return driveRoot + VAULT_BASENAME; +} + +export function isVaultName(name: string): boolean { + return name === VAULT_BASENAME || name === `${VAULT_BASENAME}.json`; +} + +/** + * Load and decrypt the vault. A missing vault (never created) resolves to an + * empty vault β€” that is not an error. A wrong passphrase rejects (so the caller + * can re-prompt), same as opening any encrypted file. + */ +export async function loadVault(driveRoot: string, passphrase: string): Promise { + const url = vaultUrlFor(driveRoot); + let ciphertext: Blob; + let sidecar: EncryptedSidecar; + try { + ciphertext = await readFileBlob(url); + const sidecarBlob = await readFileBlob(sidecarUrlFor(url)); + sidecar = JSON.parse(await sidecarBlob.text()) as EncryptedSidecar; + } catch { + // No vault yet (or sidecar missing) β€” start fresh. + return { ...EMPTY }; + } + const plaintext = await decryptFile(passphrase, ciphertext, sidecar); + const data = JSON.parse(await plaintext.text()) as VaultData; + if (data.v !== 1 || !Array.isArray(data.entries)) return { ...EMPTY }; + return data; +} + +/** Encrypt and write the vault back to the pod. */ +export async function saveVault( + driveRoot: string, + passphrase: string, + data: VaultData, +): Promise { + const url = vaultUrlFor(driveRoot); + const json = new Blob([JSON.stringify(data)], { type: "application/json" }); + const { ciphertext, sidecar } = await encryptFile(passphrase, json, VAULT_BASENAME); + await writeFileBlob(url, ciphertext, "application/octet-stream"); + const sidecarBlob = new Blob([JSON.stringify(sidecar)], { type: "application/json" }); + await writeFileBlob(sidecarUrlFor(url), sidecarBlob, "application/json"); +} + +/** + * Upsert one or more entries into the vault (keyed by fileUrl) and persist. + * Batched so a multi-file upload does one load + one save, not N of each. + */ +export async function addVaultEntries( + driveRoot: string, + passphrase: string, + entries: VaultEntry[], +): Promise { + if (entries.length === 0) return; + const data = await loadVault(driveRoot, passphrase); + for (const entry of entries) { + const idx = data.entries.findIndex((e) => e.fileUrl === entry.fileUrl); + if (idx >= 0) data.entries[idx] = entry; + else data.entries.push(entry); + } + await saveVault(driveRoot, passphrase, data); +} + +/** Remove an entry by its file URL and persist (no-op if absent). */ +export async function removeVaultEntry( + driveRoot: string, + passphrase: string, + fileUrl: string, +): Promise { + const data = await loadVault(driveRoot, passphrase); + const next = data.entries.filter((e) => e.fileUrl !== fileUrl); + if (next.length === data.entries.length) return; + await saveVault(driveRoot, passphrase, { ...data, entries: next }); +} + +/** Case-insensitive filter over entry name + file URL. */ +export function searchVaultEntries(entries: VaultEntry[], query: string): VaultEntry[] { + const q = query.trim().toLowerCase(); + if (!q) return entries; + return entries.filter( + (e) => e.name.toLowerCase().includes(q) || e.fileUrl.toLowerCase().includes(q), + ); +}