An encrypted personal cloud built on the AT Protocol.

Add web file download with client-side decryption [CL-147]

Fetch encrypted blobs from PDS, unwrap content key via WASM worker,
decrypt client-side, and trigger browser download. Extracted shared
authenticatedRequest core from api.ts for blob fetching. Context menu
on file items with configurable dropdown alignment.

+291 -34
+2
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html)s 13 13 14 14 ### Added 15 + - Add web file download with client-side decryption [#147](https://issues.opake.app/issues/147.html) 15 16 - Add web file browser with tag filtering [#145](https://issues.opake.app/issues/145.html) 16 17 - Add URL-based routing for cabinet directory navigation [#247](https://issues.opake.app/issues/247.html) 17 18 - Add ESLint, Prettier, and Immer to web frontend [#207](https://issues.opake.app/issues/207.html) ··· 75 76 - Update login command to read password from stdin [#112](https://issues.opake.app/issues/112.html) 76 77 77 78 ### Fixed 79 + - Fix tag filter bar showing tags from all directories instead of current view [#250](https://issues.opake.app/issues/250.html) 78 80 - Fix application startup to check account status [#194](https://issues.opake.app/issues/194.html) 79 81 - Fix web login on new account creating publicKey record without $bytes [#195](https://issues.opake.app/issues/195.html) 80 82 - Fix Identity type casing mismatch across WASM boundary [#203](https://issues.opake.app/issues/203.html)
+24 -5
web/src/components/DropdownMenu.tsx
··· 1 - import type { ComponentType, ReactNode } from "react"; 1 + import { useEffect, useRef, type ComponentType, type ReactNode } from "react"; 2 2 3 3 interface DropdownMenuItem { 4 4 readonly icon: ComponentType<{ readonly size: number; readonly className?: string }>; ··· 9 9 interface DropdownMenuProps { 10 10 readonly trigger: ReactNode; 11 11 readonly items: readonly DropdownMenuItem[]; 12 + readonly triggerClassName?: string; 13 + readonly align?: "left" | "right"; 12 14 } 13 15 14 - export function DropdownMenu({ trigger, items }: DropdownMenuProps) { 16 + export function DropdownMenu({ 17 + trigger, 18 + items, 19 + triggerClassName = "btn btn-neutral btn-sm gap-1.5 rounded-lg text-xs", 20 + align = "left", 21 + }: DropdownMenuProps) { 22 + const detailsRef = useRef<HTMLDetailsElement>(null); 23 + 24 + useEffect(() => { 25 + const handleClickOutside = (e: MouseEvent) => { 26 + if (detailsRef.current?.open && !detailsRef.current.contains(e.target as Node)) { 27 + detailsRef.current.removeAttribute("open"); 28 + } 29 + }; 30 + document.addEventListener("click", handleClickOutside); 31 + return () => document.removeEventListener("click", handleClickOutside); 32 + }, []); 33 + 15 34 return ( 16 - <details className="dropdown dropdown-end"> 17 - <summary className="btn btn-neutral btn-sm gap-1.5 rounded-lg text-xs">{trigger}</summary> 18 - <ul className="menu dropdown-content border-base-300/50 bg-base-100 shadow-panel-lg z-50 w-42 rounded-xl border p-1"> 35 + <details ref={detailsRef} className={`dropdown ${align === "left" ? "dropdown-end" : ""}`}> 36 + <summary className={triggerClassName}>{trigger}</summary> 37 + <ul className="menu dropdown-content border-base-300/50 bg-base-100 shadow-panel-lg z-[100] w-42 rounded-xl border p-1"> 19 38 {items.map(({ icon: Icon, label, onClick }) => ( 20 39 <li key={label}> 21 40 <button
+33
web/src/components/cabinet/FileActionMenu.tsx
··· 1 + import { DotsThreeVerticalIcon, DownloadSimpleIcon } from "@phosphor-icons/react"; 2 + import { DropdownMenu } from "@/components/DropdownMenu"; 3 + import type { FileItem } from "./types"; 4 + 5 + interface FileActionMenuProps { 6 + readonly item: FileItem; 7 + readonly downloading: boolean; 8 + readonly onDownload?: () => void; 9 + } 10 + 11 + export function FileActionMenu({ item, downloading, onDownload }: FileActionMenuProps) { 12 + const isFolder = item.kind === "folder"; 13 + if (isFolder || !item.decrypted || item.name === "[Keyring encrypted]") return null; 14 + 15 + if (downloading) { 16 + return ( 17 + <span 18 + className="loading loading-spinner loading-xs text-text-faint" 19 + role="status" 20 + aria-label={`Downloading ${item.name}`} 21 + /> 22 + ); 23 + } 24 + 25 + return ( 26 + <DropdownMenu 27 + triggerClassName="btn btn-ghost btn-xs btn-square rounded-md" 28 + trigger={<DotsThreeVerticalIcon size={24} weight="bold" className="text-base-content" />} 29 + align="right" 30 + items={[{ icon: DownloadSimpleIcon, label: "Download", onClick: onDownload }]} 31 + /> 32 + ); 33 + }
+13 -2
web/src/components/cabinet/FileGridCard.tsx
··· 1 1 import { LockIcon } from "@phosphor-icons/react"; 2 + import { FileActionMenu } from "./FileActionMenu"; 2 3 import { StatusBadge } from "./StatusBadge"; 3 4 import { fileIconElement, fileIconColors } from "./FileIcons"; 4 5 import type { FileItem } from "./types"; ··· 6 7 interface FileGridCardProps { 7 8 readonly item: FileItem; 8 9 readonly onClick: () => void; 10 + readonly onDownload?: () => void; 11 + readonly downloading?: boolean; 9 12 } 10 13 11 - export function FileGridCard({ item, onClick }: FileGridCardProps) { 14 + export function FileGridCard({ 15 + item, 16 + onClick, 17 + onDownload, 18 + downloading = false, 19 + }: FileGridCardProps) { 12 20 const { bg, text } = fileIconColors(item); 13 21 const isFolder = item.kind === "folder"; 14 22 ··· 40 48 <div className={`flex size-9.5 items-center justify-center rounded-[10px] ${bg} ${text}`}> 41 49 {fileIconElement(item, 17)} 42 50 </div> 43 - <LockIcon size={11} className="text-text-faint" /> 51 + <div className="flex items-center gap-1"> 52 + <FileActionMenu item={item} downloading={downloading} onDownload={onDownload} /> 53 + <LockIcon size={11} className="text-text-faint" /> 54 + </div> 44 55 </div> 45 56 46 57 {/* Encrypted preview area */}
+10 -2
web/src/components/cabinet/FileListRow.tsx
··· 1 1 import { CaretRightIcon } from "@phosphor-icons/react"; 2 + import { FileActionMenu } from "./FileActionMenu"; 2 3 import { StatusBadge } from "./StatusBadge"; 3 4 import { fileIconElement, fileIconColors } from "./FileIcons"; 4 5 import type { FileItem } from "./types"; ··· 6 7 interface FileListRowProps { 7 8 readonly item: FileItem; 8 9 readonly onClick: () => void; 10 + readonly onDownload?: () => void; 11 + readonly downloading?: boolean; 9 12 } 10 13 11 - export function FileListRow({ item, onClick }: FileListRowProps) { 14 + export function FileListRow({ item, onClick, onDownload, downloading = false }: FileListRowProps) { 12 15 const { bg, text } = fileIconColors(item); 13 16 const isFolder = item.kind === "folder"; 14 17 ··· 36 39 isFolder ? "cursor-pointer" : "" 37 40 }`} 38 41 > 42 + {/* Actions */} 43 + <div className="w-6"> 44 + <FileActionMenu item={item} downloading={downloading} onDownload={onDownload} /> 45 + </div> 46 + 39 47 {/* Icon */} 40 48 <div className={`flex size-8 shrink-0 items-center justify-center rounded-lg ${bg} ${text}`}> 41 49 {fileIconElement(item, 15)} ··· 82 90 </div> 83 91 )} 84 92 85 - {/* Status + actions */} 93 + {/* Status */} 86 94 <div className="flex shrink-0 items-center gap-2"> 87 95 <StatusBadge status={item.status} /> 88 96 </div>
+13 -1
web/src/components/cabinet/PanelContent.tsx
··· 7 7 readonly items: readonly FileItem[]; 8 8 readonly viewMode: "list" | "grid"; 9 9 readonly onOpen: (item: FileItem) => void; 10 + readonly onDownload: (uri: string) => void; 11 + readonly downloadingUris: ReadonlySet<string>; 10 12 } 11 13 12 - export function PanelContent({ items, viewMode, onOpen }: PanelContentProps) { 14 + export function PanelContent({ 15 + items, 16 + viewMode, 17 + onOpen, 18 + onDownload, 19 + downloadingUris, 20 + }: PanelContentProps) { 13 21 if (items.length === 0) { 14 22 return ( 15 23 <div className="hero py-16"> ··· 32 40 key={item.id} 33 41 item={item} 34 42 onClick={() => item.kind === "folder" && onOpen(item)} 43 + onDownload={() => onDownload(item.uri)} 44 + downloading={downloadingUris.has(item.uri)} 35 45 /> 36 46 ))} 37 47 </div> ··· 42 52 key={item.id} 43 53 item={item} 44 54 onClick={() => item.kind === "folder" && onOpen(item)} 55 + onDownload={() => onDownload(item.uri)} 56 + downloading={downloadingUris.has(item.uri)} 45 57 /> 46 58 ))} 47 59 </div>
+74 -20
web/src/lib/api.ts
··· 51 51 } 52 52 53 53 // --------------------------------------------------------------------------- 54 - // Authenticated XRPC (DPoP or Legacy) 54 + // Authenticated requests (DPoP or Legacy) — shared retry core 55 55 // --------------------------------------------------------------------------- 56 56 57 - interface AuthenticatedXrpcParams { 58 - pdsUrl: string; 59 - lexicon: string; 60 - method?: "GET" | "POST"; 61 - body?: unknown; 57 + interface AuthenticatedRequestParams { 58 + url: string; 59 + method: string; 60 + headers?: Record<string, string>; 61 + body?: string; 62 + label: string; 62 63 } 63 64 64 65 // eslint-disable-next-line sonarjs/cognitive-complexity -- legitimate retry/nonce dance with nested conditions; splitting would obscure the flow 65 - export async function authenticatedXrpc( 66 - params: AuthenticatedXrpcParams, 66 + async function authenticatedRequest( 67 + params: AuthenticatedRequestParams, 67 68 session: Session, 68 - ): Promise<unknown> { 69 - const { pdsUrl, lexicon, method = "GET", body } = params; 70 - const url = `${pdsUrl.replace(/\/$/, "")}/xrpc/${lexicon}`; 71 - const jsonBody = body ? JSON.stringify(body) : undefined; 69 + ): Promise<Response> { 70 + const { url, method, body, label } = params; 72 71 73 - const headers: Record<string, string> = { 74 - "Content-Type": "application/json", 75 - }; 72 + const headers: Record<string, string> = { ...params.headers }; 76 73 77 74 if (session.type === "oauth") { 78 75 await attachDpopAuth(headers, session, method, url); ··· 80 77 headers.Authorization = `Bearer ${session.accessJwt}`; 81 78 } 82 79 83 - let response = await fetch(url, { method, headers, body: jsonBody }); 80 + let response = await fetch(url, { method, headers, body }); 84 81 85 82 // DPoP nonce retry — the PDS has a different nonce than the AS. 86 83 if (session.type === "oauth" && requiresNonceRetry(response)) { ··· 88 85 if (nonce) { 89 86 session.dpopNonce = nonce; 90 87 await attachDpopAuth(headers, session, method, url); 91 - response = await fetch(url, { method, headers, body: jsonBody }); 88 + response = await fetch(url, { method, headers, body }); 92 89 } 93 90 } 94 91 ··· 98 95 const refreshed = await refreshAccessToken(session); 99 96 if (refreshed) { 100 97 await attachDpopAuth(headers, session, method, url); 101 - response = await fetch(url, { method, headers, body: jsonBody }); 98 + response = await fetch(url, { method, headers, body }); 102 99 103 100 // The refreshed token might also need a nonce retry on the PDS 104 101 if (requiresNonceRetry(response)) { ··· 106 103 if (nonce) { 107 104 session.dpopNonce = nonce; 108 105 await attachDpopAuth(headers, session, method, url); 109 - response = await fetch(url, { method, headers, body: jsonBody }); 106 + response = await fetch(url, { method, headers, body }); 110 107 } 111 108 } 112 109 } ··· 114 111 115 112 if (!response.ok) { 116 113 const detail = await response.text().catch(() => ""); 117 - throw new Error(`XRPC ${lexicon}: ${response.status} ${detail}`.trim()); 114 + throw new Error(`${label}: ${response.status} ${detail}`.trim()); 118 115 } 119 116 117 + return response; 118 + } 119 + 120 + // --------------------------------------------------------------------------- 121 + // Authenticated XRPC (JSON) 122 + // --------------------------------------------------------------------------- 123 + 124 + interface AuthenticatedXrpcParams { 125 + pdsUrl: string; 126 + lexicon: string; 127 + method?: "GET" | "POST"; 128 + body?: unknown; 129 + } 130 + 131 + export async function authenticatedXrpc( 132 + params: AuthenticatedXrpcParams, 133 + session: Session, 134 + ): Promise<unknown> { 135 + const { pdsUrl, lexicon, method = "GET", body } = params; 136 + const url = `${pdsUrl.replace(/\/$/, "")}/xrpc/${lexicon}`; 137 + 138 + const response = await authenticatedRequest( 139 + { 140 + url, 141 + method, 142 + headers: { "Content-Type": "application/json" }, 143 + body: body ? JSON.stringify(body) : undefined, 144 + label: `XRPC ${lexicon}`, 145 + }, 146 + session, 147 + ); 148 + 120 149 return response.json(); 150 + } 151 + 152 + // --------------------------------------------------------------------------- 153 + // Authenticated blob fetch (raw bytes) 154 + // --------------------------------------------------------------------------- 155 + 156 + interface BlobFetchParams { 157 + pdsUrl: string; 158 + did: string; 159 + cid: string; 160 + } 161 + 162 + export async function authenticatedBlobFetch( 163 + params: BlobFetchParams, 164 + session: Session, 165 + ): Promise<ArrayBuffer> { 166 + const { pdsUrl, did, cid } = params; 167 + const url = `${pdsUrl.replace(/\/$/, "")}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 168 + 169 + const response = await authenticatedRequest( 170 + { url, method: "GET", label: `getBlob ${cid}` }, 171 + session, 172 + ); 173 + 174 + return response.arrayBuffer(); 121 175 } 122 176 123 177 // ---------------------------------------------------------------------------
+61
web/src/lib/download.ts
··· 1 + // Download orchestration — fetch encrypted blob, decrypt client-side, trigger browser save. 2 + 3 + import { authenticatedBlobFetch } from "@/lib/api"; 4 + import { base64ToUint8Array } from "@/lib/encoding"; 5 + import { getCryptoWorker } from "@/lib/worker"; 6 + import { unwrapDirectContentKey, decryptEnvelope } from "@/stores/documents/decrypt"; 7 + import type { PdsRecord, DocumentRecord, DocumentMetadata } from "@/lib/pdsTypes"; 8 + import type { Session } from "@/lib/storageTypes"; 9 + 10 + export async function downloadDocument( 11 + record: PdsRecord<DocumentRecord>, 12 + pdsUrl: string, 13 + did: string, 14 + privateKey: Uint8Array, 15 + session: Session, 16 + ): Promise<void> { 17 + const { encryption } = record.value; 18 + if (encryption.$type !== "app.opake.document#directEncryption") { 19 + throw new Error("Keyring-encrypted downloads are not yet supported"); 20 + } 21 + 22 + const contentKey = await unwrapDirectContentKey(encryption, did, privateKey); 23 + 24 + // Fetch encrypted blob from PDS 25 + const cid = record.value.blob.ref.$link; 26 + const encryptedBlob = await authenticatedBlobFetch({ pdsUrl, did, cid }, session); 27 + 28 + // Decrypt the blob 29 + const worker = getCryptoWorker(); 30 + const blobNonce = base64ToUint8Array(encryption.envelope.nonce.$bytes); 31 + const plaintext = await worker.decryptBlob(contentKey, new Uint8Array(encryptedBlob), blobNonce); 32 + 33 + // Decrypt metadata for filename 34 + const { ciphertext: metaCiphertext, nonce: metaNonce } = decryptEnvelope( 35 + record.value.encryptedMetadata, 36 + ); 37 + const metadata: DocumentMetadata = await worker.decryptMetadata( 38 + contentKey, 39 + metaCiphertext, 40 + metaNonce, 41 + ); 42 + 43 + const filename = metadata.name; 44 + const mimeType = metadata.mimeType ?? "application/octet-stream"; 45 + 46 + triggerBrowserDownload(plaintext, filename, mimeType); 47 + } 48 + 49 + function triggerBrowserDownload(data: Uint8Array, filename: string, mimeType: string): void { 50 + const buffer = new ArrayBuffer(data.byteLength); 51 + new Uint8Array(buffer).set(data); 52 + const blob = new Blob([buffer], { type: mimeType }); 53 + const url = URL.createObjectURL(blob); 54 + const anchor = document.createElement("a"); 55 + // eslint-disable-next-line functional/immutable-data -- DOM side effect at system edge 56 + anchor.href = url; 57 + // eslint-disable-next-line functional/immutable-data -- DOM side effect at system edge 58 + anchor.download = filename; 59 + anchor.click(); 60 + URL.revokeObjectURL(url); 61 + }
+11 -1
web/src/routes/cabinet/files/$.tsx
··· 19 19 20 20 const ensureDirectoryDecrypted = useDocumentsStore((s) => s.ensureDirectoryDecrypted); 21 21 const viewMode = useDocumentsStore((s) => s.viewMode); 22 + const downloadFile = useDocumentsStore((s) => s.downloadFile); 23 + const downloadingUris = useDocumentsStore((s) => s.downloadingUris); 22 24 const items = useDocumentsStore(useShallow((s) => s.itemsForDirectory(currentDirectoryUri))); 23 25 24 26 useEffect(() => { ··· 35 37 }); 36 38 }; 37 39 38 - return <PanelContent items={items} viewMode={viewMode} onOpen={handleOpen} />; 40 + return ( 41 + <PanelContent 42 + items={items} 43 + viewMode={viewMode} 44 + onOpen={handleOpen} 45 + onDownload={(uri) => void downloadFile(uri)} 46 + downloadingUris={downloadingUris} 47 + /> 48 + ); 39 49 } 40 50 41 51 export const Route = createFileRoute("/cabinet/files/$")({
+11 -1
web/src/routes/cabinet/files/index.tsx
··· 10 10 const navigate = useNavigate(); 11 11 const ensureDirectoryDecrypted = useDocumentsStore((s) => s.ensureDirectoryDecrypted); 12 12 const viewMode = useDocumentsStore((s) => s.viewMode); 13 + const downloadFile = useDocumentsStore((s) => s.downloadFile); 14 + const downloadingUris = useDocumentsStore((s) => s.downloadingUris); 13 15 const items = useDocumentsStore(useShallow((s) => s.itemsForDirectory(null))); 14 16 15 17 useEffect(() => { ··· 23 25 }); 24 26 }; 25 27 26 - return <PanelContent items={items} viewMode={viewMode} onOpen={handleOpen} />; 28 + return ( 29 + <PanelContent 30 + items={items} 31 + viewMode={viewMode} 32 + onOpen={handleOpen} 33 + onDownload={(uri) => void downloadFile(uri)} 34 + downloadingUris={downloadingUris} 35 + /> 36 + ); 27 37 } 28 38 29 39 export const Route = createFileRoute("/cabinet/files/")({
+2 -2
web/src/stores/documents/decrypt.ts
··· 20 20 21 21 export type SetFn = (fn: (draft: ItemsDraft) => void) => void; 22 22 23 - function decryptEnvelope(envelope: EncryptedMetadataEnvelope): { 23 + export function decryptEnvelope(envelope: EncryptedMetadataEnvelope): { 24 24 readonly ciphertext: Uint8Array; 25 25 readonly nonce: Uint8Array; 26 26 } { ··· 30 30 }; 31 31 } 32 32 33 - async function unwrapDirectContentKey( 33 + export async function unwrapDirectContentKey( 34 34 encryption: Encryption & { readonly $type: "app.opake.document#directEncryption" }, 35 35 did: string, 36 36 privateKey: Uint8Array,
+37
web/src/stores/documents/store.ts
··· 14 14 DirectoryTreeSnapshot, 15 15 } from "@/lib/pdsTypes"; 16 16 import { rkeyFromUri } from "@/lib/atUri"; 17 + import { downloadDocument } from "@/lib/download"; 17 18 import { storage, fetchAllRecords } from "./fetch"; 18 19 import { decryptDocumentRecord, markDecryptionFailed } from "./decrypt"; 19 20 import { directoryItemFromSnapshot, documentPlaceholder, applyTagFilter } from "./file-items"; ··· 36 37 loading: boolean; 37 38 error: string | null; 38 39 activeTagFilters: string[]; 40 + downloadingUris: Set<string>; 39 41 viewMode: "list" | "grid"; 40 42 41 43 readonly fetchAll: () => Promise<void>; ··· 43 45 readonly itemsForDirectory: (directoryUri: string | null) => FileItem[]; 44 46 readonly setTagFilters: (tags: string[]) => void; 45 47 readonly setViewMode: (mode: "list" | "grid") => void; 48 + readonly downloadFile: (documentUri: string) => Promise<void>; 46 49 readonly ancestorsOf: (directoryUri: string | null) => readonly DirectoryAncestor[]; 47 50 } 48 51 ··· 59 62 loading: false, 60 63 error: null, 61 64 activeTagFilters: [], 65 + downloadingUris: new Set<string>(), 62 66 viewMode: "list", 63 67 64 68 fetchAll: async () => { ··· 221 225 set((draft) => { 222 226 draft.viewMode = mode; 223 227 }); 228 + }, 229 + 230 + downloadFile: async (documentUri: string) => { 231 + const authState = useAuthStore.getState(); 232 + if (authState.session.status !== "active") return; 233 + 234 + const record = get().documentRecords[documentUri]; 235 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 236 + if (!record) return; 237 + 238 + if (record.value.encryption.$type !== "app.opake.document#directEncryption") { 239 + console.warn("[documents] keyring-encrypted downloads not yet supported:", documentUri); 240 + return; 241 + } 242 + 243 + set((draft) => { 244 + draft.downloadingUris.add(documentUri); 245 + }); 246 + 247 + try { 248 + const { did, pdsUrl } = authState.session; 249 + const session = await storage.loadSession(did); 250 + const identity = await storage.loadIdentity(did); 251 + const privateKey = base64ToUint8Array(identity.private_key); 252 + 253 + await downloadDocument(record, pdsUrl, did, privateKey, session); 254 + } catch (error) { 255 + console.error("[documents] download failed:", documentUri, error); 256 + } finally { 257 + set((draft) => { 258 + draft.downloadingUris.delete(documentUri); 259 + }); 260 + } 224 261 }, 225 262 226 263 ancestorsOf: (directoryUri: string | null): readonly DirectoryAncestor[] => {