+ {{ @@ -20,6 +23,12 @@ {{ normalizeTargetLabel(getPrimaryResourceUrl(item)) }} + + {{ getSharedWithMeStatusLabel(item) }} + @@ -54,6 +63,17 @@ {{ formatKind(item.whatKind) }} + + {{ + isSharedWithMeRevoked(item) ? "block" : "verified_user" + }} + + Status + + {{ getSharedWithMeStatusLabel(item) }} + + + @@ -96,12 +116,22 @@ v-for="(ac, acIndex) in normalizeModeList(item.usersSharedWith[0]?.accessModes)" :key="`mode-${acIndex}`" class="mode-chip" + :class="{ revoked: isSharedWithMeRevoked(item) }" :title="ac" >{{ formatMode(ac) }} + + + Access was revoked on + {{ formatDate(item.usersSharedWith[0]?.revokedAt || "N/A") }}. + @@ -119,7 +149,10 @@ - + {{ @@ -130,7 +163,7 @@ group - {{ getRecipientSummary(item) }} + {{ getRecipientStatusSummary(item) }} schedule @@ -174,6 +207,7 @@ @@ -187,7 +221,14 @@ schedule {{ formatDate(mode.created) }} + + {{ isRecipientRevoked(mode) ? "Revoked" : "Active" }} + + + + block + Revocation + + + {{ + isRecipientRevoked(mode) + ? `Revoked on ${formatDate(mode.revokedAt || "N/A")}` + : "Not revoked" + }} + + tag @@ -255,6 +309,7 @@ v-for="(ac, acIndex) in normalizeModeList(mode.accessModes)" :key="`${mode.sharedWith}-mode-${acIndex}`" class="mode-chip" + :class="{ revoked: isRecipientRevoked(mode) }" :title="ac" >{{ formatMode(ac) }} @@ -263,7 +318,10 @@ @@ -455,6 +513,22 @@ export default { copyText(text: string) { navigator.clipboard.writeText(text); }, + // SharedWithMe uses as:Offer/as:Undo rows; Undo rows represent revoked access. + isSharedWithMeRevoked(item: sharedSomething): boolean { + return Boolean(item.usersSharedWith[0]?.revoked); + }, + // SharedWithOthers rows carry revocation state from matched as:Undo entries. + isRecipientRevoked(mode: userHash): boolean { + return Boolean(mode.revoked); + }, + // Keep resource-level card state accurate even when one recipient was revoked. + hasActiveRecipients(item: sharedSomething): boolean { + return item.usersSharedWith.some((entry) => !this.isRecipientRevoked(entry)); + }, + // Compact state label used in collapsed/expanded sections. + getSharedWithMeStatusLabel(item: sharedSomething): string { + return this.isSharedWithMeRevoked(item) ? "Access revoked" : "Active access"; + }, /* Checks if the input item url is a container */ @@ -536,6 +610,9 @@ export default { return `${mode.sharedWith}-${mode.resourceUrl}-${mode.created}-${userIndex}`; }, startPermissionEdit(mode: userHash, userIndex: number) { + if (this.isRecipientRevoked(mode)) { + return; + } this.permissionEditError = ""; this.editedPermissions = this.permissionsFromModeIris(mode.accessModes); this.permissionRevokeDurationValue = null; @@ -691,6 +768,20 @@ export default { const count = item.usersSharedWith.length; return count === 1 ? "1 recipient" : `${count} recipients`; }, + // Show active/revoked counts so revoked permissions remain visible and understandable. + getRecipientStatusSummary(item: sharedSomething): string { + const revokedCount = item.usersSharedWith.filter((entry) => + this.isRecipientRevoked(entry) + ).length; + const activeCount = item.usersSharedWith.length - revokedCount; + if (revokedCount === 0) { + return `${this.getRecipientSummary(item)} active`; + } + if (activeCount === 0) { + return `${this.getRecipientSummary(item)} revoked`; + } + return `${activeCount} active, ${revokedCount} revoked`; + }, // Find the newest created timestamp among all recipients of one resource. getLatestEntryDate(item: sharedSomething): string { const times = item.usersSharedWith @@ -827,6 +918,9 @@ export default { display: grid; gap: 0.58rem; } +.shared-list > li { + min-width: 0; +} .shared-entry { border: 1px solid color-mix(in srgb, var(--border) 84%, var(--primary) 16%); border-radius: 14px; @@ -843,6 +937,14 @@ export default { .shared-entry.expanded { border-color: color-mix(in srgb, var(--primary) 34%, var(--border)); } +.shared-entry.revoked-entry { + border-color: color-mix(in srgb, var(--border) 92%, var(--text-muted) 8%); + background: color-mix(in srgb, var(--panel) 96%, var(--panel-elev) 4%); +} +.shared-entry.revoked-entry:hover { + border-color: color-mix(in srgb, var(--border) 90%, var(--text-muted) 10%); + background: color-mix(in srgb, var(--panel) 94%, var(--panel-elev) 6%); +} .entry-toggle { width: 100%; @@ -856,6 +958,7 @@ export default { cursor: pointer; font-family: "Oxanium", monospace; color: var(--text-secondary); + min-width: 0; } .entry-main { display: inline-flex; @@ -870,10 +973,13 @@ export default { min-width: 0; } .shared-others-collapsed-copy { - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; align-items: center; - gap: 0.8rem; + column-gap: 0.8rem; + row-gap: 0.24rem; width: 100%; + min-width: 0; } .shared-others-collapsed-copy .entry-title { flex: 1 1 auto; @@ -884,15 +990,19 @@ export default { align-items: center; justify-content: flex-end; gap: 0.7rem; - flex: 0 0 auto; + flex: 1 1 auto; + min-width: 0; + max-width: 100%; color: var(--text-muted); font-size: var(--font-size-page-summary); - white-space: nowrap; + white-space: normal; + flex-wrap: wrap; } .entry-inline-summary span { display: inline-flex; align-items: center; gap: 0.28rem; + min-width: 0; } .entry-copy-equalized { min-height: 2.8rem; @@ -913,6 +1023,26 @@ export default { color: var(--text-muted); line-height: 1.35; } +.entry-status { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + border: 1px solid color-mix(in srgb, var(--success) 45%, var(--border) 55%); + border-radius: 999px; + background: color-mix(in srgb, var(--success) 12%, var(--panel-elev) 88%); + color: color-mix(in srgb, var(--success) 80%, var(--text-primary) 20%); + font-size: var(--font-size-section-kicker); + font-weight: 700; + line-height: 1.2; + letter-spacing: 0.03em; + padding: 0.16rem 0.44rem; +} +.entry-status.revoked { + border-color: color-mix(in srgb, var(--border) 78%, var(--text-muted) 22%); + background: color-mix(in srgb, var(--panel) 84%, var(--panel-elev) 16%); + color: var(--text-muted); +} .info-icon { color: var(--text-muted); flex: 0 0 auto; @@ -1043,6 +1173,16 @@ export default { color: var(--text-secondary); overflow-wrap: anywhere; } +.shared-revoked-note { + margin: 0; + border: 1px solid color-mix(in srgb, var(--border) 80%, var(--text-muted) 20%); + border-radius: 10px; + background: color-mix(in srgb, var(--panel) 96%, var(--panel-elev) 4%); + color: var(--text-muted); + font-size: var(--font-size-page-summary); + font-weight: 700; + padding: 0.42rem 0.52rem; +} .mono { font-family: "Oxanium", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } @@ -1067,6 +1207,11 @@ export default { font-size: 0.76rem; font-weight: 700; } +.mode-chip.revoked { + border-color: color-mix(in srgb, var(--border) 80%, var(--text-muted) 20%); + background: color-mix(in srgb, var(--panel) 94%, var(--panel-elev) 6%); + color: var(--text-muted); +} .recipient-list { display: grid; @@ -1079,15 +1224,20 @@ export default { padding: 0.62rem; display: grid; gap: 0.58rem; + min-width: 0; +} +.recipient-card.revoked-recipient { + border-color: color-mix(in srgb, var(--border) 88%, var(--text-muted) 12%); + background: color-mix(in srgb, var(--panel) 96%, var(--panel-elev) 4%); } .recipient-header { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; gap: 0.45rem 0.7rem; padding-bottom: 0.52rem; border-bottom: 1px solid color-mix(in srgb, var(--border) 74%, transparent); + min-width: 0; } .recipient-target, .recipient-date { @@ -1098,19 +1248,24 @@ export default { font-size: var(--font-size-page-summary); } .recipient-target { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; color: var(--text-primary); font-weight: 700; + overflow-wrap: anywhere; } .recipient-date { color: var(--text-muted); } .recipient-actions { - display: inline-flex; + display: flex; align-items: center; justify-content: flex-end; gap: 0.55rem; flex-wrap: wrap; min-width: 0; + max-width: 100%; } .recipient-body { display: grid; @@ -1122,7 +1277,7 @@ export default { border: 1px solid color-mix(in srgb, var(--border) 78%, var(--primary) 22%); border-radius: 999px; background: color-mix(in srgb, var(--panel-elev) 90%, transparent); - color: var(--text-secondary); + color: var(--text-muted); display: inline-flex; align-items: center; justify-content: center; @@ -1148,7 +1303,7 @@ export default { .permission-save-button { background: linear-gradient(135deg, var(--primary), var(--primary-strong)); border-color: color-mix(in srgb, var(--primary) 62%, var(--border)); - color: var(--primary-contrast); + color: var(--text-primary); } .permission-save-button:disabled { cursor: wait; @@ -1157,12 +1312,7 @@ export default { .permission-editor { border: 1px solid color-mix(in srgb, var(--primary) 24%, var(--border)); border-radius: 12px; - background: - linear-gradient( - 135deg, - color-mix(in srgb, var(--panel-elev) 94%, var(--primary) 6%), - color-mix(in srgb, var(--panel) 96%, var(--primary) 4%) - ); + background: var(--panel-elev); display: grid; gap: 0.68rem; padding: 0.72rem; @@ -1296,6 +1446,7 @@ export default { .shared-others-collapsed-copy { display: grid; gap: 0.24rem; + grid-template-columns: minmax(0, 1fr); } .entry-inline-summary { justify-content: flex-start; @@ -1314,6 +1465,21 @@ export default { } } +@media (max-width: 1160px) { + .shared-others-collapsed-copy { + grid-template-columns: minmax(0, 1fr); + } + .entry-inline-summary { + justify-content: flex-start; + } + .recipient-header { + grid-template-columns: minmax(0, 1fr); + } + .recipient-actions { + justify-content: flex-start; + } +} + @media (max-width: 520px) { .permission-options { grid-template-columns: 1fr; diff --git a/src/services/query/queryPod.ts b/src/services/query/queryPod.ts index 9912691..a7193aa 100644 --- a/src/services/query/queryPod.ts +++ b/src/services/query/queryPod.ts @@ -9,7 +9,7 @@ import { saveSolidDatasetAt, createSolidDataset, createContainerAt, - saveFileInContainer, + overwriteFile, createThing, buildThing, setThing, @@ -126,6 +126,25 @@ export interface CoiFetchOptions { onError?: (e: unknown) => void; } +interface HttpFetchIssue { + url: string; + status: number; + statusText: string; +} + +/** + * Returns the Solid SDK-authenticated fetch used across query/cache operations. + * + * We intentionally rely on the module-level `fetch` export from + * `@inrupt/solid-client-authn-browser` because it is the most stable auth + * bridge across redirects and route transitions in this app. Earlier + * conditional session switching caused `queries.ttl` reads to intermittently + * fall back to unauthorized requests, breaking Past Queries loading. + */ +function getSolidAuthenticatedFetch(): FetchLike { + return fetch; +} + export type QueryExecutionMode = | "endpoint" | "solid-no-traversal" @@ -184,7 +203,7 @@ export const QUERY_MODE_DEFINITIONS: QueryModeDefinition[] = [ * @returns A new array of cleaned source URLs without angle brackets. */ export function cleanSourcesUrls(dirtySources: string[]): ComunicaSources[] { - return cleanSourcesUrlsInternal(dirtySources, fetch); + return cleanSourcesUrlsInternal(dirtySources, getSolidAuthenticatedFetch() as typeof fetch); } export function isQueryExecutionMode(modeLike: string): modeLike is QueryExecutionMode { @@ -294,6 +313,22 @@ function getIndexResourceUrl(containerUrl: string, fileName = "queries.ttl"): st return `${containerUrl}${fileName}`; } +function ensureTrailingSlash(url: string): string { + return url.endsWith("/") ? url : `${url}/`; +} + +/** + * Builds a deterministic cache member file URL inside a container. + * This avoids server-dependent POST+Slug handling and keeps member URLs explicit. + */ +export function buildCacheMemberFileUrl( + containerUrl: string, + fileName: string +): string { + const normalizedContainerUrl = ensureTrailingSlash(containerUrl.trim()); + return `${normalizedContainerUrl}${fileName}`; +} + function getQueryEntryUrl( containerUrl: string, hash: string, @@ -443,19 +478,86 @@ async function streamBindingsToOutput( * contexts where response header normalization is needed. */ function createAuthenticatedQueryFetch(options: { noCors: boolean }): FetchLike { - return createCoiFetch(fetch, { + const baseFetch: FetchLike = getSolidAuthenticatedFetch(); + + return createCoiFetch(baseFetch, { coepCredentialless: false, passthroughOpaque: true, noCors: options.noCors, }); } -function createSolidQueryContext(mixedSources: ComunicaSources[]): Record { +function getFetchInputUrl(input: RequestInfo | URL): string { + if (typeof input === "string") { + return input; + } + if (input instanceof URL) { + return input.href; + } + if (typeof Request !== "undefined" && input instanceof Request) { + return input.url; + } + return String(input); +} + +function isBlockingHttpStatus(status: number): boolean { + return status >= 400; +} + +function summarizeBlockingHttpIssues( + issues: HttpFetchIssue[], + mode: QueryExecutionMode +): string | null { + const blockingIssues = issues.filter((issue) => + isBlockingHttpStatus(issue.status) + ); + if (blockingIssues.length === 0) { + return null; + } + + const uniqueIssues = Array.from( + new Map( + blockingIssues.map((issue) => [ + `${issue.status}|${issue.url}`, + `${issue.status} ${issue.statusText || ""}`.trim() + + ` at ${issue.url}`, + ]) + ).values() + ); + + return `HTTP request error(s) detected during ${mode} query execution: ${uniqueIssues.join( + " | " + )}`; +} + +function createHttpIssueTrackingFetch( + baseFetch: FetchLike, + issues: HttpFetchIssue[] +): FetchLike { + return async (input, init) => { + const response = await baseFetch(input, init); + if (response && isBlockingHttpStatus(response.status)) { + issues.push({ + url: response.url || getFetchInputUrl(input), + status: response.status, + statusText: response.statusText || "", + }); + } + return response; + }; +} + +function createSolidQueryContext( + mixedSources: ComunicaSources[], + httpIssues: HttpFetchIssue[] +): Record { const session = getDefaultSession(); const hasAuthenticatedSession = Boolean(session.info.isLoggedIn); - const authenticatedFetch: FetchLike = hasAuthenticatedSession - ? session.fetch.bind(session) - : fetch; + const authenticatedFetchBase: FetchLike = getSolidAuthenticatedFetch(); + const authenticatedFetch = createHttpIssueTrackingFetch( + authenticatedFetchBase, + httpIssues + ); // Per Comunica Solid docs, provide the authn session in context so the Solid // HTTP actor can attach credentials for protected pod resources. @@ -475,9 +577,13 @@ function createSolidQueryContext(mixedSources: ComunicaSources[]): Record { - const authenticatedFetch = createAuthenticatedQueryFetch({ noCors: false }); + const authenticatedFetch = createHttpIssueTrackingFetch( + createAuthenticatedQueryFetch({ noCors: false }), + httpIssues + ); return { lenient: true, fetch: authenticatedFetch, @@ -503,13 +609,21 @@ async function executeEndpointCacheLookup( cachePath: string ): Promise { const endpointEngine = new SparqlEngineCache(); + const httpIssues: HttpFetchIssue[] = []; try { - return await executeWithEngine( + const output = await executeWithEngine( endpointEngine, inputQuery, - createEndpointCacheContext(mixedSources, cachePath), + createEndpointCacheContext(mixedSources, cachePath, httpIssues), { includeProvenance: true } ); + // A cache hit that resolves to an empty set can be caused by hard HTTP + // errors against one or more remote sources. Surface those explicitly. + const httpIssueMessage = summarizeBlockingHttpIssues(httpIssues, "endpoint"); + if (httpIssueMessage && output.resultsOutput.results.bindings.length === 0) { + throw new Error(httpIssueMessage); + } + return output; } catch { return "no-cache"; } @@ -519,13 +633,22 @@ async function executeSolidNoTraversalQuery( inputQuery: string, mixedSources: ComunicaSources[] ): Promise { + const httpIssues: HttpFetchIssue[] = []; try { const solidEngine = new SolidQueryEngine(); - return await executeWithEngine( + const output = await executeWithEngine( solidEngine, inputQuery, - createSolidQueryContext(mixedSources) + createSolidQueryContext(mixedSources, httpIssues) + ); + const httpIssueMessage = summarizeBlockingHttpIssues( + httpIssues, + "solid-no-traversal" ); + if (httpIssueMessage && output.resultsOutput.results.bindings.length === 0) { + return new Error(httpIssueMessage); + } + return output; } catch (err) { return err instanceof Error ? err : new Error(String(err)); } @@ -535,12 +658,21 @@ async function executeSolidLinkTraversalQuery( inputQuery: string, mixedSources: ComunicaSources[] ): Promise { + const httpIssues: HttpFetchIssue[] = []; try { - return await executeWithEngine( + const output = await executeWithEngine( new SolidLinkTraversalQueryEngine(), inputQuery, - createSolidQueryContext(mixedSources) + createSolidQueryContext(mixedSources, httpIssues) + ); + const httpIssueMessage = summarizeBlockingHttpIssues( + httpIssues, + "solid-link-traversal" ); + if (httpIssueMessage && output.resultsOutput.results.bindings.length === 0) { + return new Error(httpIssueMessage); + } + return output; } catch (err) { return err instanceof Error ? err : new Error(String(err)); } @@ -623,12 +755,12 @@ export async function ensureCacheContainer( try { // Try to retrieve the dataset (container) - await getSolidDataset(cacheUrl, { fetch }); + await getSolidDataset(cacheUrl, { fetch: getSolidAuthenticatedFetch() }); return cacheUrl; } catch (error) { // If not found, create the container (if it is the users pod in question) if (providedCache === podUrl) { - await createContainerAt(cacheUrl, { fetch }); + await createContainerAt(cacheUrl, { fetch: getSolidAuthenticatedFetch() }); console.log(`Query Cache container was created at ${cacheUrl}`); return cacheUrl; @@ -646,10 +778,10 @@ export async function ensureCacheContainer( * - head: A Thing representing the head of the RDF list. * - nodes: An array of all list node Things (to be added to your dataset). */ -function buildRdfList(sources: string[]): { head: Thing; nodes: Thing[] } { +export function buildRdfList(sources: string[]): { head: Thing; nodes: Thing[] } { if (sources.length === 0) { throw new Error( - "Cannot create a cache entry without at least one endpoint source." + "Cannot create a cache entry without at least one source URI." ); } @@ -657,6 +789,12 @@ function buildRdfList(sources: string[]): { head: Thing; nodes: Thing[] } { let listNode = createThing(); // creates a blank node automatically listNode = buildThing(listNode).addIri(RDF_FIRST, sources[0]).build(); + // Base case: a single source terminates the RDF list with rdf:nil. + if (sources.length === 1) { + listNode = buildThing(listNode).addIri(RDF_REST, RDF_NIL).build(); + return { head: listNode, nodes: [listNode] }; + } + // Recursively build the rest of the list. const restList = buildRdfList(sources.slice(1)); @@ -742,7 +880,7 @@ export async function upsertQueryCacheEntry( let dataset: SolidDataset; try { dataset = await getSolidDataset(getIndexResourceUrl(containerUrl, fileName), { - fetch, + fetch: getSolidAuthenticatedFetch(), }); } catch { dataset = createSolidDataset(); @@ -817,7 +955,7 @@ export async function upsertQueryCacheEntry( }); await saveSolidDatasetAt(getIndexResourceUrl(containerUrl, fileName), updatedDataset, { - fetch, + fetch: getSolidAuthenticatedFetch(), }); return entry.hash; @@ -891,12 +1029,12 @@ export async function uploadQueryFile( ): Promise { const fileName = hashName + ".rq"; const blob = new Blob([query], { type: "application/sparql-query" }); + const fileUrl = buildCacheMemberFileUrl(containerUrl, fileName); try { - const savedFile = await saveFileInContainer(containerUrl, blob, { - slug: fileName, + const savedFile = await overwriteFile(fileUrl, blob, { contentType: "application/sparql-query", - fetch, + fetch: getSolidAuthenticatedFetch(), }); console.log( `Uploaded ${fileName} to ${savedFile.internal_resourceInfo.sourceIri}` @@ -941,12 +1079,12 @@ export async function uploadResults( const blob = new Blob([jsonString], { type: "application/sparql-results+json", }); + const fileUrl = buildCacheMemberFileUrl(containerUrl, fileName); try { - const savedFile = await saveFileInContainer(containerUrl, blob, { - slug: fileName, + const savedFile = await overwriteFile(fileUrl, blob, { contentType: "application/json", - fetch, + fetch: getSolidAuthenticatedFetch(), }); console.log( `Uploaded ${fileName} to ${savedFile.internal_resourceInfo.sourceIri}` @@ -959,7 +1097,7 @@ export async function uploadResults( } /** - * Determines if there is are cached queries in the pod. + * Determines if there are cached queries in the pod. * * @param containerUrl - The ttl URL * @returns boolean representing if a cache is present. @@ -967,7 +1105,7 @@ export async function uploadResults( export async function getStoredTtl(resourceUrl: string): Promise { try { // Try to retrieve the dataset and save updated dataset - await getSolidDataset(resourceUrl, { fetch }); + await getSolidDataset(resourceUrl, { fetch: getSolidAuthenticatedFetch() }); return true; } catch (error) { return false; @@ -997,7 +1135,7 @@ export async function renameCachedQueryEntry( ): Promise { const entryUrl = `${ttlFileUrl}#${targetHash}`; try { - let dataset = await getSolidDataset(ttlFileUrl, { fetch }); + let dataset = await getSolidDataset(ttlFileUrl, { fetch: getSolidAuthenticatedFetch() }); const entryThing = getThing(dataset, entryUrl); if (!entryThing) { return false; @@ -1006,7 +1144,7 @@ export async function renameCachedQueryEntry( let renamedThing = setStringNoLocale(entryThing, DCT_TITLE, title.trim()); renamedThing = setDatetime(renamedThing, DCT_MODIFIED, new Date()); dataset = setThing(dataset, renamedThing); - await saveSolidDatasetAt(ttlFileUrl, dataset, { fetch }); + await saveSolidDatasetAt(ttlFileUrl, dataset, { fetch: getSolidAuthenticatedFetch() }); return true; } catch (error) { console.error(`Could not rename cached query ${targetHash}:`, error); @@ -1034,7 +1172,9 @@ export async function renameCachedQueryEntry( export async function getCachedQueries( ttlFileUrl: string ): Promise { - const dataset: SolidDataset = await getSolidDataset(ttlFileUrl, { fetch }); + const dataset: SolidDataset = await getSolidDataset(ttlFileUrl, { + fetch: getSolidAuthenticatedFetch(), + }); const things: Thing[] = getThingAll(dataset); const queryEntries: QueryEntry[] = []; @@ -1113,7 +1253,7 @@ function rdfListSources( * @returns A promise that resolves to the text content of the query file. */ export async function fetchQueryFileData(fileUrl: string): Promise { - const file = await getFile(fileUrl, { fetch }); + const file = await getFile(fileUrl, { fetch: getSolidAuthenticatedFetch() }); const textContent = await file.text(); return textContent; } @@ -1127,7 +1267,7 @@ export async function fetchQueryFileData(fileUrl: string): Promise { export async function fetchSparqlJsonFileData( fileUrl: string ): Promise { - const file = await getFile(fileUrl, { fetch }); + const file = await getFile(fileUrl, { fetch: getSolidAuthenticatedFetch() }); const textContent = await file.text(); try { const jsonData = JSON.parse(textContent); diff --git a/src/services/query/queryWorker.js b/src/services/query/queryWorker.js index c1cb99d..3b78191 100644 --- a/src/services/query/queryWorker.js +++ b/src/services/query/queryWorker.js @@ -3,6 +3,30 @@ self.global = self; let controller = null; let stopCpu = false; +function getFetchInputUrl(input) { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + if (typeof Request !== "undefined" && input instanceof Request) { + return input.url; + } + return String(input); +} + +function summarizeBlockingHttpIssues(httpIssues) { + if (!Array.isArray(httpIssues) || httpIssues.length === 0) { + return null; + } + const unique = Array.from( + new Map( + httpIssues.map((issue) => [ + `${issue.status}|${issue.url}`, + `${issue.status} ${issue.statusText || ""}`.trim() + ` at ${issue.url}`, + ]), + ).values(), + ); + return `HTTP request error(s) detected during endpoint query execution: ${unique.join(" | ")}`; +} + /** * Executes a SPARQL query over one or many SPARQL endpoints. * @@ -19,12 +43,25 @@ self.onmessage = async (e) => { stopCpu = false; controller = new AbortController(); const { signal } = controller; + const httpIssues = []; + const statusTrackingFetch = async (input, init) => { + const response = await fetch(input, init); + if (response && response.status >= 400) { + httpIssues.push({ + url: response.url || getFetchInputUrl(input), + status: response.status, + statusText: response.statusText || "", + }); + } + return response; + }; const engine = new QueryEngineSparql(); try { // execute query using Comunica engine const bindingsStream = await engine.queryBindings(query, { sources, lenient: true, + fetch: statusTrackingFetch, }); // stream results in bindings array @@ -58,6 +95,15 @@ self.onmessage = async (e) => { ? Array.from(firstBinding.keys()).map((variable) => variable.value) : []; + // Preserve historical lenient behavior, but if the run produced no rows + // and we saw blocking HTTP statuses, surface that as a user-visible error. + if (bindingsArray.length === 0) { + const httpIssueMessage = summarizeBlockingHttpIssues(httpIssues); + if (httpIssueMessage) { + throw new Error(httpIssueMessage); + } + } + // Return results. self.postMessage({ type: "result", diff --git a/src/services/solid/privacyEdit.ts b/src/services/solid/privacyEdit.ts index e56eba8..cfd84fb 100644 --- a/src/services/solid/privacyEdit.ts +++ b/src/services/solid/privacyEdit.ts @@ -798,6 +798,9 @@ export interface userHash { created: string; revokeAt?: string; offerIri?: string; + revoked?: boolean; + revokedAt?: string; + revokedOfferIri?: string; } export interface indexedUserHash { @@ -835,12 +838,37 @@ export async function getSharedWithOthers( things.forEach((thing) => thingByUrl.set(thing.url, thing)); // Undo entries explicitly revoke prior offers through as:object. - const revokedOfferIris = new Set( - things - .filter((thing) => getIri(thing, RDF_TYPE) === AS_UNDO) - .map((thing) => getUrl(thing, AS_OBJECT)) - .filter((iri): iri is string => Boolean(iri)) - ); + const revokedOfferInfo = new Map< + string, + { revokedAt?: string; undoIri?: string } + >(); + things + .filter((thing) => getIri(thing, RDF_TYPE) === AS_UNDO) + .forEach((thing) => { + const revokedOfferIri = getUrl(thing, AS_OBJECT); + if (!revokedOfferIri) { + return; + } + const nextRevokedAt = getDatetime(thing, DCT_CREATED)?.toISOString(); + const existing = revokedOfferInfo.get(revokedOfferIri); + if (!existing) { + revokedOfferInfo.set(revokedOfferIri, { + revokedAt: nextRevokedAt, + undoIri: thing.url, + }); + return; + } + const existingTime = existing.revokedAt + ? new Date(existing.revokedAt).getTime() + : 0; + const nextTime = nextRevokedAt ? new Date(nextRevokedAt).getTime() : 0; + if (nextTime >= existingTime) { + revokedOfferInfo.set(revokedOfferIri, { + revokedAt: nextRevokedAt, + undoIri: thing.url, + }); + } + }); const sharedItems: sharedSomething[] = []; things.forEach((thing) => { // Extract the hash from the Thing’s URL fragment. @@ -859,10 +887,12 @@ export async function getSharedWithOthers( const sharedHashes = getIriAll( thing, AS_OFFER - ) - // Offers that are already referenced by Undo entries are no longer active. - .filter((offerIri) => !revokedOfferIris.has(offerIri)); - const usersSharedWith = thingsUsersSharedWithParse(sharedHashes, thingByUrl); + ); + const usersSharedWith = thingsUsersSharedWithParse( + sharedHashes, + thingByUrl, + revokedOfferInfo + ); const owner = currentUserWebId; if (usersSharedWith.length > 0) { @@ -887,7 +917,8 @@ export async function getSharedWithOthers( */ function thingsUsersSharedWithParse( userHashes: string[], - thingByUrl: Map + thingByUrl: Map, + revokedOfferInfo: Map ): userHash[] { const usersSharedWith: userHash[] = []; userHashes.forEach((hashIri) => { @@ -900,6 +931,8 @@ function thingsUsersSharedWithParse( const resourceUrl = getUrl(hashThing, ACL_ACCESS_TO) || "N/A"; const access = getIriAll(hashThing, "http://www.w3.org/ns/auth/acl#mode") || ["N/A"]; const revokeAt = getDatetime(hashThing, DCT_VALID)?.toISOString(); + const revokedInfo = revokedOfferInfo.get(hashThing.url); + const revoked = Boolean(revokedInfo); usersSharedWith.push({ sharedWith, @@ -908,6 +941,9 @@ function thingsUsersSharedWithParse( created, revokeAt, offerIri: hashThing.url, + revoked, + revokedAt: revokedInfo?.revokedAt, + revokedOfferIri: revokedInfo?.undoIri, }); }); return usersSharedWith; @@ -938,6 +974,9 @@ export function getDueSharedWithOthersRevocations( return sharedItems.flatMap((item) => item.usersSharedWith .filter((entry) => { + if (entry.revoked) { + return false; + } if (!entry.revokeAt || !entry.offerIri) { return false; } @@ -1158,6 +1197,37 @@ export async function getSharedWithMe( const dataset = await getSolidDataset(sharedWithMeUrl, { fetch }); const things: Thing[] = getThingAll(dataset); + const revokedOfferInfo = new Map< + string, + { revokedAt?: string; undoIri?: string } + >(); + things + .filter((thing) => getIri(thing, RDF_TYPE) === AS_UNDO) + .forEach((thing) => { + const revokedOfferIri = getUrl(thing, AS_OBJECT); + if (!revokedOfferIri) { + return; + } + const nextRevokedAt = getDatetime(thing, DCT_CREATED)?.toISOString(); + const existing = revokedOfferInfo.get(revokedOfferIri); + if (!existing) { + revokedOfferInfo.set(revokedOfferIri, { + revokedAt: nextRevokedAt, + undoIri: thing.url, + }); + return; + } + const existingTime = existing.revokedAt + ? new Date(existing.revokedAt).getTime() + : 0; + const nextTime = nextRevokedAt ? new Date(nextRevokedAt).getTime() : 0; + if (nextTime >= existingTime) { + revokedOfferInfo.set(revokedOfferIri, { + revokedAt: nextRevokedAt, + undoIri: thing.url, + }); + } + }); const sharedItems: sharedSomething[] = []; let lastAccessed: string = "N/A"; @@ -1166,6 +1236,7 @@ export async function getSharedWithMe( const resourceHash = thing.url.includes("#") ? thing.url.split("#")[1] : ""; + const typeIri = getIri(thing, RDF_TYPE) || "N/A"; // Get the last accessed time if (resourceHash === "lastAccess") { @@ -1175,6 +1246,16 @@ export async function getSharedWithMe( "http://purl.org/dc/terms/modified" )?.toISOString() || "N/A"; } else { + const isOffer = typeIri === AS_OFFER; + const isUndo = typeIri === AS_UNDO; + if (!isOffer && !isUndo) { + return; + } + // Skip offers that were later revoked; the matching Undo entry is used + // to represent current state in SharedWithMe. + if (isOffer && revokedOfferInfo.has(thing.url)) { + return; + } // Get all other info const creator = getUrl(thing, "http://purl.org/dc/terms/creator") || "N/A"; @@ -1189,9 +1270,11 @@ export async function getSharedWithMe( thing, "http://www.w3.org/ns/auth/acl#mode" ) || ["N/A"]; - const whatKind = - getIri(thing, "http://www.w3.org/1999/02/22-rdf-syntax-ns#type") || - "N/A"; + const revokedOfferIri = isUndo ? getUrl(thing, AS_OBJECT) || undefined : undefined; + const revokedInfo = revokedOfferIri + ? revokedOfferInfo.get(revokedOfferIri) + : undefined; + const whatKind = typeIri; const usersSharedWith: userHash[] = [ { @@ -1199,6 +1282,9 @@ export async function getSharedWithMe( resourceUrl: accessTo, accessModes: accessModes, created: created, + revoked: isUndo, + revokedAt: revokedInfo?.revokedAt || (isUndo ? created : undefined), + revokedOfferIri, }, ]; diff --git a/tests/components/AllComponentsSmoke.test.ts b/tests/components/AllComponentsSmoke.test.ts index d482de1..9dc41f7 100644 --- a/tests/components/AllComponentsSmoke.test.ts +++ b/tests/components/AllComponentsSmoke.test.ts @@ -317,8 +317,7 @@ describe("Focused Styling Component Tests", () => { expect(wrapper.text()).toContain("Last Modified: 2026-02-20"); }); - expect(wrapper.text()).toContain("Version: v1.0.0"); - expect(wrapper.text()).toContain("Version: v1.0.0"); + expect(wrapper.text()).toContain(`Version: v${__APP_VERSION__}`); expect(fetchMock).toHaveBeenCalledOnce(); }); diff --git a/tests/unit/privacyEdit.test.ts b/tests/unit/privacyEdit.test.ts index 14415a4..cfba518 100644 --- a/tests/unit/privacyEdit.test.ts +++ b/tests/unit/privacyEdit.test.ts @@ -264,3 +264,30 @@ test("getDueSharedWithOthersRevocations returns only entries with expired revoke "https://owner.example/inbox/sharedWithOthers.ttl#offer-a" ); }); + +test("getDueSharedWithOthersRevocations skips entries already marked as revoked", () => { + const dueEntries = getDueSharedWithOthersRevocations( + [ + { + resourceHash: "https://owner.example/docs/", + owner: "https://owner.example/profile/card#me", + whatKind: "https://www.w3.org/ns/ldp#Container", + usersSharedWith: [ + { + sharedWith: "https://target.example/profile/card#me", + accessModes: ["http://www.w3.org/ns/auth/acl#Read"], + resourceUrl: "https://owner.example/docs/", + created: "2029-01-01T00:00:00.000Z", + revokeAt: "2030-01-01T00:00:00.000Z", + offerIri: "https://owner.example/inbox/sharedWithOthers.ttl#offer-a", + revoked: true, + revokedAt: "2030-01-01T00:01:00.000Z", + }, + ], + }, + ], + new Date("2030-06-01T00:00:00.000Z") + ); + + assert.equal(dueEntries.length, 0); +}); diff --git a/tests/unit/queryPodCachePaths.test.ts b/tests/unit/queryPodCachePaths.test.ts new file mode 100644 index 0000000..7141d78 --- /dev/null +++ b/tests/unit/queryPodCachePaths.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildCacheMemberFileUrl } from "../../src/services/query/queryPod.ts"; + +test("buildCacheMemberFileUrl appends filename inside container URLs with slash", () => { + assert.equal( + buildCacheMemberFileUrl("https://pod.example.com/querycache/", "abc123.rq"), + "https://pod.example.com/querycache/abc123.rq" + ); +}); + +test("buildCacheMemberFileUrl normalizes missing trailing slash and trims whitespace", () => { + assert.equal( + buildCacheMemberFileUrl(" https://pod.example.com/querycache ", "abc123.json"), + "https://pod.example.com/querycache/abc123.json" + ); +}); diff --git a/tests/unit/queryPodRdfList.test.ts b/tests/unit/queryPodRdfList.test.ts new file mode 100644 index 0000000..3588e4a --- /dev/null +++ b/tests/unit/queryPodRdfList.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getUrl } from "@inrupt/solid-client"; +import { buildRdfList } from "../../src/services/query/queryPod.ts"; + +const RDF_REST = "http://www.w3.org/1999/02/22-rdf-syntax-ns#rest"; +const RDF_NIL = "http://www.w3.org/1999/02/22-rdf-syntax-ns#nil"; + +test("buildRdfList terminates a single-source list with rdf:nil", () => { + const { head, nodes } = buildRdfList(["https://pod.example.com/public/data.ttl"]); + assert.equal(nodes.length, 1); + assert.equal(getUrl(head, RDF_REST), RDF_NIL); +}); + +test("buildRdfList creates chained nodes for multi-source lists", () => { + const { head, nodes } = buildRdfList([ + "https://pod.example.com/public/data-a.ttl", + "https://pod.example.com/public/data-b.ttl", + ]); + assert.equal(nodes.length, 2); + assert.notEqual(getUrl(head, RDF_REST), RDF_NIL); + assert.equal(getUrl(nodes[1], RDF_REST), RDF_NIL); +});
{{ @@ -20,6 +23,12 @@ {{ normalizeTargetLabel(getPrimaryResourceUrl(item)) }} + + {{ getSharedWithMeStatusLabel(item) }} + @@ -54,6 +63,17 @@ {{ formatKind(item.whatKind) }} + + {{ + isSharedWithMeRevoked(item) ? "block" : "verified_user" + }} + + Status + + {{ getSharedWithMeStatusLabel(item) }} + + + @@ -96,12 +116,22 @@ v-for="(ac, acIndex) in normalizeModeList(item.usersSharedWith[0]?.accessModes)" :key="`mode-${acIndex}`" class="mode-chip" + :class="{ revoked: isSharedWithMeRevoked(item) }" :title="ac" >{{ formatMode(ac) }} + + + Access was revoked on + {{ formatDate(item.usersSharedWith[0]?.revokedAt || "N/A") }}. +
+ {{ @@ -130,7 +163,7 @@ group - {{ getRecipientSummary(item) }} + {{ getRecipientStatusSummary(item) }} schedule @@ -174,6 +207,7 @@ @@ -187,7 +221,14 @@ schedule {{ formatDate(mode.created) }} + + {{ isRecipientRevoked(mode) ? "Revoked" : "Active" }} + + + + block + Revocation + + + {{ + isRecipientRevoked(mode) + ? `Revoked on ${formatDate(mode.revokedAt || "N/A")}` + : "Not revoked" + }} + + tag @@ -255,6 +309,7 @@ v-for="(ac, acIndex) in normalizeModeList(mode.accessModes)" :key="`${mode.sharedWith}-mode-${acIndex}`" class="mode-chip" + :class="{ revoked: isRecipientRevoked(mode) }" :title="ac" >{{ formatMode(ac) }} @@ -263,7 +318,10 @@ @@ -455,6 +513,22 @@ export default { copyText(text: string) { navigator.clipboard.writeText(text); }, + // SharedWithMe uses as:Offer/as:Undo rows; Undo rows represent revoked access. + isSharedWithMeRevoked(item: sharedSomething): boolean { + return Boolean(item.usersSharedWith[0]?.revoked); + }, + // SharedWithOthers rows carry revocation state from matched as:Undo entries. + isRecipientRevoked(mode: userHash): boolean { + return Boolean(mode.revoked); + }, + // Keep resource-level card state accurate even when one recipient was revoked. + hasActiveRecipients(item: sharedSomething): boolean { + return item.usersSharedWith.some((entry) => !this.isRecipientRevoked(entry)); + }, + // Compact state label used in collapsed/expanded sections. + getSharedWithMeStatusLabel(item: sharedSomething): string { + return this.isSharedWithMeRevoked(item) ? "Access revoked" : "Active access"; + }, /* Checks if the input item url is a container */ @@ -536,6 +610,9 @@ export default { return `${mode.sharedWith}-${mode.resourceUrl}-${mode.created}-${userIndex}`; }, startPermissionEdit(mode: userHash, userIndex: number) { + if (this.isRecipientRevoked(mode)) { + return; + } this.permissionEditError = ""; this.editedPermissions = this.permissionsFromModeIris(mode.accessModes); this.permissionRevokeDurationValue = null; @@ -691,6 +768,20 @@ export default { const count = item.usersSharedWith.length; return count === 1 ? "1 recipient" : `${count} recipients`; }, + // Show active/revoked counts so revoked permissions remain visible and understandable. + getRecipientStatusSummary(item: sharedSomething): string { + const revokedCount = item.usersSharedWith.filter((entry) => + this.isRecipientRevoked(entry) + ).length; + const activeCount = item.usersSharedWith.length - revokedCount; + if (revokedCount === 0) { + return `${this.getRecipientSummary(item)} active`; + } + if (activeCount === 0) { + return `${this.getRecipientSummary(item)} revoked`; + } + return `${activeCount} active, ${revokedCount} revoked`; + }, // Find the newest created timestamp among all recipients of one resource. getLatestEntryDate(item: sharedSomething): string { const times = item.usersSharedWith @@ -827,6 +918,9 @@ export default { display: grid; gap: 0.58rem; } +.shared-list > li { + min-width: 0; +} .shared-entry { border: 1px solid color-mix(in srgb, var(--border) 84%, var(--primary) 16%); border-radius: 14px; @@ -843,6 +937,14 @@ export default { .shared-entry.expanded { border-color: color-mix(in srgb, var(--primary) 34%, var(--border)); } +.shared-entry.revoked-entry { + border-color: color-mix(in srgb, var(--border) 92%, var(--text-muted) 8%); + background: color-mix(in srgb, var(--panel) 96%, var(--panel-elev) 4%); +} +.shared-entry.revoked-entry:hover { + border-color: color-mix(in srgb, var(--border) 90%, var(--text-muted) 10%); + background: color-mix(in srgb, var(--panel) 94%, var(--panel-elev) 6%); +} .entry-toggle { width: 100%; @@ -856,6 +958,7 @@ export default { cursor: pointer; font-family: "Oxanium", monospace; color: var(--text-secondary); + min-width: 0; } .entry-main { display: inline-flex; @@ -870,10 +973,13 @@ export default { min-width: 0; } .shared-others-collapsed-copy { - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; align-items: center; - gap: 0.8rem; + column-gap: 0.8rem; + row-gap: 0.24rem; width: 100%; + min-width: 0; } .shared-others-collapsed-copy .entry-title { flex: 1 1 auto; @@ -884,15 +990,19 @@ export default { align-items: center; justify-content: flex-end; gap: 0.7rem; - flex: 0 0 auto; + flex: 1 1 auto; + min-width: 0; + max-width: 100%; color: var(--text-muted); font-size: var(--font-size-page-summary); - white-space: nowrap; + white-space: normal; + flex-wrap: wrap; } .entry-inline-summary span { display: inline-flex; align-items: center; gap: 0.28rem; + min-width: 0; } .entry-copy-equalized { min-height: 2.8rem; @@ -913,6 +1023,26 @@ export default { color: var(--text-muted); line-height: 1.35; } +.entry-status { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + border: 1px solid color-mix(in srgb, var(--success) 45%, var(--border) 55%); + border-radius: 999px; + background: color-mix(in srgb, var(--success) 12%, var(--panel-elev) 88%); + color: color-mix(in srgb, var(--success) 80%, var(--text-primary) 20%); + font-size: var(--font-size-section-kicker); + font-weight: 700; + line-height: 1.2; + letter-spacing: 0.03em; + padding: 0.16rem 0.44rem; +} +.entry-status.revoked { + border-color: color-mix(in srgb, var(--border) 78%, var(--text-muted) 22%); + background: color-mix(in srgb, var(--panel) 84%, var(--panel-elev) 16%); + color: var(--text-muted); +} .info-icon { color: var(--text-muted); flex: 0 0 auto; @@ -1043,6 +1173,16 @@ export default { color: var(--text-secondary); overflow-wrap: anywhere; } +.shared-revoked-note { + margin: 0; + border: 1px solid color-mix(in srgb, var(--border) 80%, var(--text-muted) 20%); + border-radius: 10px; + background: color-mix(in srgb, var(--panel) 96%, var(--panel-elev) 4%); + color: var(--text-muted); + font-size: var(--font-size-page-summary); + font-weight: 700; + padding: 0.42rem 0.52rem; +} .mono { font-family: "Oxanium", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } @@ -1067,6 +1207,11 @@ export default { font-size: 0.76rem; font-weight: 700; } +.mode-chip.revoked { + border-color: color-mix(in srgb, var(--border) 80%, var(--text-muted) 20%); + background: color-mix(in srgb, var(--panel) 94%, var(--panel-elev) 6%); + color: var(--text-muted); +} .recipient-list { display: grid; @@ -1079,15 +1224,20 @@ export default { padding: 0.62rem; display: grid; gap: 0.58rem; + min-width: 0; +} +.recipient-card.revoked-recipient { + border-color: color-mix(in srgb, var(--border) 88%, var(--text-muted) 12%); + background: color-mix(in srgb, var(--panel) 96%, var(--panel-elev) 4%); } .recipient-header { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; gap: 0.45rem 0.7rem; padding-bottom: 0.52rem; border-bottom: 1px solid color-mix(in srgb, var(--border) 74%, transparent); + min-width: 0; } .recipient-target, .recipient-date { @@ -1098,19 +1248,24 @@ export default { font-size: var(--font-size-page-summary); } .recipient-target { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; color: var(--text-primary); font-weight: 700; + overflow-wrap: anywhere; } .recipient-date { color: var(--text-muted); } .recipient-actions { - display: inline-flex; + display: flex; align-items: center; justify-content: flex-end; gap: 0.55rem; flex-wrap: wrap; min-width: 0; + max-width: 100%; } .recipient-body { display: grid; @@ -1122,7 +1277,7 @@ export default { border: 1px solid color-mix(in srgb, var(--border) 78%, var(--primary) 22%); border-radius: 999px; background: color-mix(in srgb, var(--panel-elev) 90%, transparent); - color: var(--text-secondary); + color: var(--text-muted); display: inline-flex; align-items: center; justify-content: center; @@ -1148,7 +1303,7 @@ export default { .permission-save-button { background: linear-gradient(135deg, var(--primary), var(--primary-strong)); border-color: color-mix(in srgb, var(--primary) 62%, var(--border)); - color: var(--primary-contrast); + color: var(--text-primary); } .permission-save-button:disabled { cursor: wait; @@ -1157,12 +1312,7 @@ export default { .permission-editor { border: 1px solid color-mix(in srgb, var(--primary) 24%, var(--border)); border-radius: 12px; - background: - linear-gradient( - 135deg, - color-mix(in srgb, var(--panel-elev) 94%, var(--primary) 6%), - color-mix(in srgb, var(--panel) 96%, var(--primary) 4%) - ); + background: var(--panel-elev); display: grid; gap: 0.68rem; padding: 0.72rem; @@ -1296,6 +1446,7 @@ export default { .shared-others-collapsed-copy { display: grid; gap: 0.24rem; + grid-template-columns: minmax(0, 1fr); } .entry-inline-summary { justify-content: flex-start; @@ -1314,6 +1465,21 @@ export default { } } +@media (max-width: 1160px) { + .shared-others-collapsed-copy { + grid-template-columns: minmax(0, 1fr); + } + .entry-inline-summary { + justify-content: flex-start; + } + .recipient-header { + grid-template-columns: minmax(0, 1fr); + } + .recipient-actions { + justify-content: flex-start; + } +} + @media (max-width: 520px) { .permission-options { grid-template-columns: 1fr; diff --git a/src/services/query/queryPod.ts b/src/services/query/queryPod.ts index 9912691..a7193aa 100644 --- a/src/services/query/queryPod.ts +++ b/src/services/query/queryPod.ts @@ -9,7 +9,7 @@ import { saveSolidDatasetAt, createSolidDataset, createContainerAt, - saveFileInContainer, + overwriteFile, createThing, buildThing, setThing, @@ -126,6 +126,25 @@ export interface CoiFetchOptions { onError?: (e: unknown) => void; } +interface HttpFetchIssue { + url: string; + status: number; + statusText: string; +} + +/** + * Returns the Solid SDK-authenticated fetch used across query/cache operations. + * + * We intentionally rely on the module-level `fetch` export from + * `@inrupt/solid-client-authn-browser` because it is the most stable auth + * bridge across redirects and route transitions in this app. Earlier + * conditional session switching caused `queries.ttl` reads to intermittently + * fall back to unauthorized requests, breaking Past Queries loading. + */ +function getSolidAuthenticatedFetch(): FetchLike { + return fetch; +} + export type QueryExecutionMode = | "endpoint" | "solid-no-traversal" @@ -184,7 +203,7 @@ export const QUERY_MODE_DEFINITIONS: QueryModeDefinition[] = [ * @returns A new array of cleaned source URLs without angle brackets. */ export function cleanSourcesUrls(dirtySources: string[]): ComunicaSources[] { - return cleanSourcesUrlsInternal(dirtySources, fetch); + return cleanSourcesUrlsInternal(dirtySources, getSolidAuthenticatedFetch() as typeof fetch); } export function isQueryExecutionMode(modeLike: string): modeLike is QueryExecutionMode { @@ -294,6 +313,22 @@ function getIndexResourceUrl(containerUrl: string, fileName = "queries.ttl"): st return `${containerUrl}${fileName}`; } +function ensureTrailingSlash(url: string): string { + return url.endsWith("/") ? url : `${url}/`; +} + +/** + * Builds a deterministic cache member file URL inside a container. + * This avoids server-dependent POST+Slug handling and keeps member URLs explicit. + */ +export function buildCacheMemberFileUrl( + containerUrl: string, + fileName: string +): string { + const normalizedContainerUrl = ensureTrailingSlash(containerUrl.trim()); + return `${normalizedContainerUrl}${fileName}`; +} + function getQueryEntryUrl( containerUrl: string, hash: string, @@ -443,19 +478,86 @@ async function streamBindingsToOutput( * contexts where response header normalization is needed. */ function createAuthenticatedQueryFetch(options: { noCors: boolean }): FetchLike { - return createCoiFetch(fetch, { + const baseFetch: FetchLike = getSolidAuthenticatedFetch(); + + return createCoiFetch(baseFetch, { coepCredentialless: false, passthroughOpaque: true, noCors: options.noCors, }); } -function createSolidQueryContext(mixedSources: ComunicaSources[]): Record { +function getFetchInputUrl(input: RequestInfo | URL): string { + if (typeof input === "string") { + return input; + } + if (input instanceof URL) { + return input.href; + } + if (typeof Request !== "undefined" && input instanceof Request) { + return input.url; + } + return String(input); +} + +function isBlockingHttpStatus(status: number): boolean { + return status >= 400; +} + +function summarizeBlockingHttpIssues( + issues: HttpFetchIssue[], + mode: QueryExecutionMode +): string | null { + const blockingIssues = issues.filter((issue) => + isBlockingHttpStatus(issue.status) + ); + if (blockingIssues.length === 0) { + return null; + } + + const uniqueIssues = Array.from( + new Map( + blockingIssues.map((issue) => [ + `${issue.status}|${issue.url}`, + `${issue.status} ${issue.statusText || ""}`.trim() + + ` at ${issue.url}`, + ]) + ).values() + ); + + return `HTTP request error(s) detected during ${mode} query execution: ${uniqueIssues.join( + " | " + )}`; +} + +function createHttpIssueTrackingFetch( + baseFetch: FetchLike, + issues: HttpFetchIssue[] +): FetchLike { + return async (input, init) => { + const response = await baseFetch(input, init); + if (response && isBlockingHttpStatus(response.status)) { + issues.push({ + url: response.url || getFetchInputUrl(input), + status: response.status, + statusText: response.statusText || "", + }); + } + return response; + }; +} + +function createSolidQueryContext( + mixedSources: ComunicaSources[], + httpIssues: HttpFetchIssue[] +): Record { const session = getDefaultSession(); const hasAuthenticatedSession = Boolean(session.info.isLoggedIn); - const authenticatedFetch: FetchLike = hasAuthenticatedSession - ? session.fetch.bind(session) - : fetch; + const authenticatedFetchBase: FetchLike = getSolidAuthenticatedFetch(); + const authenticatedFetch = createHttpIssueTrackingFetch( + authenticatedFetchBase, + httpIssues + ); // Per Comunica Solid docs, provide the authn session in context so the Solid // HTTP actor can attach credentials for protected pod resources. @@ -475,9 +577,13 @@ function createSolidQueryContext(mixedSources: ComunicaSources[]): Record { - const authenticatedFetch = createAuthenticatedQueryFetch({ noCors: false }); + const authenticatedFetch = createHttpIssueTrackingFetch( + createAuthenticatedQueryFetch({ noCors: false }), + httpIssues + ); return { lenient: true, fetch: authenticatedFetch, @@ -503,13 +609,21 @@ async function executeEndpointCacheLookup( cachePath: string ): Promise { const endpointEngine = new SparqlEngineCache(); + const httpIssues: HttpFetchIssue[] = []; try { - return await executeWithEngine( + const output = await executeWithEngine( endpointEngine, inputQuery, - createEndpointCacheContext(mixedSources, cachePath), + createEndpointCacheContext(mixedSources, cachePath, httpIssues), { includeProvenance: true } ); + // A cache hit that resolves to an empty set can be caused by hard HTTP + // errors against one or more remote sources. Surface those explicitly. + const httpIssueMessage = summarizeBlockingHttpIssues(httpIssues, "endpoint"); + if (httpIssueMessage && output.resultsOutput.results.bindings.length === 0) { + throw new Error(httpIssueMessage); + } + return output; } catch { return "no-cache"; } @@ -519,13 +633,22 @@ async function executeSolidNoTraversalQuery( inputQuery: string, mixedSources: ComunicaSources[] ): Promise { + const httpIssues: HttpFetchIssue[] = []; try { const solidEngine = new SolidQueryEngine(); - return await executeWithEngine( + const output = await executeWithEngine( solidEngine, inputQuery, - createSolidQueryContext(mixedSources) + createSolidQueryContext(mixedSources, httpIssues) + ); + const httpIssueMessage = summarizeBlockingHttpIssues( + httpIssues, + "solid-no-traversal" ); + if (httpIssueMessage && output.resultsOutput.results.bindings.length === 0) { + return new Error(httpIssueMessage); + } + return output; } catch (err) { return err instanceof Error ? err : new Error(String(err)); } @@ -535,12 +658,21 @@ async function executeSolidLinkTraversalQuery( inputQuery: string, mixedSources: ComunicaSources[] ): Promise { + const httpIssues: HttpFetchIssue[] = []; try { - return await executeWithEngine( + const output = await executeWithEngine( new SolidLinkTraversalQueryEngine(), inputQuery, - createSolidQueryContext(mixedSources) + createSolidQueryContext(mixedSources, httpIssues) + ); + const httpIssueMessage = summarizeBlockingHttpIssues( + httpIssues, + "solid-link-traversal" ); + if (httpIssueMessage && output.resultsOutput.results.bindings.length === 0) { + return new Error(httpIssueMessage); + } + return output; } catch (err) { return err instanceof Error ? err : new Error(String(err)); } @@ -623,12 +755,12 @@ export async function ensureCacheContainer( try { // Try to retrieve the dataset (container) - await getSolidDataset(cacheUrl, { fetch }); + await getSolidDataset(cacheUrl, { fetch: getSolidAuthenticatedFetch() }); return cacheUrl; } catch (error) { // If not found, create the container (if it is the users pod in question) if (providedCache === podUrl) { - await createContainerAt(cacheUrl, { fetch }); + await createContainerAt(cacheUrl, { fetch: getSolidAuthenticatedFetch() }); console.log(`Query Cache container was created at ${cacheUrl}`); return cacheUrl; @@ -646,10 +778,10 @@ export async function ensureCacheContainer( * - head: A Thing representing the head of the RDF list. * - nodes: An array of all list node Things (to be added to your dataset). */ -function buildRdfList(sources: string[]): { head: Thing; nodes: Thing[] } { +export function buildRdfList(sources: string[]): { head: Thing; nodes: Thing[] } { if (sources.length === 0) { throw new Error( - "Cannot create a cache entry without at least one endpoint source." + "Cannot create a cache entry without at least one source URI." ); } @@ -657,6 +789,12 @@ function buildRdfList(sources: string[]): { head: Thing; nodes: Thing[] } { let listNode = createThing(); // creates a blank node automatically listNode = buildThing(listNode).addIri(RDF_FIRST, sources[0]).build(); + // Base case: a single source terminates the RDF list with rdf:nil. + if (sources.length === 1) { + listNode = buildThing(listNode).addIri(RDF_REST, RDF_NIL).build(); + return { head: listNode, nodes: [listNode] }; + } + // Recursively build the rest of the list. const restList = buildRdfList(sources.slice(1)); @@ -742,7 +880,7 @@ export async function upsertQueryCacheEntry( let dataset: SolidDataset; try { dataset = await getSolidDataset(getIndexResourceUrl(containerUrl, fileName), { - fetch, + fetch: getSolidAuthenticatedFetch(), }); } catch { dataset = createSolidDataset(); @@ -817,7 +955,7 @@ export async function upsertQueryCacheEntry( }); await saveSolidDatasetAt(getIndexResourceUrl(containerUrl, fileName), updatedDataset, { - fetch, + fetch: getSolidAuthenticatedFetch(), }); return entry.hash; @@ -891,12 +1029,12 @@ export async function uploadQueryFile( ): Promise { const fileName = hashName + ".rq"; const blob = new Blob([query], { type: "application/sparql-query" }); + const fileUrl = buildCacheMemberFileUrl(containerUrl, fileName); try { - const savedFile = await saveFileInContainer(containerUrl, blob, { - slug: fileName, + const savedFile = await overwriteFile(fileUrl, blob, { contentType: "application/sparql-query", - fetch, + fetch: getSolidAuthenticatedFetch(), }); console.log( `Uploaded ${fileName} to ${savedFile.internal_resourceInfo.sourceIri}` @@ -941,12 +1079,12 @@ export async function uploadResults( const blob = new Blob([jsonString], { type: "application/sparql-results+json", }); + const fileUrl = buildCacheMemberFileUrl(containerUrl, fileName); try { - const savedFile = await saveFileInContainer(containerUrl, blob, { - slug: fileName, + const savedFile = await overwriteFile(fileUrl, blob, { contentType: "application/json", - fetch, + fetch: getSolidAuthenticatedFetch(), }); console.log( `Uploaded ${fileName} to ${savedFile.internal_resourceInfo.sourceIri}` @@ -959,7 +1097,7 @@ export async function uploadResults( } /** - * Determines if there is are cached queries in the pod. + * Determines if there are cached queries in the pod. * * @param containerUrl - The ttl URL * @returns boolean representing if a cache is present. @@ -967,7 +1105,7 @@ export async function uploadResults( export async function getStoredTtl(resourceUrl: string): Promise { try { // Try to retrieve the dataset and save updated dataset - await getSolidDataset(resourceUrl, { fetch }); + await getSolidDataset(resourceUrl, { fetch: getSolidAuthenticatedFetch() }); return true; } catch (error) { return false; @@ -997,7 +1135,7 @@ export async function renameCachedQueryEntry( ): Promise { const entryUrl = `${ttlFileUrl}#${targetHash}`; try { - let dataset = await getSolidDataset(ttlFileUrl, { fetch }); + let dataset = await getSolidDataset(ttlFileUrl, { fetch: getSolidAuthenticatedFetch() }); const entryThing = getThing(dataset, entryUrl); if (!entryThing) { return false; @@ -1006,7 +1144,7 @@ export async function renameCachedQueryEntry( let renamedThing = setStringNoLocale(entryThing, DCT_TITLE, title.trim()); renamedThing = setDatetime(renamedThing, DCT_MODIFIED, new Date()); dataset = setThing(dataset, renamedThing); - await saveSolidDatasetAt(ttlFileUrl, dataset, { fetch }); + await saveSolidDatasetAt(ttlFileUrl, dataset, { fetch: getSolidAuthenticatedFetch() }); return true; } catch (error) { console.error(`Could not rename cached query ${targetHash}:`, error); @@ -1034,7 +1172,9 @@ export async function renameCachedQueryEntry( export async function getCachedQueries( ttlFileUrl: string ): Promise { - const dataset: SolidDataset = await getSolidDataset(ttlFileUrl, { fetch }); + const dataset: SolidDataset = await getSolidDataset(ttlFileUrl, { + fetch: getSolidAuthenticatedFetch(), + }); const things: Thing[] = getThingAll(dataset); const queryEntries: QueryEntry[] = []; @@ -1113,7 +1253,7 @@ function rdfListSources( * @returns A promise that resolves to the text content of the query file. */ export async function fetchQueryFileData(fileUrl: string): Promise { - const file = await getFile(fileUrl, { fetch }); + const file = await getFile(fileUrl, { fetch: getSolidAuthenticatedFetch() }); const textContent = await file.text(); return textContent; } @@ -1127,7 +1267,7 @@ export async function fetchQueryFileData(fileUrl: string): Promise { export async function fetchSparqlJsonFileData( fileUrl: string ): Promise { - const file = await getFile(fileUrl, { fetch }); + const file = await getFile(fileUrl, { fetch: getSolidAuthenticatedFetch() }); const textContent = await file.text(); try { const jsonData = JSON.parse(textContent); diff --git a/src/services/query/queryWorker.js b/src/services/query/queryWorker.js index c1cb99d..3b78191 100644 --- a/src/services/query/queryWorker.js +++ b/src/services/query/queryWorker.js @@ -3,6 +3,30 @@ self.global = self; let controller = null; let stopCpu = false; +function getFetchInputUrl(input) { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + if (typeof Request !== "undefined" && input instanceof Request) { + return input.url; + } + return String(input); +} + +function summarizeBlockingHttpIssues(httpIssues) { + if (!Array.isArray(httpIssues) || httpIssues.length === 0) { + return null; + } + const unique = Array.from( + new Map( + httpIssues.map((issue) => [ + `${issue.status}|${issue.url}`, + `${issue.status} ${issue.statusText || ""}`.trim() + ` at ${issue.url}`, + ]), + ).values(), + ); + return `HTTP request error(s) detected during endpoint query execution: ${unique.join(" | ")}`; +} + /** * Executes a SPARQL query over one or many SPARQL endpoints. * @@ -19,12 +43,25 @@ self.onmessage = async (e) => { stopCpu = false; controller = new AbortController(); const { signal } = controller; + const httpIssues = []; + const statusTrackingFetch = async (input, init) => { + const response = await fetch(input, init); + if (response && response.status >= 400) { + httpIssues.push({ + url: response.url || getFetchInputUrl(input), + status: response.status, + statusText: response.statusText || "", + }); + } + return response; + }; const engine = new QueryEngineSparql(); try { // execute query using Comunica engine const bindingsStream = await engine.queryBindings(query, { sources, lenient: true, + fetch: statusTrackingFetch, }); // stream results in bindings array @@ -58,6 +95,15 @@ self.onmessage = async (e) => { ? Array.from(firstBinding.keys()).map((variable) => variable.value) : []; + // Preserve historical lenient behavior, but if the run produced no rows + // and we saw blocking HTTP statuses, surface that as a user-visible error. + if (bindingsArray.length === 0) { + const httpIssueMessage = summarizeBlockingHttpIssues(httpIssues); + if (httpIssueMessage) { + throw new Error(httpIssueMessage); + } + } + // Return results. self.postMessage({ type: "result", diff --git a/src/services/solid/privacyEdit.ts b/src/services/solid/privacyEdit.ts index e56eba8..cfd84fb 100644 --- a/src/services/solid/privacyEdit.ts +++ b/src/services/solid/privacyEdit.ts @@ -798,6 +798,9 @@ export interface userHash { created: string; revokeAt?: string; offerIri?: string; + revoked?: boolean; + revokedAt?: string; + revokedOfferIri?: string; } export interface indexedUserHash { @@ -835,12 +838,37 @@ export async function getSharedWithOthers( things.forEach((thing) => thingByUrl.set(thing.url, thing)); // Undo entries explicitly revoke prior offers through as:object. - const revokedOfferIris = new Set( - things - .filter((thing) => getIri(thing, RDF_TYPE) === AS_UNDO) - .map((thing) => getUrl(thing, AS_OBJECT)) - .filter((iri): iri is string => Boolean(iri)) - ); + const revokedOfferInfo = new Map< + string, + { revokedAt?: string; undoIri?: string } + >(); + things + .filter((thing) => getIri(thing, RDF_TYPE) === AS_UNDO) + .forEach((thing) => { + const revokedOfferIri = getUrl(thing, AS_OBJECT); + if (!revokedOfferIri) { + return; + } + const nextRevokedAt = getDatetime(thing, DCT_CREATED)?.toISOString(); + const existing = revokedOfferInfo.get(revokedOfferIri); + if (!existing) { + revokedOfferInfo.set(revokedOfferIri, { + revokedAt: nextRevokedAt, + undoIri: thing.url, + }); + return; + } + const existingTime = existing.revokedAt + ? new Date(existing.revokedAt).getTime() + : 0; + const nextTime = nextRevokedAt ? new Date(nextRevokedAt).getTime() : 0; + if (nextTime >= existingTime) { + revokedOfferInfo.set(revokedOfferIri, { + revokedAt: nextRevokedAt, + undoIri: thing.url, + }); + } + }); const sharedItems: sharedSomething[] = []; things.forEach((thing) => { // Extract the hash from the Thing’s URL fragment. @@ -859,10 +887,12 @@ export async function getSharedWithOthers( const sharedHashes = getIriAll( thing, AS_OFFER - ) - // Offers that are already referenced by Undo entries are no longer active. - .filter((offerIri) => !revokedOfferIris.has(offerIri)); - const usersSharedWith = thingsUsersSharedWithParse(sharedHashes, thingByUrl); + ); + const usersSharedWith = thingsUsersSharedWithParse( + sharedHashes, + thingByUrl, + revokedOfferInfo + ); const owner = currentUserWebId; if (usersSharedWith.length > 0) { @@ -887,7 +917,8 @@ export async function getSharedWithOthers( */ function thingsUsersSharedWithParse( userHashes: string[], - thingByUrl: Map + thingByUrl: Map, + revokedOfferInfo: Map ): userHash[] { const usersSharedWith: userHash[] = []; userHashes.forEach((hashIri) => { @@ -900,6 +931,8 @@ function thingsUsersSharedWithParse( const resourceUrl = getUrl(hashThing, ACL_ACCESS_TO) || "N/A"; const access = getIriAll(hashThing, "http://www.w3.org/ns/auth/acl#mode") || ["N/A"]; const revokeAt = getDatetime(hashThing, DCT_VALID)?.toISOString(); + const revokedInfo = revokedOfferInfo.get(hashThing.url); + const revoked = Boolean(revokedInfo); usersSharedWith.push({ sharedWith, @@ -908,6 +941,9 @@ function thingsUsersSharedWithParse( created, revokeAt, offerIri: hashThing.url, + revoked, + revokedAt: revokedInfo?.revokedAt, + revokedOfferIri: revokedInfo?.undoIri, }); }); return usersSharedWith; @@ -938,6 +974,9 @@ export function getDueSharedWithOthersRevocations( return sharedItems.flatMap((item) => item.usersSharedWith .filter((entry) => { + if (entry.revoked) { + return false; + } if (!entry.revokeAt || !entry.offerIri) { return false; } @@ -1158,6 +1197,37 @@ export async function getSharedWithMe( const dataset = await getSolidDataset(sharedWithMeUrl, { fetch }); const things: Thing[] = getThingAll(dataset); + const revokedOfferInfo = new Map< + string, + { revokedAt?: string; undoIri?: string } + >(); + things + .filter((thing) => getIri(thing, RDF_TYPE) === AS_UNDO) + .forEach((thing) => { + const revokedOfferIri = getUrl(thing, AS_OBJECT); + if (!revokedOfferIri) { + return; + } + const nextRevokedAt = getDatetime(thing, DCT_CREATED)?.toISOString(); + const existing = revokedOfferInfo.get(revokedOfferIri); + if (!existing) { + revokedOfferInfo.set(revokedOfferIri, { + revokedAt: nextRevokedAt, + undoIri: thing.url, + }); + return; + } + const existingTime = existing.revokedAt + ? new Date(existing.revokedAt).getTime() + : 0; + const nextTime = nextRevokedAt ? new Date(nextRevokedAt).getTime() : 0; + if (nextTime >= existingTime) { + revokedOfferInfo.set(revokedOfferIri, { + revokedAt: nextRevokedAt, + undoIri: thing.url, + }); + } + }); const sharedItems: sharedSomething[] = []; let lastAccessed: string = "N/A"; @@ -1166,6 +1236,7 @@ export async function getSharedWithMe( const resourceHash = thing.url.includes("#") ? thing.url.split("#")[1] : ""; + const typeIri = getIri(thing, RDF_TYPE) || "N/A"; // Get the last accessed time if (resourceHash === "lastAccess") { @@ -1175,6 +1246,16 @@ export async function getSharedWithMe( "http://purl.org/dc/terms/modified" )?.toISOString() || "N/A"; } else { + const isOffer = typeIri === AS_OFFER; + const isUndo = typeIri === AS_UNDO; + if (!isOffer && !isUndo) { + return; + } + // Skip offers that were later revoked; the matching Undo entry is used + // to represent current state in SharedWithMe. + if (isOffer && revokedOfferInfo.has(thing.url)) { + return; + } // Get all other info const creator = getUrl(thing, "http://purl.org/dc/terms/creator") || "N/A"; @@ -1189,9 +1270,11 @@ export async function getSharedWithMe( thing, "http://www.w3.org/ns/auth/acl#mode" ) || ["N/A"]; - const whatKind = - getIri(thing, "http://www.w3.org/1999/02/22-rdf-syntax-ns#type") || - "N/A"; + const revokedOfferIri = isUndo ? getUrl(thing, AS_OBJECT) || undefined : undefined; + const revokedInfo = revokedOfferIri + ? revokedOfferInfo.get(revokedOfferIri) + : undefined; + const whatKind = typeIri; const usersSharedWith: userHash[] = [ { @@ -1199,6 +1282,9 @@ export async function getSharedWithMe( resourceUrl: accessTo, accessModes: accessModes, created: created, + revoked: isUndo, + revokedAt: revokedInfo?.revokedAt || (isUndo ? created : undefined), + revokedOfferIri, }, ]; diff --git a/tests/components/AllComponentsSmoke.test.ts b/tests/components/AllComponentsSmoke.test.ts index d482de1..9dc41f7 100644 --- a/tests/components/AllComponentsSmoke.test.ts +++ b/tests/components/AllComponentsSmoke.test.ts @@ -317,8 +317,7 @@ describe("Focused Styling Component Tests", () => { expect(wrapper.text()).toContain("Last Modified: 2026-02-20"); }); - expect(wrapper.text()).toContain("Version: v1.0.0"); - expect(wrapper.text()).toContain("Version: v1.0.0"); + expect(wrapper.text()).toContain(`Version: v${__APP_VERSION__}`); expect(fetchMock).toHaveBeenCalledOnce(); }); diff --git a/tests/unit/privacyEdit.test.ts b/tests/unit/privacyEdit.test.ts index 14415a4..cfba518 100644 --- a/tests/unit/privacyEdit.test.ts +++ b/tests/unit/privacyEdit.test.ts @@ -264,3 +264,30 @@ test("getDueSharedWithOthersRevocations returns only entries with expired revoke "https://owner.example/inbox/sharedWithOthers.ttl#offer-a" ); }); + +test("getDueSharedWithOthersRevocations skips entries already marked as revoked", () => { + const dueEntries = getDueSharedWithOthersRevocations( + [ + { + resourceHash: "https://owner.example/docs/", + owner: "https://owner.example/profile/card#me", + whatKind: "https://www.w3.org/ns/ldp#Container", + usersSharedWith: [ + { + sharedWith: "https://target.example/profile/card#me", + accessModes: ["http://www.w3.org/ns/auth/acl#Read"], + resourceUrl: "https://owner.example/docs/", + created: "2029-01-01T00:00:00.000Z", + revokeAt: "2030-01-01T00:00:00.000Z", + offerIri: "https://owner.example/inbox/sharedWithOthers.ttl#offer-a", + revoked: true, + revokedAt: "2030-01-01T00:01:00.000Z", + }, + ], + }, + ], + new Date("2030-06-01T00:00:00.000Z") + ); + + assert.equal(dueEntries.length, 0); +}); diff --git a/tests/unit/queryPodCachePaths.test.ts b/tests/unit/queryPodCachePaths.test.ts new file mode 100644 index 0000000..7141d78 --- /dev/null +++ b/tests/unit/queryPodCachePaths.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildCacheMemberFileUrl } from "../../src/services/query/queryPod.ts"; + +test("buildCacheMemberFileUrl appends filename inside container URLs with slash", () => { + assert.equal( + buildCacheMemberFileUrl("https://pod.example.com/querycache/", "abc123.rq"), + "https://pod.example.com/querycache/abc123.rq" + ); +}); + +test("buildCacheMemberFileUrl normalizes missing trailing slash and trims whitespace", () => { + assert.equal( + buildCacheMemberFileUrl(" https://pod.example.com/querycache ", "abc123.json"), + "https://pod.example.com/querycache/abc123.json" + ); +}); diff --git a/tests/unit/queryPodRdfList.test.ts b/tests/unit/queryPodRdfList.test.ts new file mode 100644 index 0000000..3588e4a --- /dev/null +++ b/tests/unit/queryPodRdfList.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getUrl } from "@inrupt/solid-client"; +import { buildRdfList } from "../../src/services/query/queryPod.ts"; + +const RDF_REST = "http://www.w3.org/1999/02/22-rdf-syntax-ns#rest"; +const RDF_NIL = "http://www.w3.org/1999/02/22-rdf-syntax-ns#nil"; + +test("buildRdfList terminates a single-source list with rdf:nil", () => { + const { head, nodes } = buildRdfList(["https://pod.example.com/public/data.ttl"]); + assert.equal(nodes.length, 1); + assert.equal(getUrl(head, RDF_REST), RDF_NIL); +}); + +test("buildRdfList creates chained nodes for multi-source lists", () => { + const { head, nodes } = buildRdfList([ + "https://pod.example.com/public/data-a.ttl", + "https://pod.example.com/public/data-b.ttl", + ]); + assert.equal(nodes.length, 2); + assert.notEqual(getUrl(head, RDF_REST), RDF_NIL); + assert.equal(getUrl(nodes[1], RDF_REST), RDF_NIL); +});
{{ @@ -130,7 +163,7 @@ group - {{ getRecipientSummary(item) }} + {{ getRecipientStatusSummary(item) }} schedule @@ -174,6 +207,7 @@ @@ -187,7 +221,14 @@ schedule {{ formatDate(mode.created) }} + + {{ isRecipientRevoked(mode) ? "Revoked" : "Active" }} + + + + block + Revocation + + + {{ + isRecipientRevoked(mode) + ? `Revoked on ${formatDate(mode.revokedAt || "N/A")}` + : "Not revoked" + }} + + tag @@ -255,6 +309,7 @@ v-for="(ac, acIndex) in normalizeModeList(mode.accessModes)" :key="`${mode.sharedWith}-mode-${acIndex}`" class="mode-chip" + :class="{ revoked: isRecipientRevoked(mode) }" :title="ac" >{{ formatMode(ac) }} @@ -263,7 +318,10 @@ @@ -455,6 +513,22 @@ export default { copyText(text: string) { navigator.clipboard.writeText(text); }, + // SharedWithMe uses as:Offer/as:Undo rows; Undo rows represent revoked access. + isSharedWithMeRevoked(item: sharedSomething): boolean { + return Boolean(item.usersSharedWith[0]?.revoked); + }, + // SharedWithOthers rows carry revocation state from matched as:Undo entries. + isRecipientRevoked(mode: userHash): boolean { + return Boolean(mode.revoked); + }, + // Keep resource-level card state accurate even when one recipient was revoked. + hasActiveRecipients(item: sharedSomething): boolean { + return item.usersSharedWith.some((entry) => !this.isRecipientRevoked(entry)); + }, + // Compact state label used in collapsed/expanded sections. + getSharedWithMeStatusLabel(item: sharedSomething): string { + return this.isSharedWithMeRevoked(item) ? "Access revoked" : "Active access"; + }, /* Checks if the input item url is a container */ @@ -536,6 +610,9 @@ export default { return `${mode.sharedWith}-${mode.resourceUrl}-${mode.created}-${userIndex}`; }, startPermissionEdit(mode: userHash, userIndex: number) { + if (this.isRecipientRevoked(mode)) { + return; + } this.permissionEditError = ""; this.editedPermissions = this.permissionsFromModeIris(mode.accessModes); this.permissionRevokeDurationValue = null; @@ -691,6 +768,20 @@ export default { const count = item.usersSharedWith.length; return count === 1 ? "1 recipient" : `${count} recipients`; }, + // Show active/revoked counts so revoked permissions remain visible and understandable. + getRecipientStatusSummary(item: sharedSomething): string { + const revokedCount = item.usersSharedWith.filter((entry) => + this.isRecipientRevoked(entry) + ).length; + const activeCount = item.usersSharedWith.length - revokedCount; + if (revokedCount === 0) { + return `${this.getRecipientSummary(item)} active`; + } + if (activeCount === 0) { + return `${this.getRecipientSummary(item)} revoked`; + } + return `${activeCount} active, ${revokedCount} revoked`; + }, // Find the newest created timestamp among all recipients of one resource. getLatestEntryDate(item: sharedSomething): string { const times = item.usersSharedWith @@ -827,6 +918,9 @@ export default { display: grid; gap: 0.58rem; } +.shared-list > li { + min-width: 0; +} .shared-entry { border: 1px solid color-mix(in srgb, var(--border) 84%, var(--primary) 16%); border-radius: 14px; @@ -843,6 +937,14 @@ export default { .shared-entry.expanded { border-color: color-mix(in srgb, var(--primary) 34%, var(--border)); } +.shared-entry.revoked-entry { + border-color: color-mix(in srgb, var(--border) 92%, var(--text-muted) 8%); + background: color-mix(in srgb, var(--panel) 96%, var(--panel-elev) 4%); +} +.shared-entry.revoked-entry:hover { + border-color: color-mix(in srgb, var(--border) 90%, var(--text-muted) 10%); + background: color-mix(in srgb, var(--panel) 94%, var(--panel-elev) 6%); +} .entry-toggle { width: 100%; @@ -856,6 +958,7 @@ export default { cursor: pointer; font-family: "Oxanium", monospace; color: var(--text-secondary); + min-width: 0; } .entry-main { display: inline-flex; @@ -870,10 +973,13 @@ export default { min-width: 0; } .shared-others-collapsed-copy { - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; align-items: center; - gap: 0.8rem; + column-gap: 0.8rem; + row-gap: 0.24rem; width: 100%; + min-width: 0; } .shared-others-collapsed-copy .entry-title { flex: 1 1 auto; @@ -884,15 +990,19 @@ export default { align-items: center; justify-content: flex-end; gap: 0.7rem; - flex: 0 0 auto; + flex: 1 1 auto; + min-width: 0; + max-width: 100%; color: var(--text-muted); font-size: var(--font-size-page-summary); - white-space: nowrap; + white-space: normal; + flex-wrap: wrap; } .entry-inline-summary span { display: inline-flex; align-items: center; gap: 0.28rem; + min-width: 0; } .entry-copy-equalized { min-height: 2.8rem; @@ -913,6 +1023,26 @@ export default { color: var(--text-muted); line-height: 1.35; } +.entry-status { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + border: 1px solid color-mix(in srgb, var(--success) 45%, var(--border) 55%); + border-radius: 999px; + background: color-mix(in srgb, var(--success) 12%, var(--panel-elev) 88%); + color: color-mix(in srgb, var(--success) 80%, var(--text-primary) 20%); + font-size: var(--font-size-section-kicker); + font-weight: 700; + line-height: 1.2; + letter-spacing: 0.03em; + padding: 0.16rem 0.44rem; +} +.entry-status.revoked { + border-color: color-mix(in srgb, var(--border) 78%, var(--text-muted) 22%); + background: color-mix(in srgb, var(--panel) 84%, var(--panel-elev) 16%); + color: var(--text-muted); +} .info-icon { color: var(--text-muted); flex: 0 0 auto; @@ -1043,6 +1173,16 @@ export default { color: var(--text-secondary); overflow-wrap: anywhere; } +.shared-revoked-note { + margin: 0; + border: 1px solid color-mix(in srgb, var(--border) 80%, var(--text-muted) 20%); + border-radius: 10px; + background: color-mix(in srgb, var(--panel) 96%, var(--panel-elev) 4%); + color: var(--text-muted); + font-size: var(--font-size-page-summary); + font-weight: 700; + padding: 0.42rem 0.52rem; +} .mono { font-family: "Oxanium", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } @@ -1067,6 +1207,11 @@ export default { font-size: 0.76rem; font-weight: 700; } +.mode-chip.revoked { + border-color: color-mix(in srgb, var(--border) 80%, var(--text-muted) 20%); + background: color-mix(in srgb, var(--panel) 94%, var(--panel-elev) 6%); + color: var(--text-muted); +} .recipient-list { display: grid; @@ -1079,15 +1224,20 @@ export default { padding: 0.62rem; display: grid; gap: 0.58rem; + min-width: 0; +} +.recipient-card.revoked-recipient { + border-color: color-mix(in srgb, var(--border) 88%, var(--text-muted) 12%); + background: color-mix(in srgb, var(--panel) 96%, var(--panel-elev) 4%); } .recipient-header { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; gap: 0.45rem 0.7rem; padding-bottom: 0.52rem; border-bottom: 1px solid color-mix(in srgb, var(--border) 74%, transparent); + min-width: 0; } .recipient-target, .recipient-date { @@ -1098,19 +1248,24 @@ export default { font-size: var(--font-size-page-summary); } .recipient-target { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; color: var(--text-primary); font-weight: 700; + overflow-wrap: anywhere; } .recipient-date { color: var(--text-muted); } .recipient-actions { - display: inline-flex; + display: flex; align-items: center; justify-content: flex-end; gap: 0.55rem; flex-wrap: wrap; min-width: 0; + max-width: 100%; } .recipient-body { display: grid; @@ -1122,7 +1277,7 @@ export default { border: 1px solid color-mix(in srgb, var(--border) 78%, var(--primary) 22%); border-radius: 999px; background: color-mix(in srgb, var(--panel-elev) 90%, transparent); - color: var(--text-secondary); + color: var(--text-muted); display: inline-flex; align-items: center; justify-content: center; @@ -1148,7 +1303,7 @@ export default { .permission-save-button { background: linear-gradient(135deg, var(--primary), var(--primary-strong)); border-color: color-mix(in srgb, var(--primary) 62%, var(--border)); - color: var(--primary-contrast); + color: var(--text-primary); } .permission-save-button:disabled { cursor: wait; @@ -1157,12 +1312,7 @@ export default { .permission-editor { border: 1px solid color-mix(in srgb, var(--primary) 24%, var(--border)); border-radius: 12px; - background: - linear-gradient( - 135deg, - color-mix(in srgb, var(--panel-elev) 94%, var(--primary) 6%), - color-mix(in srgb, var(--panel) 96%, var(--primary) 4%) - ); + background: var(--panel-elev); display: grid; gap: 0.68rem; padding: 0.72rem; @@ -1296,6 +1446,7 @@ export default { .shared-others-collapsed-copy { display: grid; gap: 0.24rem; + grid-template-columns: minmax(0, 1fr); } .entry-inline-summary { justify-content: flex-start; @@ -1314,6 +1465,21 @@ export default { } } +@media (max-width: 1160px) { + .shared-others-collapsed-copy { + grid-template-columns: minmax(0, 1fr); + } + .entry-inline-summary { + justify-content: flex-start; + } + .recipient-header { + grid-template-columns: minmax(0, 1fr); + } + .recipient-actions { + justify-content: flex-start; + } +} + @media (max-width: 520px) { .permission-options { grid-template-columns: 1fr; diff --git a/src/services/query/queryPod.ts b/src/services/query/queryPod.ts index 9912691..a7193aa 100644 --- a/src/services/query/queryPod.ts +++ b/src/services/query/queryPod.ts @@ -9,7 +9,7 @@ import { saveSolidDatasetAt, createSolidDataset, createContainerAt, - saveFileInContainer, + overwriteFile, createThing, buildThing, setThing, @@ -126,6 +126,25 @@ export interface CoiFetchOptions { onError?: (e: unknown) => void; } +interface HttpFetchIssue { + url: string; + status: number; + statusText: string; +} + +/** + * Returns the Solid SDK-authenticated fetch used across query/cache operations. + * + * We intentionally rely on the module-level `fetch` export from + * `@inrupt/solid-client-authn-browser` because it is the most stable auth + * bridge across redirects and route transitions in this app. Earlier + * conditional session switching caused `queries.ttl` reads to intermittently + * fall back to unauthorized requests, breaking Past Queries loading. + */ +function getSolidAuthenticatedFetch(): FetchLike { + return fetch; +} + export type QueryExecutionMode = | "endpoint" | "solid-no-traversal" @@ -184,7 +203,7 @@ export const QUERY_MODE_DEFINITIONS: QueryModeDefinition[] = [ * @returns A new array of cleaned source URLs without angle brackets. */ export function cleanSourcesUrls(dirtySources: string[]): ComunicaSources[] { - return cleanSourcesUrlsInternal(dirtySources, fetch); + return cleanSourcesUrlsInternal(dirtySources, getSolidAuthenticatedFetch() as typeof fetch); } export function isQueryExecutionMode(modeLike: string): modeLike is QueryExecutionMode { @@ -294,6 +313,22 @@ function getIndexResourceUrl(containerUrl: string, fileName = "queries.ttl"): st return `${containerUrl}${fileName}`; } +function ensureTrailingSlash(url: string): string { + return url.endsWith("/") ? url : `${url}/`; +} + +/** + * Builds a deterministic cache member file URL inside a container. + * This avoids server-dependent POST+Slug handling and keeps member URLs explicit. + */ +export function buildCacheMemberFileUrl( + containerUrl: string, + fileName: string +): string { + const normalizedContainerUrl = ensureTrailingSlash(containerUrl.trim()); + return `${normalizedContainerUrl}${fileName}`; +} + function getQueryEntryUrl( containerUrl: string, hash: string, @@ -443,19 +478,86 @@ async function streamBindingsToOutput( * contexts where response header normalization is needed. */ function createAuthenticatedQueryFetch(options: { noCors: boolean }): FetchLike { - return createCoiFetch(fetch, { + const baseFetch: FetchLike = getSolidAuthenticatedFetch(); + + return createCoiFetch(baseFetch, { coepCredentialless: false, passthroughOpaque: true, noCors: options.noCors, }); } -function createSolidQueryContext(mixedSources: ComunicaSources[]): Record { +function getFetchInputUrl(input: RequestInfo | URL): string { + if (typeof input === "string") { + return input; + } + if (input instanceof URL) { + return input.href; + } + if (typeof Request !== "undefined" && input instanceof Request) { + return input.url; + } + return String(input); +} + +function isBlockingHttpStatus(status: number): boolean { + return status >= 400; +} + +function summarizeBlockingHttpIssues( + issues: HttpFetchIssue[], + mode: QueryExecutionMode +): string | null { + const blockingIssues = issues.filter((issue) => + isBlockingHttpStatus(issue.status) + ); + if (blockingIssues.length === 0) { + return null; + } + + const uniqueIssues = Array.from( + new Map( + blockingIssues.map((issue) => [ + `${issue.status}|${issue.url}`, + `${issue.status} ${issue.statusText || ""}`.trim() + + ` at ${issue.url}`, + ]) + ).values() + ); + + return `HTTP request error(s) detected during ${mode} query execution: ${uniqueIssues.join( + " | " + )}`; +} + +function createHttpIssueTrackingFetch( + baseFetch: FetchLike, + issues: HttpFetchIssue[] +): FetchLike { + return async (input, init) => { + const response = await baseFetch(input, init); + if (response && isBlockingHttpStatus(response.status)) { + issues.push({ + url: response.url || getFetchInputUrl(input), + status: response.status, + statusText: response.statusText || "", + }); + } + return response; + }; +} + +function createSolidQueryContext( + mixedSources: ComunicaSources[], + httpIssues: HttpFetchIssue[] +): Record { const session = getDefaultSession(); const hasAuthenticatedSession = Boolean(session.info.isLoggedIn); - const authenticatedFetch: FetchLike = hasAuthenticatedSession - ? session.fetch.bind(session) - : fetch; + const authenticatedFetchBase: FetchLike = getSolidAuthenticatedFetch(); + const authenticatedFetch = createHttpIssueTrackingFetch( + authenticatedFetchBase, + httpIssues + ); // Per Comunica Solid docs, provide the authn session in context so the Solid // HTTP actor can attach credentials for protected pod resources. @@ -475,9 +577,13 @@ function createSolidQueryContext(mixedSources: ComunicaSources[]): Record { - const authenticatedFetch = createAuthenticatedQueryFetch({ noCors: false }); + const authenticatedFetch = createHttpIssueTrackingFetch( + createAuthenticatedQueryFetch({ noCors: false }), + httpIssues + ); return { lenient: true, fetch: authenticatedFetch, @@ -503,13 +609,21 @@ async function executeEndpointCacheLookup( cachePath: string ): Promise { const endpointEngine = new SparqlEngineCache(); + const httpIssues: HttpFetchIssue[] = []; try { - return await executeWithEngine( + const output = await executeWithEngine( endpointEngine, inputQuery, - createEndpointCacheContext(mixedSources, cachePath), + createEndpointCacheContext(mixedSources, cachePath, httpIssues), { includeProvenance: true } ); + // A cache hit that resolves to an empty set can be caused by hard HTTP + // errors against one or more remote sources. Surface those explicitly. + const httpIssueMessage = summarizeBlockingHttpIssues(httpIssues, "endpoint"); + if (httpIssueMessage && output.resultsOutput.results.bindings.length === 0) { + throw new Error(httpIssueMessage); + } + return output; } catch { return "no-cache"; } @@ -519,13 +633,22 @@ async function executeSolidNoTraversalQuery( inputQuery: string, mixedSources: ComunicaSources[] ): Promise { + const httpIssues: HttpFetchIssue[] = []; try { const solidEngine = new SolidQueryEngine(); - return await executeWithEngine( + const output = await executeWithEngine( solidEngine, inputQuery, - createSolidQueryContext(mixedSources) + createSolidQueryContext(mixedSources, httpIssues) + ); + const httpIssueMessage = summarizeBlockingHttpIssues( + httpIssues, + "solid-no-traversal" ); + if (httpIssueMessage && output.resultsOutput.results.bindings.length === 0) { + return new Error(httpIssueMessage); + } + return output; } catch (err) { return err instanceof Error ? err : new Error(String(err)); } @@ -535,12 +658,21 @@ async function executeSolidLinkTraversalQuery( inputQuery: string, mixedSources: ComunicaSources[] ): Promise { + const httpIssues: HttpFetchIssue[] = []; try { - return await executeWithEngine( + const output = await executeWithEngine( new SolidLinkTraversalQueryEngine(), inputQuery, - createSolidQueryContext(mixedSources) + createSolidQueryContext(mixedSources, httpIssues) + ); + const httpIssueMessage = summarizeBlockingHttpIssues( + httpIssues, + "solid-link-traversal" ); + if (httpIssueMessage && output.resultsOutput.results.bindings.length === 0) { + return new Error(httpIssueMessage); + } + return output; } catch (err) { return err instanceof Error ? err : new Error(String(err)); } @@ -623,12 +755,12 @@ export async function ensureCacheContainer( try { // Try to retrieve the dataset (container) - await getSolidDataset(cacheUrl, { fetch }); + await getSolidDataset(cacheUrl, { fetch: getSolidAuthenticatedFetch() }); return cacheUrl; } catch (error) { // If not found, create the container (if it is the users pod in question) if (providedCache === podUrl) { - await createContainerAt(cacheUrl, { fetch }); + await createContainerAt(cacheUrl, { fetch: getSolidAuthenticatedFetch() }); console.log(`Query Cache container was created at ${cacheUrl}`); return cacheUrl; @@ -646,10 +778,10 @@ export async function ensureCacheContainer( * - head: A Thing representing the head of the RDF list. * - nodes: An array of all list node Things (to be added to your dataset). */ -function buildRdfList(sources: string[]): { head: Thing; nodes: Thing[] } { +export function buildRdfList(sources: string[]): { head: Thing; nodes: Thing[] } { if (sources.length === 0) { throw new Error( - "Cannot create a cache entry without at least one endpoint source." + "Cannot create a cache entry without at least one source URI." ); } @@ -657,6 +789,12 @@ function buildRdfList(sources: string[]): { head: Thing; nodes: Thing[] } { let listNode = createThing(); // creates a blank node automatically listNode = buildThing(listNode).addIri(RDF_FIRST, sources[0]).build(); + // Base case: a single source terminates the RDF list with rdf:nil. + if (sources.length === 1) { + listNode = buildThing(listNode).addIri(RDF_REST, RDF_NIL).build(); + return { head: listNode, nodes: [listNode] }; + } + // Recursively build the rest of the list. const restList = buildRdfList(sources.slice(1)); @@ -742,7 +880,7 @@ export async function upsertQueryCacheEntry( let dataset: SolidDataset; try { dataset = await getSolidDataset(getIndexResourceUrl(containerUrl, fileName), { - fetch, + fetch: getSolidAuthenticatedFetch(), }); } catch { dataset = createSolidDataset(); @@ -817,7 +955,7 @@ export async function upsertQueryCacheEntry( }); await saveSolidDatasetAt(getIndexResourceUrl(containerUrl, fileName), updatedDataset, { - fetch, + fetch: getSolidAuthenticatedFetch(), }); return entry.hash; @@ -891,12 +1029,12 @@ export async function uploadQueryFile( ): Promise { const fileName = hashName + ".rq"; const blob = new Blob([query], { type: "application/sparql-query" }); + const fileUrl = buildCacheMemberFileUrl(containerUrl, fileName); try { - const savedFile = await saveFileInContainer(containerUrl, blob, { - slug: fileName, + const savedFile = await overwriteFile(fileUrl, blob, { contentType: "application/sparql-query", - fetch, + fetch: getSolidAuthenticatedFetch(), }); console.log( `Uploaded ${fileName} to ${savedFile.internal_resourceInfo.sourceIri}` @@ -941,12 +1079,12 @@ export async function uploadResults( const blob = new Blob([jsonString], { type: "application/sparql-results+json", }); + const fileUrl = buildCacheMemberFileUrl(containerUrl, fileName); try { - const savedFile = await saveFileInContainer(containerUrl, blob, { - slug: fileName, + const savedFile = await overwriteFile(fileUrl, blob, { contentType: "application/json", - fetch, + fetch: getSolidAuthenticatedFetch(), }); console.log( `Uploaded ${fileName} to ${savedFile.internal_resourceInfo.sourceIri}` @@ -959,7 +1097,7 @@ export async function uploadResults( } /** - * Determines if there is are cached queries in the pod. + * Determines if there are cached queries in the pod. * * @param containerUrl - The ttl URL * @returns boolean representing if a cache is present. @@ -967,7 +1105,7 @@ export async function uploadResults( export async function getStoredTtl(resourceUrl: string): Promise { try { // Try to retrieve the dataset and save updated dataset - await getSolidDataset(resourceUrl, { fetch }); + await getSolidDataset(resourceUrl, { fetch: getSolidAuthenticatedFetch() }); return true; } catch (error) { return false; @@ -997,7 +1135,7 @@ export async function renameCachedQueryEntry( ): Promise { const entryUrl = `${ttlFileUrl}#${targetHash}`; try { - let dataset = await getSolidDataset(ttlFileUrl, { fetch }); + let dataset = await getSolidDataset(ttlFileUrl, { fetch: getSolidAuthenticatedFetch() }); const entryThing = getThing(dataset, entryUrl); if (!entryThing) { return false; @@ -1006,7 +1144,7 @@ export async function renameCachedQueryEntry( let renamedThing = setStringNoLocale(entryThing, DCT_TITLE, title.trim()); renamedThing = setDatetime(renamedThing, DCT_MODIFIED, new Date()); dataset = setThing(dataset, renamedThing); - await saveSolidDatasetAt(ttlFileUrl, dataset, { fetch }); + await saveSolidDatasetAt(ttlFileUrl, dataset, { fetch: getSolidAuthenticatedFetch() }); return true; } catch (error) { console.error(`Could not rename cached query ${targetHash}:`, error); @@ -1034,7 +1172,9 @@ export async function renameCachedQueryEntry( export async function getCachedQueries( ttlFileUrl: string ): Promise { - const dataset: SolidDataset = await getSolidDataset(ttlFileUrl, { fetch }); + const dataset: SolidDataset = await getSolidDataset(ttlFileUrl, { + fetch: getSolidAuthenticatedFetch(), + }); const things: Thing[] = getThingAll(dataset); const queryEntries: QueryEntry[] = []; @@ -1113,7 +1253,7 @@ function rdfListSources( * @returns A promise that resolves to the text content of the query file. */ export async function fetchQueryFileData(fileUrl: string): Promise { - const file = await getFile(fileUrl, { fetch }); + const file = await getFile(fileUrl, { fetch: getSolidAuthenticatedFetch() }); const textContent = await file.text(); return textContent; } @@ -1127,7 +1267,7 @@ export async function fetchQueryFileData(fileUrl: string): Promise { export async function fetchSparqlJsonFileData( fileUrl: string ): Promise { - const file = await getFile(fileUrl, { fetch }); + const file = await getFile(fileUrl, { fetch: getSolidAuthenticatedFetch() }); const textContent = await file.text(); try { const jsonData = JSON.parse(textContent); diff --git a/src/services/query/queryWorker.js b/src/services/query/queryWorker.js index c1cb99d..3b78191 100644 --- a/src/services/query/queryWorker.js +++ b/src/services/query/queryWorker.js @@ -3,6 +3,30 @@ self.global = self; let controller = null; let stopCpu = false; +function getFetchInputUrl(input) { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + if (typeof Request !== "undefined" && input instanceof Request) { + return input.url; + } + return String(input); +} + +function summarizeBlockingHttpIssues(httpIssues) { + if (!Array.isArray(httpIssues) || httpIssues.length === 0) { + return null; + } + const unique = Array.from( + new Map( + httpIssues.map((issue) => [ + `${issue.status}|${issue.url}`, + `${issue.status} ${issue.statusText || ""}`.trim() + ` at ${issue.url}`, + ]), + ).values(), + ); + return `HTTP request error(s) detected during endpoint query execution: ${unique.join(" | ")}`; +} + /** * Executes a SPARQL query over one or many SPARQL endpoints. * @@ -19,12 +43,25 @@ self.onmessage = async (e) => { stopCpu = false; controller = new AbortController(); const { signal } = controller; + const httpIssues = []; + const statusTrackingFetch = async (input, init) => { + const response = await fetch(input, init); + if (response && response.status >= 400) { + httpIssues.push({ + url: response.url || getFetchInputUrl(input), + status: response.status, + statusText: response.statusText || "", + }); + } + return response; + }; const engine = new QueryEngineSparql(); try { // execute query using Comunica engine const bindingsStream = await engine.queryBindings(query, { sources, lenient: true, + fetch: statusTrackingFetch, }); // stream results in bindings array @@ -58,6 +95,15 @@ self.onmessage = async (e) => { ? Array.from(firstBinding.keys()).map((variable) => variable.value) : []; + // Preserve historical lenient behavior, but if the run produced no rows + // and we saw blocking HTTP statuses, surface that as a user-visible error. + if (bindingsArray.length === 0) { + const httpIssueMessage = summarizeBlockingHttpIssues(httpIssues); + if (httpIssueMessage) { + throw new Error(httpIssueMessage); + } + } + // Return results. self.postMessage({ type: "result", diff --git a/src/services/solid/privacyEdit.ts b/src/services/solid/privacyEdit.ts index e56eba8..cfd84fb 100644 --- a/src/services/solid/privacyEdit.ts +++ b/src/services/solid/privacyEdit.ts @@ -798,6 +798,9 @@ export interface userHash { created: string; revokeAt?: string; offerIri?: string; + revoked?: boolean; + revokedAt?: string; + revokedOfferIri?: string; } export interface indexedUserHash { @@ -835,12 +838,37 @@ export async function getSharedWithOthers( things.forEach((thing) => thingByUrl.set(thing.url, thing)); // Undo entries explicitly revoke prior offers through as:object. - const revokedOfferIris = new Set( - things - .filter((thing) => getIri(thing, RDF_TYPE) === AS_UNDO) - .map((thing) => getUrl(thing, AS_OBJECT)) - .filter((iri): iri is string => Boolean(iri)) - ); + const revokedOfferInfo = new Map< + string, + { revokedAt?: string; undoIri?: string } + >(); + things + .filter((thing) => getIri(thing, RDF_TYPE) === AS_UNDO) + .forEach((thing) => { + const revokedOfferIri = getUrl(thing, AS_OBJECT); + if (!revokedOfferIri) { + return; + } + const nextRevokedAt = getDatetime(thing, DCT_CREATED)?.toISOString(); + const existing = revokedOfferInfo.get(revokedOfferIri); + if (!existing) { + revokedOfferInfo.set(revokedOfferIri, { + revokedAt: nextRevokedAt, + undoIri: thing.url, + }); + return; + } + const existingTime = existing.revokedAt + ? new Date(existing.revokedAt).getTime() + : 0; + const nextTime = nextRevokedAt ? new Date(nextRevokedAt).getTime() : 0; + if (nextTime >= existingTime) { + revokedOfferInfo.set(revokedOfferIri, { + revokedAt: nextRevokedAt, + undoIri: thing.url, + }); + } + }); const sharedItems: sharedSomething[] = []; things.forEach((thing) => { // Extract the hash from the Thing’s URL fragment. @@ -859,10 +887,12 @@ export async function getSharedWithOthers( const sharedHashes = getIriAll( thing, AS_OFFER - ) - // Offers that are already referenced by Undo entries are no longer active. - .filter((offerIri) => !revokedOfferIris.has(offerIri)); - const usersSharedWith = thingsUsersSharedWithParse(sharedHashes, thingByUrl); + ); + const usersSharedWith = thingsUsersSharedWithParse( + sharedHashes, + thingByUrl, + revokedOfferInfo + ); const owner = currentUserWebId; if (usersSharedWith.length > 0) { @@ -887,7 +917,8 @@ export async function getSharedWithOthers( */ function thingsUsersSharedWithParse( userHashes: string[], - thingByUrl: Map + thingByUrl: Map, + revokedOfferInfo: Map ): userHash[] { const usersSharedWith: userHash[] = []; userHashes.forEach((hashIri) => { @@ -900,6 +931,8 @@ function thingsUsersSharedWithParse( const resourceUrl = getUrl(hashThing, ACL_ACCESS_TO) || "N/A"; const access = getIriAll(hashThing, "http://www.w3.org/ns/auth/acl#mode") || ["N/A"]; const revokeAt = getDatetime(hashThing, DCT_VALID)?.toISOString(); + const revokedInfo = revokedOfferInfo.get(hashThing.url); + const revoked = Boolean(revokedInfo); usersSharedWith.push({ sharedWith, @@ -908,6 +941,9 @@ function thingsUsersSharedWithParse( created, revokeAt, offerIri: hashThing.url, + revoked, + revokedAt: revokedInfo?.revokedAt, + revokedOfferIri: revokedInfo?.undoIri, }); }); return usersSharedWith; @@ -938,6 +974,9 @@ export function getDueSharedWithOthersRevocations( return sharedItems.flatMap((item) => item.usersSharedWith .filter((entry) => { + if (entry.revoked) { + return false; + } if (!entry.revokeAt || !entry.offerIri) { return false; } @@ -1158,6 +1197,37 @@ export async function getSharedWithMe( const dataset = await getSolidDataset(sharedWithMeUrl, { fetch }); const things: Thing[] = getThingAll(dataset); + const revokedOfferInfo = new Map< + string, + { revokedAt?: string; undoIri?: string } + >(); + things + .filter((thing) => getIri(thing, RDF_TYPE) === AS_UNDO) + .forEach((thing) => { + const revokedOfferIri = getUrl(thing, AS_OBJECT); + if (!revokedOfferIri) { + return; + } + const nextRevokedAt = getDatetime(thing, DCT_CREATED)?.toISOString(); + const existing = revokedOfferInfo.get(revokedOfferIri); + if (!existing) { + revokedOfferInfo.set(revokedOfferIri, { + revokedAt: nextRevokedAt, + undoIri: thing.url, + }); + return; + } + const existingTime = existing.revokedAt + ? new Date(existing.revokedAt).getTime() + : 0; + const nextTime = nextRevokedAt ? new Date(nextRevokedAt).getTime() : 0; + if (nextTime >= existingTime) { + revokedOfferInfo.set(revokedOfferIri, { + revokedAt: nextRevokedAt, + undoIri: thing.url, + }); + } + }); const sharedItems: sharedSomething[] = []; let lastAccessed: string = "N/A"; @@ -1166,6 +1236,7 @@ export async function getSharedWithMe( const resourceHash = thing.url.includes("#") ? thing.url.split("#")[1] : ""; + const typeIri = getIri(thing, RDF_TYPE) || "N/A"; // Get the last accessed time if (resourceHash === "lastAccess") { @@ -1175,6 +1246,16 @@ export async function getSharedWithMe( "http://purl.org/dc/terms/modified" )?.toISOString() || "N/A"; } else { + const isOffer = typeIri === AS_OFFER; + const isUndo = typeIri === AS_UNDO; + if (!isOffer && !isUndo) { + return; + } + // Skip offers that were later revoked; the matching Undo entry is used + // to represent current state in SharedWithMe. + if (isOffer && revokedOfferInfo.has(thing.url)) { + return; + } // Get all other info const creator = getUrl(thing, "http://purl.org/dc/terms/creator") || "N/A"; @@ -1189,9 +1270,11 @@ export async function getSharedWithMe( thing, "http://www.w3.org/ns/auth/acl#mode" ) || ["N/A"]; - const whatKind = - getIri(thing, "http://www.w3.org/1999/02/22-rdf-syntax-ns#type") || - "N/A"; + const revokedOfferIri = isUndo ? getUrl(thing, AS_OBJECT) || undefined : undefined; + const revokedInfo = revokedOfferIri + ? revokedOfferInfo.get(revokedOfferIri) + : undefined; + const whatKind = typeIri; const usersSharedWith: userHash[] = [ { @@ -1199,6 +1282,9 @@ export async function getSharedWithMe( resourceUrl: accessTo, accessModes: accessModes, created: created, + revoked: isUndo, + revokedAt: revokedInfo?.revokedAt || (isUndo ? created : undefined), + revokedOfferIri, }, ]; diff --git a/tests/components/AllComponentsSmoke.test.ts b/tests/components/AllComponentsSmoke.test.ts index d482de1..9dc41f7 100644 --- a/tests/components/AllComponentsSmoke.test.ts +++ b/tests/components/AllComponentsSmoke.test.ts @@ -317,8 +317,7 @@ describe("Focused Styling Component Tests", () => { expect(wrapper.text()).toContain("Last Modified: 2026-02-20"); }); - expect(wrapper.text()).toContain("Version: v1.0.0"); - expect(wrapper.text()).toContain("Version: v1.0.0"); + expect(wrapper.text()).toContain(`Version: v${__APP_VERSION__}`); expect(fetchMock).toHaveBeenCalledOnce(); }); diff --git a/tests/unit/privacyEdit.test.ts b/tests/unit/privacyEdit.test.ts index 14415a4..cfba518 100644 --- a/tests/unit/privacyEdit.test.ts +++ b/tests/unit/privacyEdit.test.ts @@ -264,3 +264,30 @@ test("getDueSharedWithOthersRevocations returns only entries with expired revoke "https://owner.example/inbox/sharedWithOthers.ttl#offer-a" ); }); + +test("getDueSharedWithOthersRevocations skips entries already marked as revoked", () => { + const dueEntries = getDueSharedWithOthersRevocations( + [ + { + resourceHash: "https://owner.example/docs/", + owner: "https://owner.example/profile/card#me", + whatKind: "https://www.w3.org/ns/ldp#Container", + usersSharedWith: [ + { + sharedWith: "https://target.example/profile/card#me", + accessModes: ["http://www.w3.org/ns/auth/acl#Read"], + resourceUrl: "https://owner.example/docs/", + created: "2029-01-01T00:00:00.000Z", + revokeAt: "2030-01-01T00:00:00.000Z", + offerIri: "https://owner.example/inbox/sharedWithOthers.ttl#offer-a", + revoked: true, + revokedAt: "2030-01-01T00:01:00.000Z", + }, + ], + }, + ], + new Date("2030-06-01T00:00:00.000Z") + ); + + assert.equal(dueEntries.length, 0); +}); diff --git a/tests/unit/queryPodCachePaths.test.ts b/tests/unit/queryPodCachePaths.test.ts new file mode 100644 index 0000000..7141d78 --- /dev/null +++ b/tests/unit/queryPodCachePaths.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildCacheMemberFileUrl } from "../../src/services/query/queryPod.ts"; + +test("buildCacheMemberFileUrl appends filename inside container URLs with slash", () => { + assert.equal( + buildCacheMemberFileUrl("https://pod.example.com/querycache/", "abc123.rq"), + "https://pod.example.com/querycache/abc123.rq" + ); +}); + +test("buildCacheMemberFileUrl normalizes missing trailing slash and trims whitespace", () => { + assert.equal( + buildCacheMemberFileUrl(" https://pod.example.com/querycache ", "abc123.json"), + "https://pod.example.com/querycache/abc123.json" + ); +}); diff --git a/tests/unit/queryPodRdfList.test.ts b/tests/unit/queryPodRdfList.test.ts new file mode 100644 index 0000000..3588e4a --- /dev/null +++ b/tests/unit/queryPodRdfList.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getUrl } from "@inrupt/solid-client"; +import { buildRdfList } from "../../src/services/query/queryPod.ts"; + +const RDF_REST = "http://www.w3.org/1999/02/22-rdf-syntax-ns#rest"; +const RDF_NIL = "http://www.w3.org/1999/02/22-rdf-syntax-ns#nil"; + +test("buildRdfList terminates a single-source list with rdf:nil", () => { + const { head, nodes } = buildRdfList(["https://pod.example.com/public/data.ttl"]); + assert.equal(nodes.length, 1); + assert.equal(getUrl(head, RDF_REST), RDF_NIL); +}); + +test("buildRdfList creates chained nodes for multi-source lists", () => { + const { head, nodes } = buildRdfList([ + "https://pod.example.com/public/data-a.ttl", + "https://pod.example.com/public/data-b.ttl", + ]); + assert.equal(nodes.length, 2); + assert.notEqual(getUrl(head, RDF_REST), RDF_NIL); + assert.equal(getUrl(nodes[1], RDF_REST), RDF_NIL); +});
@@ -187,7 +221,14 @@ schedule {{ formatDate(mode.created) }} + + {{ isRecipientRevoked(mode) ? "Revoked" : "Active" }} +