diff --git a/app/components/FileItem.tsx b/app/components/FileItem.tsx index 2d99afe..fe776bc 100644 --- a/app/components/FileItem.tsx +++ b/app/components/FileItem.tsx @@ -3,6 +3,7 @@ import { useState, useRef } from "react"; import { getFileIcon, formatFileSize, formatDate, type FileType } from "../lib/helpers"; import FileItemMenu from "./FileItemMenu"; +import type { AccessEntry, AccessResult } from "./FileList"; export type { FileType }; @@ -30,6 +31,7 @@ interface FileItemProps { onShare?: (file: FileItemData) => void; isSelected?: boolean; onContextMenu?: (file: FileItemData, event: React.MouseEvent) => void; + accessResult?: AccessResult | null | "loading"; } export default function FileItem({ @@ -46,6 +48,7 @@ export default function FileItem({ onShare, isSelected = false, onContextMenu, + accessResult, }: FileItemProps) { const [isHovered, setIsHovered] = useState(false); const clickCountRef = useRef(0); @@ -116,6 +119,184 @@ export default function FileItem({ lastTapRef.current = currentTime; }; + const [hoveredCategory, setHoveredCategory] = useState(null); + + type Category = { key: string; label: string; dotColor: string; chipClass: string; entries: AccessEntry[]; }; + + const buildCategories = (): { categories: Category[]; allInherited: boolean; someInherited: boolean } | null => { + if (accessResult === undefined || accessResult === "loading" || accessResult === null) return null; + const entries = accessResult.entries; + if (entries.length === 0) return { categories: [], allInherited: false, someInherited: false }; + + const publicEntries = entries.filter(e => e.isPublic); + const authenticatedEntries = entries.filter(e => e.isAuthenticated); + const agentEntries = entries.filter(e => !e.isPublic && !e.isAuthenticated); + + const categories: Category[] = []; + + if (publicEntries.length > 0) { + const modes = [...new Set(publicEntries.flatMap(e => e.modes))]; + categories.push({ + key: "public", + label: `Public: ${modes.join(", ")}`, + dotColor: "bg-red-400", + chipClass: "bg-red-50 text-red-800", + entries: publicEntries, + }); + } + + if (authenticatedEntries.length > 0) { + const modes = [...new Set(authenticatedEntries.flatMap(e => e.modes))]; + categories.push({ + key: "authenticated", + label: `Authenticated: ${modes.join(", ")}`, + dotColor: "bg-orange-400", + chipClass: "bg-orange-50 text-orange-800", + entries: authenticatedEntries, + }); + } + + if (agentEntries.length > 0) { + const modes = [...new Set(agentEntries.flatMap(e => e.modes))]; + categories.push({ + key: "agents", + label: `Shared: ${modes.join(", ")}`, + dotColor: "bg-purple-400", + chipClass: "bg-purple-50 text-purple-800", + entries: agentEntries, + }); + } + + return { + categories, + allInherited: entries.every(e => e.inherited), + someInherited: entries.some(e => e.inherited), + }; + }; + + const renderPermissions = () => { + if (accessResult === undefined) return null; + if (accessResult === "loading") { + return ( +
+
+
+ ); + } + if (accessResult === null) { + return error; + } + + const result = buildCategories(); + if (!result) return null; + const { categories, allInherited, someInherited } = result; + + if (categories.length === 0) { + return ( + + Private + + ); + } + + return ( +
+ {categories.map((cat) => ( + setHoveredCategory(cat.key)} + onMouseLeave={() => setHoveredCategory(null)} + > + + {cat.label} + + {hoveredCategory === cat.key && ( +
+ {cat.entries.map((entry) => ( +
+ {entry.rawAgent || entry.agent} + — {entry.modes.join(", ")} +
+ ))} +
+ )} +
+ ))} + {someInherited && ( + + {allInherited ? "(inherited)" : "(partly inherited)"} + + )} +
+ ); + }; + + const renderPermissionDots = () => { + if (accessResult === undefined) return null; + if (accessResult === "loading") { + return ( +
+
+
+ ); + } + if (accessResult === null) return null; + + const result = buildCategories(); + if (!result) return null; + const { categories } = result; + + if (categories.length === 0) { + return ( +
setHoveredCategory("private")} + onMouseLeave={() => setHoveredCategory(null)} + > + + {hoveredCategory === "private" && ( +
+ Private — only the owner +
+ )} +
+ ); + } + + return ( +
+ {categories.map((cat) => ( + setHoveredCategory(cat.key)} + onMouseLeave={() => setHoveredCategory(null)} + > + + {hoveredCategory === cat.key && ( +
+
{cat.label}
+ {cat.entries.map((entry) => ( +
+ {entry.rawAgent || entry.agent} + — {entry.modes.join(", ")} +
+ ))} +
+ )} +
+ ))} +
+ ); + }; + if (view === "grid") { return (
{file.name}

+ {renderPermissionDots()}
); } - // List view + // List view — always use grid layout for consistent alignment + const hasPermissions = accessResult !== undefined; + return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onClick={handleClick} @@ -179,30 +369,37 @@ export default function FileItem({ onContextMenu?.(file, event); }} > -
+
{getFileIcon(file.type, file.mimeType)}
-
+

{file.name}

-
+ {hasPermissions && ( +
+ {renderPermissions()} +
+ )} +
{file.lastModified && formatDate(file.lastModified)}
-
+
{file.size && formatFileSize(file.size)}
{isHovered && ( - +
+ +
)}
); diff --git a/app/components/FileList.tsx b/app/components/FileList.tsx index 74362ef..e531267 100644 --- a/app/components/FileList.tsx +++ b/app/components/FileList.tsx @@ -1,9 +1,13 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import FileItem, { FileItemData } from "./FileItem"; import Toolbar from "./shared/Toolbar"; import EmptyState from "./shared/EmptyState"; +import { getResourceAccessList, type AccessEntry, type AccessResult } from "../lib/helpers/acpUtils"; + +export type { AccessEntry, AccessResult }; +type PermissionsMap = Record; interface FileListProps { files: FileItemData[]; @@ -19,6 +23,8 @@ interface FileListProps { onFileShare?: (file: FileItemData) => void; selectedFileIds: string[]; onFileContextMenu?: (file: FileItemData, event: React.MouseEvent) => void; + showPermissions?: boolean; + onTogglePermissions?: () => void; } const VIEW_STORAGE_KEY = "solid-file-manager-view"; @@ -37,6 +43,8 @@ export default function FileList({ onFileShare, selectedFileIds, onFileContextMenu, + showPermissions, + onTogglePermissions, }: FileListProps) { const [view, setView] = useState<"grid" | "list">(() => { if (typeof window === "undefined") return "list"; @@ -44,6 +52,53 @@ export default function FileList({ return (stored === "grid" || stored === "list") ? stored : "list"; }); + const [permissionsState, setPermissionsState] = useState<{ path: string; map: PermissionsMap }>({ path: currentPath, map: {} }); + const fetchedUrlsRef = useRef>(new Set()); + + // Derive the current map, resetting if path changed + const permissionsMap = permissionsState.path === currentPath ? permissionsState.map : {}; + + // Fetch permissions for all visible files when toggle is on + useEffect(() => { + fetchedUrlsRef.current = new Set(); + + if (!showPermissions || files.length === 0) return; + + let cancelled = false; + + const fetchAll = async () => { + const toFetch = files.filter(f => !fetchedUrlsRef.current.has(f.url)); + if (toFetch.length === 0) return; + + // Mark all as loading + const loadingMap: PermissionsMap = {}; + for (const f of toFetch) { + loadingMap[f.url] = "loading"; + fetchedUrlsRef.current.add(f.url); + } + if (!cancelled) setPermissionsState({ path: currentPath, map: loadingMap }); + + // Fetch in parallel + await Promise.all( + toFetch.map(async (file) => { + try { + const resourceUrl = file.type === "folder" && !file.url.endsWith("/") + ? file.url + "/" + : file.url; + const list = await getResourceAccessList(resourceUrl); + if (!cancelled) setPermissionsState(prev => ({ ...prev, map: { ...prev.map, [file.url]: list } })); + } catch { + if (!cancelled) setPermissionsState(prev => ({ ...prev, map: { ...prev.map, [file.url]: null } })); + } + }) + ); + }; + + fetchAll(); + + return () => { cancelled = true; }; + }, [showPermissions, currentPath, files]); + useEffect(() => { localStorage.setItem(VIEW_STORAGE_KEY, view); }, [view]); @@ -54,6 +109,8 @@ export default function FileList({ view={view} onViewChange={setView} itemCount={files.length} + showPermissions={showPermissions} + onTogglePermissions={onTogglePermissions} /> {/* File List/Grid */} @@ -78,11 +135,23 @@ export default function FileList({ onShare={onFileShare} isSelected={selectedFileIds.includes(file.id)} onContextMenu={onFileContextMenu} + accessResult={showPermissions ? permissionsMap[file.url] : undefined} /> ))}
) : (
+ {showPermissions && ( +
+
+
Name
+
Permissions
+
Modified
+
Size
+
+ )} {files.map((file) => ( ))}
diff --git a/app/components/FileManager.tsx b/app/components/FileManager.tsx index 554af85..9d333df 100644 --- a/app/components/FileManager.tsx +++ b/app/components/FileManager.tsx @@ -99,6 +99,7 @@ export default function FileManager() { const [showShareSuccessModal, setShowShareSuccessModal] = useState(false); const [sharedResourceUrl, setSharedResourceUrl] = useState(""); const [sharedResourceName, setSharedResourceName] = useState(""); + const [showPermissions, setShowPermissions] = useState(false); const closeContextMenu = () => setContextMenuState(null); @@ -882,6 +883,8 @@ export default function FileManager() { onFileShare={handleShare} selectedFileIds={selectedFileIds} onFileContextMenu={handleFileContextMenu} + showPermissions={showPermissions} + onTogglePermissions={() => setShowPermissions(prev => !prev)} />
)} diff --git a/app/components/ShareDialog.tsx b/app/components/ShareDialog.tsx index 79cd11c..e58f08a 100644 --- a/app/components/ShareDialog.tsx +++ b/app/components/ShareDialog.tsx @@ -7,7 +7,7 @@ import UrlCombobox, { ComboboxOption } from "./shared/UrlCombobox"; import { FileItemData } from "./FileItem"; import { fetchUserContacts, Contact, addContactToProfile } from "../lib/helpers/contactUtils"; import { fetchAndParseProfile } from "../lib/helpers/profileUtils"; -import { getResourceAccessList, removeAccessFromResource } from "../lib/helpers/acpUtils"; +import { getResourceAccessList, removeAccessFromResource, type AccessResult } from "../lib/helpers/acpUtils"; import { UserIcon, MagnifyingGlassIcon, LockClosedIcon, XMarkIcon, CheckCircleIcon, TrashIcon } from "@heroicons/react/24/outline"; import LoadingSpinner from "./shared/LoadingSpinner"; @@ -39,7 +39,7 @@ export default function ShareDialog({ const [peopleChips, setPeopleChips] = useState([]); const [isAddingWebId, setIsAddingWebId] = useState(false); const [isSharing, setIsSharing] = useState(false); - const [accessList, setAccessList] = useState | null>(null); + const [accessResult, setAccessResult] = useState(null); const [isLoadingAccessList, setIsLoadingAccessList] = useState(false); const [removingWebId, setRemovingWebId] = useState(null); @@ -61,8 +61,8 @@ export default function ShareDialog({ setIsLoadingAccessList(true); const resourceUrl = file.type === "folder" && !file.url.endsWith("/") ? file.url + "/" : file.url; getResourceAccessList(resourceUrl) - .then((list) => { - setAccessList(list); + .then((result) => { + setAccessResult(result); setIsLoadingAccessList(false); }) .catch((error) => { @@ -74,7 +74,7 @@ export default function ShareDialog({ setWebIdInput(""); setSelectedAccessLevel("Editor"); setPeopleChips([]); - setAccessList(null); + setAccessResult(null); setRemovingWebId(null); } }, [isOpen, file]); @@ -88,8 +88,8 @@ export default function ShareDialog({ await removeAccessFromResource(resourceUrl, webIdToRemove); // Refresh the access list - const updatedList = await getResourceAccessList(resourceUrl); - setAccessList(updatedList); + const updatedResult = await getResourceAccessList(resourceUrl); + setAccessResult(updatedResult); } catch (error) { console.error("Failed to remove access:", error); } finally { @@ -212,8 +212,8 @@ export default function ShareDialog({ // Refresh access list after sharing if (file) { const resourceUrl = file.type === "folder" && !file.url.endsWith("/") ? file.url + "/" : file.url; - const updatedList = await getResourceAccessList(resourceUrl); - setAccessList(updatedList); + const updatedResult = await getResourceAccessList(resourceUrl); + setAccessResult(updatedResult); } onClose(); @@ -333,7 +333,7 @@ export default function ShareDialog({
{/* People with access section */} - {accessList && accessList.length > 0 && ( + {accessResult && accessResult.entries.length > 0 && (

People with access

@@ -342,30 +342,31 @@ export default function ShareDialog({
) : ( - accessList.map((access, index) => { - const hasWrite = access.accessModes.some((mode) => mode.includes("Write")); + accessResult.entries.map((access, index) => { + const hasWrite = access.modes.some((mode) => mode === "Write"); const accessLevel = hasWrite ? "Editor" : "Viewer"; - const isRemoving = removingWebId === access.webId; - + const displayLabel = access.isPublic ? "Anyone (Public)" : access.isAuthenticated ? "Authenticated users" : access.agent; + const isRemoving = removingWebId === access.agent; + return (
- {access.webId} + {displayLabel}
{accessLevel} - +
+ + {onTogglePermissions && ( + <> +
+ + + )} {actions &&
{actions}
} - +
{itemCount} {itemCount === 1 ? "item" : "items"}
diff --git a/app/lib/helpers/acpUtils.ts b/app/lib/helpers/acpUtils.ts index 1cff865..0533ab7 100644 --- a/app/lib/helpers/acpUtils.ts +++ b/app/lib/helpers/acpUtils.ts @@ -388,73 +388,424 @@ export async function verifyResourceAccess(resourceUrl: string): Promise<{ } } +export interface AccessEntry { + /** Canonical agent key: WebID URI, or "PUBLIC" / "AUTHENTICATED" for special agent classes */ + agent: string; + /** Original RDF URI of the agent (before normalization). Same as agent for regular WebIDs. */ + rawAgent: string; + /** Normalized mode names: "Read", "Write", "Append", "Control" */ + modes: string[]; + /** True if this grants access to everyone (foaf:Agent / acp public matcher) */ + isPublic: boolean; + /** True if this grants access to any authenticated agent */ + isAuthenticated: boolean; + /** True if these permissions are inherited from a parent container (not set directly on this resource) */ + inherited: boolean; +} + +/** Result of getResourceAccessList */ +export interface AccessResult { + entries: AccessEntry[]; + /** URL of the ACL/ACR document that was resolved (may be from a parent if inherited) */ + sourceUrl?: string; +} + +// Well-known URIs for public/authenticated agent classes (WAC + ACP variants) +const PUBLIC_AGENTS = new Set([ + "http://xmlns.com/foaf/0.1/Agent", // WAC: foaf:Agent + "http://www.w3.org/ns/solid/acp#PublicAgent", // ACP (ESS) + "http://www.w3.org/ns/solid/acp/acp#PublicAgent", // ACP alternate namespace +]); +const AUTHENTICATED_AGENTS = new Set([ + "http://www.w3.org/ns/auth/acl#AuthenticatedAgent", // WAC + "http://www.w3.org/ns/solid/acp#AuthenticatedAgent", // ACP (ESS) + "http://www.w3.org/ns/solid/acp/acp#AuthenticatedAgent", // ACP alternate namespace +]); + +function isPublicAgent(agent: string): boolean { + return PUBLIC_AGENTS.has(agent); +} + +function isAuthenticatedAgent(agent: string): boolean { + return AUTHENTICATED_AGENTS.has(agent); +} + +// WAC predicates +const WAC = { + Authorization: "http://www.w3.org/ns/auth/acl#Authorization", + agent: "http://www.w3.org/ns/auth/acl#agent", + agentClass: "http://www.w3.org/ns/auth/acl#agentClass", + mode: "http://www.w3.org/ns/auth/acl#mode", + accessTo: "http://www.w3.org/ns/auth/acl#accessTo", + default_: "http://www.w3.org/ns/auth/acl#default", +}; + /** - * Fetches the ACR for a resource to see who has access + * Normalizes a mode URI to a short human-readable name. + * Handles both WAC (http://www.w3.org/ns/auth/acl#Read) and ACP URIs. */ -export async function getResourceAccessList(resourceUrl: string): Promise | null> { +function normalizeMode(modeUri: string): string { + const fragment = modeUri.includes("#") ? modeUri.split("#").pop() : modeUri.split("/").pop(); + if (!fragment) return modeUri; + + const lower = fragment.toLowerCase(); + if (lower === "read") return "Read"; + if (lower === "write") return "Write"; + if (lower === "append") return "Append"; + if (lower === "control") return "Control"; + // ACP uses "mode" as a generic predicate value — not a real mode name + if (lower === "mode") return "Read"; + return fragment; +} + +/** + * Discovers the ACL/ACR URL from Link headers. Returns the URL as-is from the server. + * Does NOT guess ACP vs WAC from the URL — that is determined from the document content. + */ +function discoverAclUrl(linkHeader: string): string | null { + const aclMatch = linkHeader.match(/<([^>]+)>;\s*rel=["']acl["']/i); + return aclMatch ? aclMatch[1] : null; +} + +/** + * Detects whether a parsed RDF dataset contains ACP or WAC triples. + */ +function detectAuthType(dataset: Store): "acp" | "wac" | "unknown" { + const { namedNode } = DataFactory; + + // Check for ACP: look for acp:AccessControlResource type or acp:accessControl predicate + const acpTypeQuads = dataset.getQuads( + null, + namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + namedNode(ACP.AccessControlResource), + null, + ); + if (acpTypeQuads.length > 0) return "acp"; + + const acpControlQuads = dataset.getQuads(null, namedNode(ACP.accessControl), null, null); + if (acpControlQuads.length > 0) return "acp"; + + const acpMemberControlQuads = dataset.getQuads(null, namedNode(ACP.memberAccessControl), null, null); + if (acpMemberControlQuads.length > 0) return "acp"; + + // Check for WAC: look for acl:Authorization type + const wacTypeQuads = dataset.getQuads( + null, + namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + namedNode(WAC.Authorization), + null, + ); + if (wacTypeQuads.length > 0) return "wac"; + + return "unknown"; +} + +/** + * Computes the parent container URL. + * "http://ex.com/pod/folder/file.txt" → "http://ex.com/pod/folder/" + * "http://ex.com/pod/folder/" → "http://ex.com/pod/" + */ +function getParentContainerUrl(url: string): string | null { try { - const { fetch } = getAuthenticatedSession(); - const acrUrl = await getAcrUrl(resourceUrl, fetch); + const u = new URL(url); + // Remove trailing slash for containers so we can go up + let path = u.pathname; + if (path.endsWith("/") && path.length > 1) { + path = path.slice(0, -1); + } + const lastSlash = path.lastIndexOf("/"); + if (lastSlash <= 0) return null; // Already at root + u.pathname = path.slice(0, lastSlash + 1); + return u.toString(); + } catch { + return null; + } +} - const response = await fetch(acrUrl, { - method: "GET", - headers: { - Accept: "text/turtle", - }, +/** + * Collects agents from a set of WAC Authorization quads. + * scope: "accessTo" = direct rules for the resource, "default" = inherited rules for children. + */ +function collectWacAgents( + dataset: Store, + scope: "accessTo" | "default", + targetResourceUrl: string, + inherited: boolean, +): AccessEntry[] { + const { namedNode } = DataFactory; + const scopePredicate = scope === "accessTo" ? WAC.accessTo : WAC.default_; + + // Find all Authorization subjects + const authSubjects = dataset.getSubjects( + namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + namedNode(WAC.Authorization), + null + ); + + const agentMap = new Map; isPublic: boolean; isAuthenticated: boolean }>(); + + for (const authSubject of authSubjects) { + // Check if this authorization applies to our scope + const scopeObjects = dataset.getObjects(authSubject, namedNode(scopePredicate), null); + const applies = scopeObjects.some(o => { + if (scope === "accessTo") { + return o.value === targetResourceUrl; + } + return true; }); + if (scopeObjects.length > 0 && !applies) continue; + if (scopeObjects.length === 0) continue; + + const modeQuads = dataset.getObjects(authSubject, namedNode(WAC.mode), null); + const modes = modeQuads.map(m => normalizeMode(m.value)); + + // Direct agents + const agentQuads = dataset.getObjects(authSubject, namedNode(WAC.agent), null); + for (const agent of agentQuads) { + const key = agent.value; + if (!agentMap.has(key)) { + agentMap.set(key, { rawAgent: key, modes: new Set(), isPublic: false, isAuthenticated: false }); + } + for (const mode of modes) agentMap.get(key)!.modes.add(mode); + } - if (!response.ok) { - if (response.status === 404) { - return []; + // Agent classes + const classQuads = dataset.getObjects(authSubject, namedNode(WAC.agentClass), null); + for (const cls of classQuads) { + if (isPublicAgent(cls.value)) { + if (!agentMap.has("PUBLIC")) { + agentMap.set("PUBLIC", { rawAgent: cls.value, modes: new Set(), isPublic: true, isAuthenticated: false }); + } + for (const mode of modes) agentMap.get("PUBLIC")!.modes.add(mode); + } else if (isAuthenticatedAgent(cls.value)) { + if (!agentMap.has("AUTHENTICATED")) { + agentMap.set("AUTHENTICATED", { rawAgent: cls.value, modes: new Set(), isPublic: false, isAuthenticated: true }); + } + for (const mode of modes) agentMap.get("AUTHENTICATED")!.modes.add(mode); } - throw new Error(`Failed to fetch ACR: ${response.statusText}`); } + } - const turtle = await response.text(); + return [...agentMap.entries()].map(([agent, data]) => ({ + agent, + rawAgent: data.rawAgent, + modes: [...data.modes], + isPublic: data.isPublic, + isAuthenticated: data.isAuthenticated, + inherited, + })); +} - const dataset = new Store(); - dataset.addQuads(new Parser().parse(turtle)); - - const acr = new AccessControlResource(DataFactory.namedNode(acrUrl), dataset, DataFactory) - - const rawModesByWebId = - [...acr.accessControl].flatMap(ac => - [...ac.apply].flatMap(p => - [...p.anyOf].flatMap(m => - [...m.agent].flatMap(a => - ({ - webId: a, - accessModes: p.allow - }))))) - - const grouped = rawModesByWebId.reduce(groupByWebId, new Map) - return [...grouped].map(shape) +/** + * Fetches the access list for a resource, supporting both ACP and WAC. + * + * WAC inheritance: if the resource has no own .acl, walks up parent containers + * to find the nearest .acl with acl:default rules. Results are flagged inherited=true. + * + * ACP: fetches the ACR directly. If empty, walks up to find inherited policies. + */ +export async function getResourceAccessList(resourceUrl: string): Promise { + try { + const { session, fetch: fetchFn } = getAuthenticatedSession(); + const result = await resolveAccessList(resourceUrl, fetchFn, false); + if (!result) return null; + + // Filter out the current user's own WebID — it's standard for ACLs to include + // the owner with full access, but this is noise in the UI. + const currentWebId = session.info.webId; + if (currentWebId) { + result.entries = result.entries.filter(e => e.agent !== currentWebId); + } + + return result; } catch (error) { console.error("Failed to get resource access list:", error); return null; } } -function groupByWebId(previous: Map>, current: { webId: string, accessModes: Set }) { - if (!previous.has(current.webId)) { - previous.set(current.webId, new Set) +/** + * Core recursive resolver. Tries to fetch the ACL/ACR for resourceUrl; + * if it doesn't exist (404), walks up parent containers. + * + * Detection of ACP vs WAC is done by inspecting the fetched document's RDF content, + * NOT from the URL (ESS serves ACP content at .acl URLs). + */ +async function resolveAccessList( + resourceUrl: string, + fetchFn: typeof fetch, + isInheriting: boolean, + depth: number = 0, +): Promise { + if (depth > 10) return { entries: [] }; // Safety limit + + // HEAD the resource to discover ACL URL + const headResponse = await fetchFn(resourceUrl, { + method: "HEAD", + headers: { Accept: "*/*" }, + }); + + const linkHeader = headResponse.headers.get("Link") || ""; + const aclUrl = discoverAclUrl(linkHeader); + if (!aclUrl) { + return walkUpForInherited(resourceUrl, fetchFn, depth); + } + + // Fetch the ACL/ACR document + const response = await fetchFn(aclUrl, { + method: "GET", + headers: { Accept: "text/turtle" }, + }); + + if (!response.ok) { + // 404: no ACL/ACR exists, walk up for inherited. + // 403/other: user may not have permission to read the ACL — treat as unavailable. + if (response.status === 404 || response.status === 403) { + return walkUpForInherited(resourceUrl, fetchFn, depth); + } + // For other unexpected errors, log and return null rather than crashing the UI. + console.warn(`Could not fetch ACL/ACR (${response.status} ${response.statusText}) for ${aclUrl}`); + return null; } - for (const mode of current.accessModes) { - previous.get(current.webId)!.add(mode) + const turtle = await response.text(); + const dataset = new Store(); + dataset.addQuads(new Parser({ baseIRI: aclUrl }).parse(turtle)); + + // Detect ACP vs WAC from the document content + const authType = detectAuthType(dataset); + + if (authType === "acp") { + // ACP: parse accessControl + memberAccessControl + const result = parseAcpFromDataset(dataset, aclUrl, isInheriting); + if (result.entries.length > 0) { + return result; + } + // Empty ACP ACR — walk up for inherited + return walkUpForInherited(resourceUrl, fetchFn, depth); + } + + if (authType === "wac") { + // WAC: check for direct (accessTo) or default rules + if (!isInheriting) { + const directEntries = collectWacAgents(dataset, "accessTo", resourceUrl, false); + if (directEntries.length > 0) { + return { entries: directEntries, sourceUrl: aclUrl }; + } + // No direct rules — walk up to find inherited + return walkUpForInherited(resourceUrl, fetchFn, depth); + } else { + // Looking for acl:default rules (inherited from parent) + const defaultEntries = collectWacAgents(dataset, "default", resourceUrl, true); + if (defaultEntries.length > 0) { + return { entries: defaultEntries, sourceUrl: aclUrl }; + } + // Fallback: check accessTo on the container itself + const directOnContainer = collectWacAgents(dataset, "accessTo", resourceUrl, true); + if (directOnContainer.length > 0) { + return { entries: directOnContainer, sourceUrl: aclUrl }; + } + return walkUpForInherited(resourceUrl, fetchFn, depth); + } } - return previous + // Unknown auth type — document was fetched but contained neither ACP nor WAC triples + // This can happen with empty ACR documents on some servers + return walkUpForInherited(resourceUrl, fetchFn, depth); } -function shape(item: [string, Set]) { - return { - webId: item[0], - accessModes: [...item[1]] +/** + * Parses ACP entries from a dataset. Reads both acp:accessControl (direct) + * and acp:memberAccessControl (inherited by container members, used by ESS). + */ +function parseAcpFromDataset(dataset: Store, acrUrl: string, inherited: boolean): AccessResult { + const { namedNode } = DataFactory; + + // Collect AccessControl nodes from both predicates + const directControlQuads = dataset.getQuads(null, namedNode(ACP.accessControl), null, null); + const memberControlQuads = dataset.getQuads(null, namedNode(ACP.memberAccessControl), null, null); + + type RawEntry = { agent: string; modes: string[]; inherited: boolean }; + const rawEntries: RawEntry[] = []; + + const extractFromControls = (controlQuads: ReturnType, isInherited: boolean) => { + for (const cq of controlQuads) { + const controlNode = cq.object; + + // Get policies via acp:apply + const policyQuads = dataset.getQuads(controlNode, namedNode(ACP.apply), null, null); + for (const pq of policyQuads) { + const policyNode = pq.object; + + // Get allowed modes from the policy + const allowQuads = dataset.getQuads(policyNode, namedNode(ACP.allow), null, null); + const modes = allowQuads.map(aq => normalizeMode(aq.object.value)); + + // Get matchers via acp:anyOf + const matcherQuads = dataset.getQuads(policyNode, namedNode(ACP.anyOf), null, null); + for (const mq of matcherQuads) { + const matcherNode = mq.object; + + // Get agents from matcher + const agentQuads = dataset.getQuads(matcherNode, namedNode(ACP.agent), null, null); + for (const aq of agentQuads) { + rawEntries.push({ agent: aq.object.value, modes, inherited: isInherited }); + } + } + + // Some ACP implementations put acp:agent directly on the policy (without matchers) + const directAgentQuads = dataset.getQuads(policyNode, namedNode(ACP.agent), null, null); + for (const aq of directAgentQuads) { + rawEntries.push({ agent: aq.object.value, modes, inherited: isInherited }); + } + } + } + }; + + extractFromControls(directControlQuads, inherited); + extractFromControls(memberControlQuads, true); // memberAccessControl is always inherited + + // Group by agent URI + const grouped = new Map; inherited: boolean }>(); + for (const entry of rawEntries) { + if (!grouped.has(entry.agent)) { + grouped.set(entry.agent, { rawAgent: entry.agent, modes: new Set(), inherited: entry.inherited }); + } + const group = grouped.get(entry.agent)!; + for (const mode of entry.modes) { + group.modes.add(mode); + } + if (!entry.inherited) { + group.inherited = false; + } + } + + const entries: AccessEntry[] = [...grouped.entries()].map(([, data]) => { + const pub = isPublicAgent(data.rawAgent); + const auth = isAuthenticatedAgent(data.rawAgent); + return { + agent: pub ? "PUBLIC" : auth ? "AUTHENTICATED" : data.rawAgent, + rawAgent: data.rawAgent, + modes: [...data.modes], + isPublic: pub, + isAuthenticated: auth, + inherited: data.inherited, + }; + }); + + return { entries, sourceUrl: acrUrl }; +} + +async function walkUpForInherited( + resourceUrl: string, + fetchFn: typeof fetch, + depth: number, +): Promise { + const parentUrl = getParentContainerUrl(resourceUrl); + if (!parentUrl) { + return { entries: [] }; } + return resolveAccessList(parentUrl, fetchFn, true, depth + 1); } /** diff --git a/package-lock.json b/package-lock.json index 1e21177..acb447a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "react": "19.2.1", "react-dom": "19.2.1", "react-hot-toast": "^2.6.0", - "solid-react-component": "^0.2.1" + "solid-react-component": "^0.2.2" }, "devDependencies": { "@solid/community-server": "^8.0.0-alpha.1", @@ -6986,7 +6986,6 @@ "resolved": "https://registry.npmjs.org/@ldo/solid-react/-/solid-react-1.0.0-alpha.33.tgz", "integrity": "sha512-H7GN2SGWHsX1N5NF2c/lSbaPK3MLBZXtK6GvrXdWkR3I6kuUaZE6PsYMgx38CmJuIfh9dK4Vbz2+a36J30YMcg==", "license": "MIT", - "peer": true, "dependencies": { "@inrupt/solid-client-authn-browser": "^3.0.0", "@ldo/connected": "^1.0.0-alpha.32", @@ -16670,9 +16669,9 @@ } }, "node_modules/solid-react-component": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/solid-react-component/-/solid-react-component-0.2.1.tgz", - "integrity": "sha512-gENFQeJLeXqPo51UD+PRP8YTbflSsxwceiFAbv0c4DQM3fvbq6OtiHejWZsZoGrqHwd+Sh5h8SR4wGhN976rcg==", + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/solid-react-component/-/solid-react-component-0.2.8.tgz", + "integrity": "sha512-Tn3oA1ctUsmOKH1TLNzyRpRt/w8URZpG5W+8rS9AdLsBQOY1/7pJ/zoJXnrRxWIXSplgk/F/4rRQffzFE5230g==", "license": "MIT", "peerDependencies": { "@ldo/solid-react": ">=1.0.0-alpha.33",