Skip to content

Commit 3663b4c

Browse files
authored
Merge branch 'main' into npmPublish
2 parents 37d1d0f + 3db31d9 commit 3663b4c

12 files changed

Lines changed: 701 additions & 121 deletions

src/profile/profileAclDocuments.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export function ownerOnlyContainerAclDocument(webId: string): string {
2+
return [
3+
'@prefix acl: <http://www.w3.org/ns/auth/acl#>.',
4+
'',
5+
'<#owner>',
6+
'a acl:Authorization;',
7+
`acl:agent <${webId}>;`,
8+
'acl:accessTo <./>;',
9+
'acl:default <./>;',
10+
'acl:mode acl:Read, acl:Write, acl:Control.'
11+
].join('\n')
12+
}

src/profile/profileDocuments.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export function preferencesFileDocument(): string {
2+
return [
3+
'@prefix dct: <http://purl.org/dc/terms/>.',
4+
'@prefix pim: <http://www.w3.org/ns/pim/space#>.',
5+
'@prefix solid: <http://www.w3.org/ns/solid/terms#>.',
6+
'<>',
7+
' a pim:ConfigurationFile ;',
8+
' dct:title "Preferences file".'
9+
].join('\n')
10+
}

src/profile/profileLogic.ts

Lines changed: 225 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,196 @@
1-
import { NamedNode } from 'rdflib'
1+
import { literal, NamedNode, st, sym } from 'rdflib'
2+
import { ACL_LINK } from '../acl/aclLogic'
23
import { CrossOriginForbiddenError, FetchError, NotEditableError, SameOriginForbiddenError, UnauthorizedError, WebOperationError } from '../logic/CustomError'
34
import * as debug from '../util/debug'
45
import { ns as namespace } from '../util/ns'
6+
import { privateTypeIndexDocument, publicTypeIndexDocument } from '../typeIndex/typeIndexDocuments'
7+
import { publicTypeIndexAclDocument } from '../typeIndex/typeIndexAclDocuments'
8+
import { preferencesFileDocument } from './profileDocuments'
9+
import { ownerOnlyContainerAclDocument } from './profileAclDocuments'
10+
import { createContainerLogic } from '../util/containerLogic'
511
import { differentOrigin, suggestPreferencesFile } from '../util/utils'
612
import { ProfileLogic } from '../types'
713

814
export function createProfileLogic(store, authn, utilityLogic): ProfileLogic {
915
const ns = namespace
16+
const containerLogic = createContainerLogic(store)
17+
const loadPreferencesInFlight = new Map<string, Promise<NamedNode>>()
18+
const cachedPreferencesFileByWebId = new Map<string, NamedNode>()
19+
20+
function isAbsoluteHttpUri(uri: string | null | undefined): boolean {
21+
return !!uri && (uri.startsWith('https://') || uri.startsWith('http://'))
22+
}
23+
24+
function docDirUri(node: NamedNode): string | null {
25+
const doc = node.doc()
26+
const dir = doc.dir()
27+
if (dir?.uri && isAbsoluteHttpUri(dir.uri)) return dir.uri
28+
const docUri = doc.uri
29+
if (!docUri || !isAbsoluteHttpUri(docUri)) return null
30+
const withoutFragment = docUri.split('#')[0]
31+
const lastSlash = withoutFragment.lastIndexOf('/')
32+
if (lastSlash === -1) return null
33+
return withoutFragment.slice(0, lastSlash + 1)
34+
}
35+
36+
function suggestTypeIndexInPreferences(preferencesFile: NamedNode, filename: string): NamedNode {
37+
const dirUri = docDirUri(preferencesFile)
38+
if (!dirUri) throw new Error(`Cannot derive directory for preferences file ${preferencesFile.uri}`)
39+
return sym(dirUri + filename)
40+
}
41+
42+
function isNotFoundError(err: any): boolean {
43+
if (err?.response?.status === 404) return true
44+
const text = `${err?.message || err || ''}`
45+
return text.includes('404') || text.includes('Not Found')
46+
}
47+
48+
async function ensureContainerExists(containerUri: string): Promise<void> {
49+
const containerNode = sym(containerUri)
50+
try {
51+
await store.fetcher.load(containerNode)
52+
return
53+
} catch (err) {
54+
if (!isNotFoundError(err)) throw err
55+
}
56+
await containerLogic.createContainer(containerUri)
57+
}
58+
59+
async function ensureOwnerOnlyAclForSettings(user: NamedNode, preferencesFile: NamedNode): Promise<void> {
60+
const dirUri = docDirUri(preferencesFile)
61+
if (!dirUri) throw new Error(`Cannot derive settings directory from ${preferencesFile.uri}`)
62+
await ensureContainerExists(dirUri)
63+
64+
const containerNode = sym(dirUri)
65+
let aclDocUri: string | undefined
66+
try {
67+
await store.fetcher.load(containerNode)
68+
aclDocUri = store.any(containerNode, ACL_LINK)?.value
69+
} catch (err) {
70+
if (!isNotFoundError(err)) throw err
71+
}
72+
if (!aclDocUri) {
73+
// Fallback for servers/tests where rel=acl is not exposed in mocked headers.
74+
aclDocUri = `${dirUri}.acl`
75+
}
76+
const aclDoc = sym(aclDocUri)
77+
try {
78+
await store.fetcher.load(aclDoc)
79+
return
80+
} catch (err) {
81+
if (!isNotFoundError(err)) throw err
82+
}
83+
84+
await store.fetcher.webOperation('PUT', aclDoc.uri, {
85+
data: ownerOnlyContainerAclDocument(user.uri),
86+
contentType: 'text/turtle'
87+
})
88+
}
89+
90+
async function ensurePublicTypeIndexAclOnCreate(user: NamedNode, publicTypeIndex: NamedNode, ensureAcl = false): Promise<void> {
91+
const created = await utilityLogic.loadOrCreateWithContentOnCreate(publicTypeIndex, publicTypeIndexDocument())
92+
if (!created && !ensureAcl) return
93+
94+
let aclDocUri: string | undefined
95+
try {
96+
await store.fetcher.load(publicTypeIndex)
97+
aclDocUri = store.any(publicTypeIndex, ACL_LINK)?.value
98+
} catch (err) {
99+
if (!isNotFoundError(err)) throw err
100+
}
101+
if (!aclDocUri) {
102+
aclDocUri = `${publicTypeIndex.uri}.acl`
103+
}
104+
105+
const aclDoc = sym(aclDocUri)
106+
try {
107+
await store.fetcher.webOperation('PUT', aclDoc.uri, {
108+
data: publicTypeIndexAclDocument(user.uri, publicTypeIndex.uri),
109+
contentType: 'text/turtle',
110+
headers: { 'If-None-Match': '*' }
111+
})
112+
} catch (err: any) {
113+
const status = err?.response?.status ?? err?.status
114+
if (status !== 412) throw err
115+
}
116+
}
117+
118+
async function ensurePrivateTypeIndexOnCreate(privateTypeIndex: NamedNode): Promise<void> {
119+
await utilityLogic.loadOrCreateWithContentOnCreate(privateTypeIndex, privateTypeIndexDocument())
120+
}
121+
122+
async function initializePreferencesDefaults(user: NamedNode, preferencesFile: NamedNode): Promise<void> {
123+
const preferencesDoc = preferencesFile.doc() as NamedNode
124+
const profileDoc = user.doc() as NamedNode
125+
await store.fetcher.load(preferencesDoc)
126+
127+
const profilePublicTypeIndex =
128+
(store.any(user, ns.solid('publicTypeIndex'), null, profileDoc) as NamedNode | null)
129+
const preferencesPublicTypeIndex =
130+
(store.any(user, ns.solid('publicTypeIndex'), null, preferencesDoc) as NamedNode | null)
131+
const publicTypeIndex =
132+
profilePublicTypeIndex ||
133+
preferencesPublicTypeIndex ||
134+
suggestTypeIndexInPreferences(preferencesFile, 'publicTypeIndex.ttl')
135+
const privateTypeIndex =
136+
(store.any(user, ns.solid('privateTypeIndex'), null, preferencesDoc) as NamedNode | null) ||
137+
suggestTypeIndexInPreferences(preferencesFile, 'privateTypeIndex.ttl')
138+
139+
// Keep discovery consistent with typeIndexLogic, which resolves publicTypeIndex from the profile doc.
140+
const createdProfilePublicTypeIndexLink = !profilePublicTypeIndex
141+
if (createdProfilePublicTypeIndexLink) {
142+
await utilityLogic.followOrCreateLinkWithContentOnCreate(
143+
user,
144+
ns.solid('publicTypeIndex') as NamedNode,
145+
publicTypeIndex,
146+
profileDoc,
147+
publicTypeIndexDocument()
148+
)
149+
}
150+
151+
const toInsert: any[] = []
152+
if (!store.holds(preferencesDoc, ns.rdf('type'), ns.space('ConfigurationFile'), preferencesDoc)) {
153+
toInsert.push(st(preferencesDoc, ns.rdf('type'), ns.space('ConfigurationFile'), preferencesDoc))
154+
}
155+
if (!store.holds(preferencesDoc, ns.dct('title'), undefined, preferencesDoc)) {
156+
toInsert.push(st(preferencesDoc, ns.dct('title'), literal('Preferences file'), preferencesDoc))
157+
}
158+
if (!store.holds(user, ns.solid('publicTypeIndex'), publicTypeIndex, preferencesDoc)) {
159+
toInsert.push(st(user, ns.solid('publicTypeIndex'), publicTypeIndex, preferencesDoc))
160+
}
161+
if (!store.holds(user, ns.solid('privateTypeIndex'), privateTypeIndex, preferencesDoc)) {
162+
toInsert.push(st(user, ns.solid('privateTypeIndex'), privateTypeIndex, preferencesDoc))
163+
}
164+
165+
if (toInsert.length > 0) {
166+
await store.updater.update([], toInsert)
167+
await store.fetcher.load(preferencesDoc)
168+
}
169+
170+
await ensurePublicTypeIndexAclOnCreate(user, publicTypeIndex, createdProfilePublicTypeIndexLink)
171+
await ensurePrivateTypeIndexOnCreate(privateTypeIndex)
172+
}
173+
174+
async function ensurePreferencesDocExists(preferencesFile: NamedNode): Promise<boolean> {
175+
try {
176+
const created = await utilityLogic.loadOrCreateWithContentOnCreate(preferencesFile, preferencesFileDocument())
177+
if (created) {
178+
return true
179+
}
180+
return false
181+
} catch (err) {
182+
if (err.response?.status === 401) {
183+
throw new UnauthorizedError()
184+
}
185+
if (err.response?.status === 403) {
186+
if (differentOrigin(preferencesFile)) {
187+
throw new CrossOriginForbiddenError()
188+
}
189+
throw new SameOriginForbiddenError()
190+
}
191+
throw err
192+
}
193+
}
10194

11195
/**
12196
* loads the preference without throwing errors - if it can create it it does so.
@@ -29,12 +213,32 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic {
29213
* @returns undefined if preferenceFile cannot be an Error or NamedNode if it can find it or create it
30214
*/
31215
async function loadPreferences (user: NamedNode): Promise <NamedNode> {
216+
const cachedPreferencesFile = cachedPreferencesFileByWebId.get(user.uri)
217+
if (cachedPreferencesFile) {
218+
return cachedPreferencesFile
219+
}
220+
221+
const inFlight = loadPreferencesInFlight.get(user.uri)
222+
if (inFlight) {
223+
return inFlight
224+
}
225+
226+
const run = (async (): Promise<NamedNode> => {
32227
await loadProfile(user)
33228

34229
const possiblePreferencesFile = suggestPreferencesFile(user)
35230
let preferencesFile
36231
try {
37-
preferencesFile = await utilityLogic.followOrCreateLink(user, ns.space('preferencesFile') as NamedNode, possiblePreferencesFile, user.doc())
232+
const existingPreferencesFile = store.any(user, ns.space('preferencesFile'), null, user.doc()) as NamedNode | null
233+
if (existingPreferencesFile) {
234+
preferencesFile = existingPreferencesFile
235+
} else {
236+
preferencesFile = await utilityLogic.followOrCreateLink(user, ns.space('preferencesFile') as NamedNode, possiblePreferencesFile, user.doc())
237+
}
238+
239+
await ensureOwnerOnlyAclForSettings(user, preferencesFile as NamedNode)
240+
await ensurePreferencesDocExists(preferencesFile as NamedNode)
241+
await initializePreferencesDefaults(user, preferencesFile as NamedNode)
38242
} catch (err) {
39243
const message = `User ${user} has no pointer in profile to preferences file.`
40244
debug.warn(message)
@@ -52,6 +256,14 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic {
52256
await store.fetcher.load(preferencesFile as NamedNode)
53257
} catch (err) { // Maybe a permission problem or origin problem
54258
const msg = `Unable to load preference of user ${user}: ${err}`
259+
if (err.response?.status === 404) {
260+
// Self-heal when a stale profile pointer references a missing preferences file.
261+
await ensureOwnerOnlyAclForSettings(user, preferencesFile as NamedNode)
262+
await ensurePreferencesDocExists(preferencesFile as NamedNode)
263+
await initializePreferencesDefaults(user, preferencesFile as NamedNode)
264+
await store.fetcher.load(preferencesFile as NamedNode)
265+
return preferencesFile as NamedNode
266+
}
55267
debug.warn(msg)
56268
if (err.response.status === 401) {
57269
throw new UnauthorizedError()
@@ -67,7 +279,18 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic {
67279
}*/
68280
throw new Error(msg)
69281
}
282+
cachedPreferencesFileByWebId.set(user.uri, preferencesFile as NamedNode)
70283
return preferencesFile as NamedNode
284+
})()
285+
286+
loadPreferencesInFlight.set(user.uri, run)
287+
try {
288+
return await run
289+
} finally {
290+
if (loadPreferencesInFlight.get(user.uri) === run) {
291+
loadPreferencesInFlight.delete(user.uri)
292+
}
293+
}
71294
}
72295

73296
async function loadProfile (user: NamedNode):Promise <NamedNode> {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export function publicTypeIndexAclDocument(webId: string, publicTypeIndexUri: string): string {
2+
const fileName = new URL(publicTypeIndexUri).pathname.split('/').pop() || 'publicTypeIndex.ttl'
3+
return [
4+
'# ACL resource for the Public Type Index',
5+
'',
6+
'@prefix acl: <http://www.w3.org/ns/auth/acl#>.',
7+
'@prefix foaf: <http://xmlns.com/foaf/0.1/>.',
8+
'',
9+
'<#owner>',
10+
' a acl:Authorization;',
11+
'',
12+
' acl:agent',
13+
` <${webId}>;`,
14+
'',
15+
` acl:accessTo <./${fileName}>;`,
16+
'',
17+
' acl:mode',
18+
' acl:Read, acl:Write, acl:Control.',
19+
'',
20+
'# Public-readable',
21+
'<#public>',
22+
' a acl:Authorization;',
23+
'',
24+
' acl:agentClass foaf:Agent;',
25+
'',
26+
` acl:accessTo <./${fileName}>;`,
27+
'',
28+
' acl:mode acl:Read.'
29+
].join('\n')
30+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export function publicTypeIndexDocument(): string {
2+
return [
3+
'@prefix solid: <http://www.w3.org/ns/solid/terms#>.',
4+
'<>',
5+
' a solid:TypeIndex ;',
6+
' a solid:ListedDocument.'
7+
].join('\n')
8+
}
9+
10+
export function privateTypeIndexDocument(): string {
11+
return [
12+
'@prefix solid: <http://www.w3.org/ns/solid/terms#>.',
13+
'<>',
14+
' a solid:TypeIndex ;',
15+
' a solid:UnlistedDocument.'
16+
].join('\n')
17+
}

0 commit comments

Comments
 (0)