1- import { NamedNode } from 'rdflib'
1+ import { literal , NamedNode , st , sym } from 'rdflib'
2+ import { ACL_LINK } from '../acl/aclLogic'
23import { CrossOriginForbiddenError , FetchError , NotEditableError , SameOriginForbiddenError , UnauthorizedError , WebOperationError } from '../logic/CustomError'
34import * as debug from '../util/debug'
45import { 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'
511import { differentOrigin , suggestPreferencesFile } from '../util/utils'
612import { ProfileLogic } from '../types'
713
814export 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 > {
0 commit comments