diff --git a/src/profile/profileAclDocuments.ts b/src/profile/profileAclDocuments.ts new file mode 100644 index 0000000..0b23b99 --- /dev/null +++ b/src/profile/profileAclDocuments.ts @@ -0,0 +1,12 @@ +export function ownerOnlyContainerAclDocument(webId: string): string { + return [ + '@prefix acl: .', + '', + '<#owner>', + 'a acl:Authorization;', + `acl:agent <${webId}>;`, + 'acl:accessTo <./>;', + 'acl:default <./>;', + 'acl:mode acl:Read, acl:Write, acl:Control.' + ].join('\n') +} diff --git a/src/profile/profileDocuments.ts b/src/profile/profileDocuments.ts new file mode 100644 index 0000000..dec0524 --- /dev/null +++ b/src/profile/profileDocuments.ts @@ -0,0 +1,10 @@ +export function preferencesFileDocument(): string { + return [ + '@prefix dct: .', + '@prefix pim: .', + '@prefix solid: .', + '<>', + ' a pim:ConfigurationFile ;', + ' dct:title "Preferences file".' + ].join('\n') +} diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index 2f38a7e..009dfda 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -1,12 +1,196 @@ -import { NamedNode } from 'rdflib' +import { literal, NamedNode, st, sym } from 'rdflib' +import { ACL_LINK } from '../acl/aclLogic' import { CrossOriginForbiddenError, FetchError, NotEditableError, SameOriginForbiddenError, UnauthorizedError, WebOperationError } from '../logic/CustomError' import * as debug from '../util/debug' import { ns as namespace } from '../util/ns' +import { privateTypeIndexDocument, publicTypeIndexDocument } from '../typeIndex/typeIndexDocuments' +import { publicTypeIndexAclDocument } from '../typeIndex/typeIndexAclDocuments' +import { preferencesFileDocument } from './profileDocuments' +import { ownerOnlyContainerAclDocument } from './profileAclDocuments' +import { createContainerLogic } from '../util/containerLogic' import { differentOrigin, suggestPreferencesFile } from '../util/utils' import { ProfileLogic } from '../types' export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { const ns = namespace + const containerLogic = createContainerLogic(store) + const loadPreferencesInFlight = new Map>() + const cachedPreferencesFileByWebId = new Map() + + function isAbsoluteHttpUri(uri: string | null | undefined): boolean { + return !!uri && (uri.startsWith('https://') || uri.startsWith('http://')) + } + + function docDirUri(node: NamedNode): string | null { + const doc = node.doc() + const dir = doc.dir() + if (dir?.uri && isAbsoluteHttpUri(dir.uri)) return dir.uri + const docUri = doc.uri + if (!docUri || !isAbsoluteHttpUri(docUri)) return null + const withoutFragment = docUri.split('#')[0] + const lastSlash = withoutFragment.lastIndexOf('/') + if (lastSlash === -1) return null + return withoutFragment.slice(0, lastSlash + 1) + } + + function suggestTypeIndexInPreferences(preferencesFile: NamedNode, filename: string): NamedNode { + const dirUri = docDirUri(preferencesFile) + if (!dirUri) throw new Error(`Cannot derive directory for preferences file ${preferencesFile.uri}`) + return sym(dirUri + filename) + } + + function isNotFoundError(err: any): boolean { + if (err?.response?.status === 404) return true + const text = `${err?.message || err || ''}` + return text.includes('404') || text.includes('Not Found') + } + + async function ensureContainerExists(containerUri: string): Promise { + const containerNode = sym(containerUri) + try { + await store.fetcher.load(containerNode) + return + } catch (err) { + if (!isNotFoundError(err)) throw err + } + await containerLogic.createContainer(containerUri) + } + + async function ensureOwnerOnlyAclForSettings(user: NamedNode, preferencesFile: NamedNode): Promise { + const dirUri = docDirUri(preferencesFile) + if (!dirUri) throw new Error(`Cannot derive settings directory from ${preferencesFile.uri}`) + await ensureContainerExists(dirUri) + + const containerNode = sym(dirUri) + let aclDocUri: string | undefined + try { + await store.fetcher.load(containerNode) + aclDocUri = store.any(containerNode, ACL_LINK)?.value + } catch (err) { + if (!isNotFoundError(err)) throw err + } + if (!aclDocUri) { + // Fallback for servers/tests where rel=acl is not exposed in mocked headers. + aclDocUri = `${dirUri}.acl` + } + const aclDoc = sym(aclDocUri) + try { + await store.fetcher.load(aclDoc) + return + } catch (err) { + if (!isNotFoundError(err)) throw err + } + + await store.fetcher.webOperation('PUT', aclDoc.uri, { + data: ownerOnlyContainerAclDocument(user.uri), + contentType: 'text/turtle' + }) + } + + async function ensurePublicTypeIndexAclOnCreate(user: NamedNode, publicTypeIndex: NamedNode, ensureAcl = false): Promise { + const created = await utilityLogic.loadOrCreateWithContentOnCreate(publicTypeIndex, publicTypeIndexDocument()) + if (!created && !ensureAcl) return + + let aclDocUri: string | undefined + try { + await store.fetcher.load(publicTypeIndex) + aclDocUri = store.any(publicTypeIndex, ACL_LINK)?.value + } catch (err) { + if (!isNotFoundError(err)) throw err + } + if (!aclDocUri) { + aclDocUri = `${publicTypeIndex.uri}.acl` + } + + const aclDoc = sym(aclDocUri) + try { + await store.fetcher.webOperation('PUT', aclDoc.uri, { + data: publicTypeIndexAclDocument(user.uri, publicTypeIndex.uri), + contentType: 'text/turtle', + headers: { 'If-None-Match': '*' } + }) + } catch (err: any) { + const status = err?.response?.status ?? err?.status + if (status !== 412) throw err + } + } + + async function ensurePrivateTypeIndexOnCreate(privateTypeIndex: NamedNode): Promise { + await utilityLogic.loadOrCreateWithContentOnCreate(privateTypeIndex, privateTypeIndexDocument()) + } + + async function initializePreferencesDefaults(user: NamedNode, preferencesFile: NamedNode): Promise { + const preferencesDoc = preferencesFile.doc() as NamedNode + const profileDoc = user.doc() as NamedNode + await store.fetcher.load(preferencesDoc) + + const profilePublicTypeIndex = + (store.any(user, ns.solid('publicTypeIndex'), null, profileDoc) as NamedNode | null) + const preferencesPublicTypeIndex = + (store.any(user, ns.solid('publicTypeIndex'), null, preferencesDoc) as NamedNode | null) + const publicTypeIndex = + profilePublicTypeIndex || + preferencesPublicTypeIndex || + suggestTypeIndexInPreferences(preferencesFile, 'publicTypeIndex.ttl') + const privateTypeIndex = + (store.any(user, ns.solid('privateTypeIndex'), null, preferencesDoc) as NamedNode | null) || + suggestTypeIndexInPreferences(preferencesFile, 'privateTypeIndex.ttl') + + // Keep discovery consistent with typeIndexLogic, which resolves publicTypeIndex from the profile doc. + const createdProfilePublicTypeIndexLink = !profilePublicTypeIndex + if (createdProfilePublicTypeIndexLink) { + await utilityLogic.followOrCreateLinkWithContentOnCreate( + user, + ns.solid('publicTypeIndex') as NamedNode, + publicTypeIndex, + profileDoc, + publicTypeIndexDocument() + ) + } + + const toInsert: any[] = [] + if (!store.holds(preferencesDoc, ns.rdf('type'), ns.space('ConfigurationFile'), preferencesDoc)) { + toInsert.push(st(preferencesDoc, ns.rdf('type'), ns.space('ConfigurationFile'), preferencesDoc)) + } + if (!store.holds(preferencesDoc, ns.dct('title'), undefined, preferencesDoc)) { + toInsert.push(st(preferencesDoc, ns.dct('title'), literal('Preferences file'), preferencesDoc)) + } + if (!store.holds(user, ns.solid('publicTypeIndex'), publicTypeIndex, preferencesDoc)) { + toInsert.push(st(user, ns.solid('publicTypeIndex'), publicTypeIndex, preferencesDoc)) + } + if (!store.holds(user, ns.solid('privateTypeIndex'), privateTypeIndex, preferencesDoc)) { + toInsert.push(st(user, ns.solid('privateTypeIndex'), privateTypeIndex, preferencesDoc)) + } + + if (toInsert.length > 0) { + await store.updater.update([], toInsert) + await store.fetcher.load(preferencesDoc) + } + + await ensurePublicTypeIndexAclOnCreate(user, publicTypeIndex, createdProfilePublicTypeIndexLink) + await ensurePrivateTypeIndexOnCreate(privateTypeIndex) + } + + async function ensurePreferencesDocExists(preferencesFile: NamedNode): Promise { + try { + const created = await utilityLogic.loadOrCreateWithContentOnCreate(preferencesFile, preferencesFileDocument()) + if (created) { + return true + } + return false + } catch (err) { + if (err.response?.status === 401) { + throw new UnauthorizedError() + } + if (err.response?.status === 403) { + if (differentOrigin(preferencesFile)) { + throw new CrossOriginForbiddenError() + } + throw new SameOriginForbiddenError() + } + throw err + } + } /** * 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 { * @returns undefined if preferenceFile cannot be an Error or NamedNode if it can find it or create it */ async function loadPreferences (user: NamedNode): Promise { + const cachedPreferencesFile = cachedPreferencesFileByWebId.get(user.uri) + if (cachedPreferencesFile) { + return cachedPreferencesFile + } + + const inFlight = loadPreferencesInFlight.get(user.uri) + if (inFlight) { + return inFlight + } + + const run = (async (): Promise => { await loadProfile(user) const possiblePreferencesFile = suggestPreferencesFile(user) let preferencesFile try { - preferencesFile = await utilityLogic.followOrCreateLink(user, ns.space('preferencesFile') as NamedNode, possiblePreferencesFile, user.doc()) + const existingPreferencesFile = store.any(user, ns.space('preferencesFile'), null, user.doc()) as NamedNode | null + if (existingPreferencesFile) { + preferencesFile = existingPreferencesFile + } else { + preferencesFile = await utilityLogic.followOrCreateLink(user, ns.space('preferencesFile') as NamedNode, possiblePreferencesFile, user.doc()) + } + + await ensureOwnerOnlyAclForSettings(user, preferencesFile as NamedNode) + await ensurePreferencesDocExists(preferencesFile as NamedNode) + await initializePreferencesDefaults(user, preferencesFile as NamedNode) } catch (err) { const message = `User ${user} has no pointer in profile to preferences file.` debug.warn(message) @@ -52,6 +256,14 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { await store.fetcher.load(preferencesFile as NamedNode) } catch (err) { // Maybe a permission problem or origin problem const msg = `Unable to load preference of user ${user}: ${err}` + if (err.response?.status === 404) { + // Self-heal when a stale profile pointer references a missing preferences file. + await ensureOwnerOnlyAclForSettings(user, preferencesFile as NamedNode) + await ensurePreferencesDocExists(preferencesFile as NamedNode) + await initializePreferencesDefaults(user, preferencesFile as NamedNode) + await store.fetcher.load(preferencesFile as NamedNode) + return preferencesFile as NamedNode + } debug.warn(msg) if (err.response.status === 401) { throw new UnauthorizedError() @@ -67,7 +279,18 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { }*/ throw new Error(msg) } + cachedPreferencesFileByWebId.set(user.uri, preferencesFile as NamedNode) return preferencesFile as NamedNode + })() + + loadPreferencesInFlight.set(user.uri, run) + try { + return await run + } finally { + if (loadPreferencesInFlight.get(user.uri) === run) { + loadPreferencesInFlight.delete(user.uri) + } + } } async function loadProfile (user: NamedNode):Promise { diff --git a/src/typeIndex/typeIndexAclDocuments.ts b/src/typeIndex/typeIndexAclDocuments.ts new file mode 100644 index 0000000..9ab7ea9 --- /dev/null +++ b/src/typeIndex/typeIndexAclDocuments.ts @@ -0,0 +1,30 @@ +export function publicTypeIndexAclDocument(webId: string, publicTypeIndexUri: string): string { + const fileName = new URL(publicTypeIndexUri).pathname.split('/').pop() || 'publicTypeIndex.ttl' + return [ + '# ACL resource for the Public Type Index', + '', + '@prefix acl: .', + '@prefix foaf: .', + '', + '<#owner>', + ' a acl:Authorization;', + '', + ' acl:agent', + ` <${webId}>;`, + '', + ` acl:accessTo <./${fileName}>;`, + '', + ' acl:mode', + ' acl:Read, acl:Write, acl:Control.', + '', + '# Public-readable', + '<#public>', + ' a acl:Authorization;', + '', + ' acl:agentClass foaf:Agent;', + '', + ` acl:accessTo <./${fileName}>;`, + '', + ' acl:mode acl:Read.' + ].join('\n') +} diff --git a/src/typeIndex/typeIndexDocuments.ts b/src/typeIndex/typeIndexDocuments.ts new file mode 100644 index 0000000..de4112a --- /dev/null +++ b/src/typeIndex/typeIndexDocuments.ts @@ -0,0 +1,17 @@ +export function publicTypeIndexDocument(): string { + return [ + '@prefix solid: .', + '<>', + ' a solid:TypeIndex ;', + ' a solid:ListedDocument.' + ].join('\n') +} + +export function privateTypeIndexDocument(): string { + return [ + '@prefix solid: .', + '<>', + ' a solid:TypeIndex ;', + ' a solid:UnlistedDocument.' + ].join('\n') +} \ No newline at end of file diff --git a/src/typeIndex/typeIndexLogic.ts b/src/typeIndex/typeIndexLogic.ts index 9505f65..b456342 100644 --- a/src/typeIndex/typeIndexLogic.ts +++ b/src/typeIndex/typeIndexLogic.ts @@ -3,6 +3,7 @@ import { ScopedApp, TypeIndexLogic, TypeIndexScope } from '../types' import * as debug from '../util/debug' import { ns as namespace } from '../util/ns' import { newThing } from '../util/utils' +import { privateTypeIndexDocument, publicTypeIndexDocument } from './typeIndexDocuments' export function createTypeIndexLogic(store, authn, profileLogic, utilityLogic): TypeIndexLogic { const ns = namespace @@ -32,11 +33,20 @@ export function createTypeIndexLogic(store, authn, profileLogic, utilityLogic): } let publicTypeIndex try { - publicTypeIndex = - store.any(user, ns.solid('publicTypeIndex'), undefined, profile) || - (suggestion - ? await utilityLogic.followOrCreateLink(user, ns.solid('publicTypeIndex') as NamedNode, suggestion, profile) - : null) + const existingPublicTypeIndex = store.any(user, ns.solid('publicTypeIndex'), undefined, profile) + if (existingPublicTypeIndex) { + publicTypeIndex = existingPublicTypeIndex + } else if (suggestion) { + publicTypeIndex = await utilityLogic.followOrCreateLinkWithContentOnCreate( + user, + ns.solid('publicTypeIndex') as NamedNode, + suggestion, + profile, + publicTypeIndexDocument() + ) + } else { + publicTypeIndex = null + } } catch (err) { const message = `User ${user} has no pointer in profile to publicTypeIndex file: ${err}` debug.warn(message) @@ -63,11 +73,21 @@ export function createTypeIndexLogic(store, authn, profileLogic, utilityLogic): } let privateTypeIndex try { - privateTypeIndex = store.any(user, ns.solid('privateTypeIndex'), undefined, profile) || - (suggestedPrivateTypeIndex - ? await utilityLogic.followOrCreateLink(user, ns.solid('privateTypeIndex') as NamedNode, suggestedPrivateTypeIndex, preferencesFile) - : null) - } catch (err) { + const existingPrivateTypeIndex = store.any(user, ns.solid('privateTypeIndex'), undefined, profile) + if (existingPrivateTypeIndex) { + privateTypeIndex = existingPrivateTypeIndex + } else if (suggestedPrivateTypeIndex) { + privateTypeIndex = await utilityLogic.followOrCreateLinkWithContentOnCreate( + user, + ns.solid('privateTypeIndex') as NamedNode, + suggestedPrivateTypeIndex, + preferencesFile, + privateTypeIndexDocument() + ) + } else { + privateTypeIndex = null + } + } catch (err) { const message = `User ${user} has no pointer in preference file to privateTypeIndex file: ${err}` debug.warn(message) } diff --git a/src/util/containerLogic.ts b/src/util/containerLogic.ts index 00ce90b..33cafb5 100644 --- a/src/util/containerLogic.ts +++ b/src/util/containerLogic.ts @@ -35,6 +35,10 @@ export function createContainerLogic(store) { }, body: ' ', // work around https://github.com/michielbdejong/community-server/issues/4#issuecomment-776222863 }) + // Treat 409 as idempotent success: another process/request already created the container. + if (result.status === 409) { + return + } if (result.status.toString()[0] !== '2') { throw new Error(`Not OK: got ${result.status} response while creating container at ${url}`) } diff --git a/src/util/utilityLogic.ts b/src/util/utilityLogic.ts index d82c63f..6ac02e1 100644 --- a/src/util/utilityLogic.ts +++ b/src/util/utilityLogic.ts @@ -1,69 +1,106 @@ import { NamedNode, st, sym } from 'rdflib' -import { CrossOriginForbiddenError, FetchError, NotEditableError, SameOriginForbiddenError, UnauthorizedError, WebOperationError } from '../logic/CustomError' +import { + CrossOriginForbiddenError, + FetchError, + NotEditableError, + SameOriginForbiddenError, + UnauthorizedError, + WebOperationError +} from '../logic/CustomError' import * as debug from '../util/debug' import { differentOrigin } from './utils' export function createUtilityLogic(store, aclLogic, containerLogic) { - async function recursiveDelete(containerNode: NamedNode) { - try { - if (containerLogic.isContainer(containerNode)) { - const aclDocUrl = await aclLogic.findAclDocUrl(containerNode) - await store.fetcher._fetch(aclDocUrl, { method: 'DELETE' }) - const containerMembers = await containerLogic.getContainerMembers(containerNode) - await Promise.all( - containerMembers.map((url) => recursiveDelete(url)) - ) - } - const nodeToStringHere = containerNode.value - return store.fetcher._fetch(nodeToStringHere, { method: 'DELETE' }) - } catch (e) { - debug.log(`Please manually remove ${containerNode.value} from your system.`, e) + try { + if (containerLogic.isContainer(containerNode)) { + const aclDocUrl = await aclLogic.findAclDocUrl(containerNode) + await store.fetcher._fetch(aclDocUrl, { method: 'DELETE' }) + const containerMembers = await containerLogic.getContainerMembers(containerNode) + await Promise.all(containerMembers.map((url) => recursiveDelete(url))) } + return store.fetcher._fetch(containerNode.value, { method: 'DELETE' }) + } catch (e) { + debug.log(`Please manually remove ${containerNode.value} from your system.`, e) + } } /** - * Create a resource if it really does not exist - * Be absolutely sure something does not exist before creating a new empty file - * as otherwise existing could be deleted. - * @param doc {NamedNode} - The resource + * Create a resource if it really does not exist. + * Be absolutely sure something does not exist before creating a new empty file, + * as otherwise existing content could be deleted. */ async function loadOrCreateIfNotExists(doc: NamedNode) { - let response try { - response = await store.fetcher.load(doc) - } catch (err) { - if (err.response.status === 404) { + return await store.fetcher.load(doc) + } catch (err: any) { + if (err?.response?.status === 404) { try { - await store.fetcher.webOperation('PUT', doc, { data: '', contentType: 'text/turtle' }) - } catch (err) { - const msg = 'createIfNotExists: PUT FAILED: ' + doc + ': ' + err - throw new WebOperationError(msg) - } - await store.fetcher.load(doc) - } else { - if (err.response.status === 401) { - throw new UnauthorizedError() - } - if (err.response.status === 403) { - if (differentOrigin(doc)) { - throw new CrossOriginForbiddenError() + await store.fetcher.webOperation('PUT', doc.uri, { + data: '', + contentType: 'text/turtle', + headers: { 'If-None-Match': '*' } + }) + } catch (putErr: any) { + const status = putErr?.response?.status ?? putErr?.status + if (status !== 412) { + const msg = `createIfNotExists: PUT FAILED: ${doc}: ${putErr}` + throw new WebOperationError(msg) } - throw new SameOriginForbiddenError() } - const msg = 'createIfNotExists doc load error NOT 404: ' + doc + ': ' + err - throw new FetchError(err.status, err.message + msg) + return await store.fetcher.load(doc) } + if (err?.response?.status === 401) { + throw new UnauthorizedError() + } + if (err?.response?.status === 403) { + if (differentOrigin(doc)) { + throw new CrossOriginForbiddenError() + } + throw new SameOriginForbiddenError() + } + const msg = `createIfNotExists doc load error: ${doc}: ${err}` + throw new FetchError(err?.status, `${err?.message || ''}${msg}`) } - return response } - /* Follow link from this doc to another thing, or else make a new link - ** - ** @returns existing object, or creates it if non existent - */ - async function followOrCreateLink(subject: NamedNode, predicate: NamedNode, - object: NamedNode, doc: NamedNode + async function loadOrCreateWithContentOnCreate(doc: NamedNode, data: string): Promise { + try { + await store.fetcher.load(doc) + return false + } catch (err: any) { + const status = err?.response?.status ?? err?.status + if (status !== 404) { + throw err + } + } + + try { + await store.fetcher.webOperation('PUT', doc.uri, { + data, + contentType: 'text/turtle', + headers: { 'If-None-Match': '*' } + }) + return true + } catch (err: any) { + const status = err?.response?.status ?? err?.status + if (status === 412) { + // Another client created the resource between our 404 check and PUT. + return false + } + throw err + } + } + + /* + * Follow link from this doc to another thing, or else make a new link. + * Returns existing object, or creates it if non-existent. + */ + async function followOrCreateLink( + subject: NamedNode, + predicate: NamedNode, + object: NamedNode, + doc: NamedNode ): Promise { await store.fetcher.load(doc) const result = store.any(subject, predicate, null, doc) @@ -74,6 +111,7 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { debug.warn(msg) throw new NotEditableError(msg) } + try { await store.updater.update([], [st(subject, predicate, object, doc)]) } catch (err) { @@ -84,7 +122,6 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { try { await loadOrCreateIfNotExists(object) - // store.fetcher.webOperation('PUT', object, { data: '', contentType: 'text/turtle'}) } catch (err) { debug.warn(`followOrCreateLink: Error loading or saving new linked document: ${object}: ${err}`) throw err @@ -92,7 +129,42 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { return object } - // Copied from https://github.com/solidos/web-access-control-tests/blob/v3.0.0/test/surface/delete.test.ts#L5 + async function followOrCreateLinkWithContentOnCreate( + subject: NamedNode, + predicate: NamedNode, + object: NamedNode, + doc: NamedNode, + data: string + ): Promise { + await store.fetcher.load(doc) + const result = store.any(subject, predicate, null, doc) + + if (result) return result as NamedNode + if (!store.updater.editable(doc)) { + const msg = `followOrCreateLinkWithContentOnCreate: cannot edit ${doc.value}` + debug.warn(msg) + throw new NotEditableError(msg) + } + + try { + await store.updater.update([], [st(subject, predicate, object, doc)]) + } catch (err) { + const msg = `followOrCreateLinkWithContentOnCreate: Error making link in ${doc} to ${object}: ${err}` + debug.warn(msg) + throw new WebOperationError(msg) + } + + try { + await loadOrCreateWithContentOnCreate(object, data) + } catch (err) { + debug.warn(`followOrCreateLinkWithContentOnCreate: Error loading or saving new linked document: ${object}: ${err}`) + throw err + } + return object + } + + // Copied from + // https://github.com/solidos/web-access-control-tests/blob/v3.0.0/test/surface/delete.test.ts#L5 async function setSinglePeerAccess(options: { ownerWebId: string, peerWebId: string, @@ -103,12 +175,13 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { let str = [ '@prefix acl: .', '', - `<#alice> a acl:Authorization;\n acl:agent <${options.ownerWebId}>;`, + '<#alice> a acl:Authorization;\n acl:agent <' + options.ownerWebId + '>;', ` acl:accessTo <${options.target}>;`, ` acl:default <${options.target}>;`, ' acl:mode acl:Read, acl:Write, acl:Control.', '' ].join('\n') + if (options.accessToModes) { str += [ '<#bobAccessTo> a acl:Authorization;', @@ -118,6 +191,7 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { '' ].join('\n') } + if (options.defaultModes) { str += [ '<#bobDefault> a acl:Authorization;', @@ -127,30 +201,29 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { '' ].join('\n') } + const aclDocUrl = await aclLogic.findAclDocUrl(sym(options.target)) return store.fetcher._fetch(aclDocUrl, { method: 'PUT', body: str, - headers: [ - ['Content-Type', 'text/turtle'] - ] + headers: [['Content-Type', 'text/turtle']] }) } async function createEmptyRdfDoc(doc: NamedNode, comment: string) { await store.fetcher.webOperation('PUT', doc.uri, { - data: `# ${new Date()} ${comment} - `, - contentType: 'text/turtle', + data: `# ${new Date()} ${comment}\n`, + contentType: 'text/turtle' }) } - + return { recursiveDelete, setSinglePeerAccess, createEmptyRdfDoc, followOrCreateLink, - loadOrCreateIfNotExists + followOrCreateLinkWithContentOnCreate, + loadOrCreateIfNotExists, + loadOrCreateWithContentOnCreate } } - diff --git a/src/util/utils.ts b/src/util/utils.ts index c1ccd99..5eab07e 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -34,7 +34,7 @@ export function suggestPreferencesFile (me:NamedNode) { const stripped = me.uri.replace('/profile/', '/').replace('/public/', '/') // const stripped = me.uri.replace(\/[p|P]rofile/\g, '/').replace(\/[p|P]ublic/\g, '/') const folderURI = stripped.split('/').slice(0,-1).join('/') + '/Settings/' - const fileURI = folderURI + 'Preferences.ttl' + const fileURI = folderURI + 'prefs.ttl' return sym(fileURI) } diff --git a/test/profileLogic.test.ts b/test/profileLogic.test.ts index 970fd78..f734c77 100644 --- a/test/profileLogic.test.ts +++ b/test/profileLogic.test.ts @@ -12,20 +12,28 @@ import { import { createAclLogic } from '../src/acl/aclLogic' import { createContainerLogic } from '../src/util/containerLogic' +declare const fetchMock: any + +declare global { + interface Window { + $SolidTestEnvironment?: { username: string } + } +} + const prefixes = Object.keys(ns).map(prefix => `@prefix ${prefix}: ${ns[prefix]('')}.\n`).join('') // In turtle const user = alice const profile = user.doc() let requests: Request[] = [] -let profileLogic +let profileLogic: ReturnType describe('Profile', () => { describe('loadProfile', () => { window.$SolidTestEnvironment = { username: alice.uri } - let store + let store: Store requests = [] const statustoBeReturned = 200 - let web = {} + let web: Record = {} const authn = { currentUser: () => { return alice @@ -35,7 +43,7 @@ describe('Profile', () => { fetchMock.resetMocks() web = loadWebObject() requests = [] - fetchMock.mockIf(/^https?.*$/, async req => { + fetchMock.mockIf(/^https?.*$/, async (req: Request) => { if (req.method !== 'GET') { requests.push(req) @@ -85,10 +93,10 @@ describe('Profile', () => { describe('silencedLoadPreferences', () => { window.$SolidTestEnvironment = { username: alice.uri } - let store + let store: Store requests = [] const statustoBeReturned = 200 - let web = {} + let web: Record = {} const authn = { currentUser: () => { return alice @@ -98,7 +106,7 @@ describe('Profile', () => { fetchMock.resetMocks() web = loadWebObject() requests = [] - fetchMock.mockIf(/^https?.*$/, async req => { + fetchMock.mockIf(/^https?.*$/, async (req: Request) => { if (req.method !== 'GET') { requests.push(req) @@ -139,26 +147,87 @@ describe('Profile', () => { it('loads data', async () => { const result = await profileLogic.silencedLoadPreferences(alice) expect(result).toBeInstanceOf(Object) + if (!result) { + throw new Error('Expected preferences document for alice') + } expect(result.uri).toEqual(AlicePreferencesFile.uri) expect(store.holds(user, ns.rdf('type'), ns.vcard('Individual'), profile)).toEqual(true) expect(store.statementsMatching(null, null, null, profile).length).toEqual(4) - expect(store.statementsMatching(null, null, null, AlicePreferencesFile).length).toEqual(2) + expect(store.statementsMatching(null, null, null, AlicePreferencesFile).length).toBeGreaterThanOrEqual(2) expect(store.holds(user, ns.solid('privateTypeIndex'), AlicePrivateTypeIndex, AlicePreferencesFile)).toEqual(true) }) it('creates new file', async () => { await profileLogic.silencedLoadPreferences(bob) - const patchRequest = requests[0] - expect(patchRequest.method).toEqual('PATCH') - expect(patchRequest.url).toEqual(bob.doc().uri) - const text = await patchRequest.text() - expect(text).toContain('INSERT DATA { .') + const profilePatch = requests.find(req => req.method === 'PATCH' && req.url === bob.doc().uri) + expect(profilePatch).toBeDefined() + if (!profilePatch) { + throw new Error('Expected profile patch request for bob') + } + const profilePatchText = await profilePatch.text() + expect(profilePatchText).toContain('INSERT DATA { .') + + const preferencesPatch = requests.find(req => req.method === 'PATCH' && req.url === 'https://bob.example.com/Settings/prefs.ttl') + expect(preferencesPatch).toBeDefined() + if (!preferencesPatch) { + throw new Error('Expected preferences patch request for bob') + } + const preferencesPatchText = await preferencesPatch.text() + expect(preferencesPatchText).toContain(' .') + expect(preferencesPatchText).toContain(' "Preferences file" .') + expect(preferencesPatchText).toContain(' ') + expect(preferencesPatchText).toContain(' ') + + const putUrls = requests.filter(req => req.method === 'PUT').map(req => req.url) + expect(putUrls).toContain('https://bob.example.com/Settings/') + expect(putUrls).toContain('https://bob.example.com/Settings/.acl') + expect(putUrls).toContain('https://bob.example.com/Settings/prefs.ttl') + expect(putUrls).toContain('https://bob.example.com/Settings/publicTypeIndex.ttl') + expect(putUrls).toContain('https://bob.example.com/Settings/publicTypeIndex.ttl.acl') + expect(putUrls).toContain('https://bob.example.com/Settings/privateTypeIndex.ttl') + + const publicTypeIndexBody = web['https://bob.example.com/Settings/publicTypeIndex.ttl'] + expect(publicTypeIndexBody).toBeDefined() + expect(publicTypeIndexBody).toContain('@prefix solid: .') + expect(publicTypeIndexBody).toContain('<>') + expect(publicTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(publicTypeIndexBody).toContain('a solid:ListedDocument.') + + const privateTypeIndexBody = web['https://bob.example.com/Settings/privateTypeIndex.ttl'] + expect(privateTypeIndexBody).toBeDefined() + expect(privateTypeIndexBody).toContain('@prefix solid: .') + expect(privateTypeIndexBody).toContain('<>') + expect(privateTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(privateTypeIndexBody).toContain('a solid:UnlistedDocument.') - const putRequest = requests[1] - expect(putRequest.method).toEqual('PUT') - expect(putRequest.url).toEqual('https://bob.example.com/Settings/Preferences.ttl') - expect(web[putRequest.url]).toEqual('') + const settingsAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://bob.example.com/Settings/.acl') + expect(settingsAclPut).toBeDefined() + const settingsAclBody = web['https://bob.example.com/Settings/.acl'] + expect(settingsAclBody).toBeDefined() + expect(settingsAclBody).toContain('@prefix acl: .') + expect(settingsAclBody).toContain('<#owner>') + expect(settingsAclBody).toContain('acl:agent ;') + expect(settingsAclBody).toContain('acl:accessTo <./>;') + expect(settingsAclBody).toContain('acl:default <./>;') + expect(settingsAclBody).toContain('acl:mode acl:Read, acl:Write, acl:Control.') + + const publicTypeIndexAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://bob.example.com/Settings/publicTypeIndex.ttl.acl') + expect(publicTypeIndexAclPut).toBeDefined() + const publicTypeIndexAclBody = web['https://bob.example.com/Settings/publicTypeIndex.ttl.acl'] + expect(publicTypeIndexAclBody).toBeDefined() + expect(publicTypeIndexAclBody).not.toEqual('') + expect(publicTypeIndexAclBody).toContain('@prefix acl: .') + expect(publicTypeIndexAclBody).toContain('@prefix foaf: .') + expect(publicTypeIndexAclBody).toContain('<#owner>') + expect(publicTypeIndexAclBody).toContain('acl:agent') + expect(publicTypeIndexAclBody).toContain(';') + expect(publicTypeIndexAclBody).toContain('acl:accessTo <./publicTypeIndex.ttl>;') + expect(publicTypeIndexAclBody).toContain('acl:mode') + expect(publicTypeIndexAclBody).toContain('acl:Read, acl:Write, acl:Control.') + expect(publicTypeIndexAclBody).toContain('<#public>') + expect(publicTypeIndexAclBody).toContain('acl:agentClass foaf:Agent;') + expect(publicTypeIndexAclBody).toContain('acl:mode acl:Read.') }) }) @@ -166,10 +235,10 @@ describe('Profile', () => { describe('loadPreferences', () => { window.$SolidTestEnvironment = { username: boby.uri } - let store + let store: Store requests = [] const statustoBeReturned = 200 - let web = {} + let web: Record = {} const authn = { currentUser: () => { return boby @@ -179,7 +248,7 @@ describe('Profile', () => { fetchMock.resetMocks() web = loadWebObject() requests = [] - fetchMock.mockIf(/^https?.*$/, async req => { + fetchMock.mockIf(/^https?.*$/, async (req: Request) => { if (req.method !== 'GET') { requests.push(req) @@ -224,22 +293,80 @@ describe('Profile', () => { expect(store.holds(user, ns.rdf('type'), ns.vcard('Individual'), profile)).toEqual(true) expect(store.statementsMatching(null, null, null, profile).length).toEqual(4) - expect(store.statementsMatching(null, null, null, AlicePreferencesFile).length).toEqual(2) + expect(store.statementsMatching(null, null, null, AlicePreferencesFile).length).toBeGreaterThanOrEqual(2) expect(store.holds(user, ns.solid('privateTypeIndex'), AlicePrivateTypeIndex, AlicePreferencesFile)).toEqual(true) }) it('creates new file', async () => { await profileLogic.loadPreferences(boby) - const patchRequest = requests[0] - expect(patchRequest.method).toEqual('PATCH') - expect(patchRequest.url).toEqual(boby.doc().uri) - const text = await patchRequest.text() - expect(text).toContain('INSERT DATA { .') + const profilePatch = requests.find(req => req.method === 'PATCH' && req.url === boby.doc().uri) + expect(profilePatch).toBeDefined() + if (!profilePatch) { + throw new Error('Expected profile patch request for boby') + } + const profilePatchText = await profilePatch.text() + expect(profilePatchText).toContain('INSERT DATA { .') + + const preferencesPatch = requests.find(req => req.method === 'PATCH' && req.url === 'https://boby.example.com/Settings/prefs.ttl') + expect(preferencesPatch).toBeDefined() + if (!preferencesPatch) { + throw new Error('Expected preferences patch request for boby') + } + const preferencesPatchText = await preferencesPatch.text() + expect(preferencesPatchText).toContain(' .') + expect(preferencesPatchText).toContain(' "Preferences file" .') + expect(preferencesPatchText).toContain(' ') + expect(preferencesPatchText).toContain(' ') + + const putUrls = requests.filter(req => req.method === 'PUT').map(req => req.url) + expect(putUrls).toContain('https://boby.example.com/Settings/') + expect(putUrls).toContain('https://boby.example.com/Settings/.acl') + expect(putUrls).toContain('https://boby.example.com/Settings/prefs.ttl') + expect(putUrls).toContain('https://boby.example.com/Settings/publicTypeIndex.ttl') + expect(putUrls).toContain('https://boby.example.com/Settings/publicTypeIndex.ttl.acl') + expect(putUrls).toContain('https://boby.example.com/Settings/privateTypeIndex.ttl') + + const publicTypeIndexBody = web['https://boby.example.com/Settings/publicTypeIndex.ttl'] + expect(publicTypeIndexBody).toBeDefined() + expect(publicTypeIndexBody).toContain('@prefix solid: .') + expect(publicTypeIndexBody).toContain('<>') + expect(publicTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(publicTypeIndexBody).toContain('a solid:ListedDocument.') + + const privateTypeIndexBody = web['https://boby.example.com/Settings/privateTypeIndex.ttl'] + expect(privateTypeIndexBody).toBeDefined() + expect(privateTypeIndexBody).toContain('@prefix solid: .') + expect(privateTypeIndexBody).toContain('<>') + expect(privateTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(privateTypeIndexBody).toContain('a solid:UnlistedDocument.') + + const settingsAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://boby.example.com/Settings/.acl') + expect(settingsAclPut).toBeDefined() + const settingsAclBody = web['https://boby.example.com/Settings/.acl'] + expect(settingsAclBody).toBeDefined() + expect(settingsAclBody).toContain('@prefix acl: .') + expect(settingsAclBody).toContain('<#owner>') + expect(settingsAclBody).toContain('acl:agent ;') + expect(settingsAclBody).toContain('acl:accessTo <./>;') + expect(settingsAclBody).toContain('acl:default <./>;') + expect(settingsAclBody).toContain('acl:mode acl:Read, acl:Write, acl:Control.') - const putRequest = requests[1] - expect(putRequest.method).toEqual('PUT') - expect(putRequest.url).toEqual('https://boby.example.com/Settings/Preferences.ttl') - expect(web[putRequest.url]).toEqual('') + const publicTypeIndexAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://boby.example.com/Settings/publicTypeIndex.ttl.acl') + expect(publicTypeIndexAclPut).toBeDefined() + const publicTypeIndexAclBody = web['https://boby.example.com/Settings/publicTypeIndex.ttl.acl'] + expect(publicTypeIndexAclBody).toBeDefined() + expect(publicTypeIndexAclBody).not.toEqual('') + expect(publicTypeIndexAclBody).toContain('@prefix acl: .') + expect(publicTypeIndexAclBody).toContain('@prefix foaf: .') + expect(publicTypeIndexAclBody).toContain('<#owner>') + expect(publicTypeIndexAclBody).toContain('acl:agent') + expect(publicTypeIndexAclBody).toContain(';') + expect(publicTypeIndexAclBody).toContain('acl:accessTo <./publicTypeIndex.ttl>;') + expect(publicTypeIndexAclBody).toContain('acl:mode') + expect(publicTypeIndexAclBody).toContain('acl:Read, acl:Write, acl:Control.') + expect(publicTypeIndexAclBody).toContain('<#public>') + expect(publicTypeIndexAclBody).toContain('acl:agentClass foaf:Agent;') + expect(publicTypeIndexAclBody).toContain('acl:mode acl:Read.') }) }) diff --git a/test/typeIndexLogic.test.ts b/test/typeIndexLogic.test.ts index 529deb2..41e7da0 100644 --- a/test/typeIndexLogic.test.ts +++ b/test/typeIndexLogic.test.ts @@ -22,7 +22,7 @@ const Image = ns.schema('Image') //web = loadWebObject() const user = alice const profile = user.doc() -const web = {} +const web: Record = {} web[profile.uri] = AliceProfile web[AlicePreferencesFile.uri] = AlicePreferences web[AlicePrivateTypeIndex.uri] = AlicePrivateTypes @@ -36,10 +36,10 @@ web[ClubPrivateTypeIndex.uri] = ClubPrivateTypes web[ClubPublicTypeIndex.uri] = ClubPublicTypes let requests: Request[] = [] let statustoBeReturned = 200 -let typeIndexLogic +let typeIndexLogic: ReturnType describe('TypeIndex logic NEW', () => { - let store + let store: Store const authn = { currentUser: () => { return alice @@ -51,7 +51,7 @@ describe('TypeIndex logic NEW', () => { requests = [] statustoBeReturned = 200 - fetchMock.mockIf(/^https?.*$/, async req => { + fetchMock.mockIf(/^https?.*$/, async (req: Request) => { if (req.method !== 'GET') { requests.push(req) @@ -206,25 +206,33 @@ describe('TypeIndex logic NEW', () => { it('creates new preferenceFile and typeIndex files where they dont exist', async () => { await typeIndexLogic.getScopedAppInstances(Tracker, bob) - expect(requests[0].method).toEqual('PATCH') // Add preferrencesFile link to profile - expect(requests[0].url).toEqual('https://bob.example.com/profile/card.ttl') + const byUrlAndMethod = (url: string, method: string) => + requests.some(req => req.url === url && req.method === method) - expect(requests[1].method).toEqual('PUT') // create publiTypeIndex - expect(requests[1].url).toEqual('https://bob.example.com/profile/publicTypeIndex.ttl') + // Existing behavior that must remain true + expect(byUrlAndMethod('https://bob.example.com/profile/card.ttl', 'PATCH')).toEqual(true) + expect(byUrlAndMethod('https://bob.example.com/profile/publicTypeIndex.ttl', 'PUT')).toEqual(true) + expect(byUrlAndMethod('https://bob.example.com/Settings/prefs.ttl', 'PUT')).toEqual(true) + expect(byUrlAndMethod('https://bob.example.com/Settings/prefs.ttl', 'PATCH')).toEqual(true) + expect(byUrlAndMethod('https://bob.example.com/Settings/privateTypeIndex.ttl', 'PUT')).toEqual(true) - expect(requests[2].method).toEqual('PATCH') // Add link of publiTypeIndex to profile - expect(requests[2].url).toEqual('https://bob.example.com/profile/card.ttl') + const createdPublicTypeIndexBody = web['https://bob.example.com/profile/publicTypeIndex.ttl'] + expect(createdPublicTypeIndexBody).toBeDefined() + expect(createdPublicTypeIndexBody).toContain('@prefix solid: .') + expect(createdPublicTypeIndexBody).toContain('<>') + expect(createdPublicTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(createdPublicTypeIndexBody).toContain('a solid:ListedDocument.') - expect(requests[3].method).toEqual('PUT') // create preferenceFile - expect(requests[3].url).toEqual('https://bob.example.com/Settings/Preferences.ttl') + const createdPrivateTypeIndexBody = web['https://bob.example.com/Settings/privateTypeIndex.ttl'] + expect(createdPrivateTypeIndexBody).toBeDefined() + expect(createdPrivateTypeIndexBody).toContain('@prefix solid: .') + expect(createdPrivateTypeIndexBody).toContain('<>') + expect(createdPrivateTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(createdPrivateTypeIndexBody).toContain('a solid:UnlistedDocument.') - expect(requests[4].method).toEqual('PATCH') // Add privateTypeIndex link preference file - expect(requests[4].url).toEqual('https://bob.example.com/Settings/Preferences.ttl') - - expect(requests[5].method).toEqual('PUT') //create privatTypeIndex - expect(requests[5].url).toEqual('https://bob.example.com/Settings/privateTypeIndex.ttl') - - expect(requests.length).toEqual(6) + // New ACL/setup behavior + expect(byUrlAndMethod('https://bob.example.com/Settings/', 'PUT')).toEqual(true) + expect(byUrlAndMethod('https://bob.example.com/Settings/.acl', 'PUT')).toEqual(true) }) }) diff --git a/test/utilityLogic.test.ts b/test/utilityLogic.test.ts index 2d51589..747094f 100644 --- a/test/utilityLogic.test.ts +++ b/test/utilityLogic.test.ts @@ -95,6 +95,33 @@ describe('utilityLogic', () => { }) }) + describe('loadOrCreateWithContentOnCreate', () => { + it('exists', () => { + expect(utilityLogic.loadOrCreateWithContentOnCreate).toBeInstanceOf(Function) + }) + it('creates and seeds content when missing', async () => { + const suggestion = 'https://bob.example.com/settings/new-index.ttl' + const body = [ + '@prefix solid: .', + '<>', + ' a solid:TypeIndex ;', + ' a solid:ListedDocument.' + ].join('\n') + const created = await utilityLogic.loadOrCreateWithContentOnCreate(sym(suggestion), body) + + expect(created).toEqual(true) + expect(web[suggestion]).toEqual(body) + }) + it('does not overwrite existing content', async () => { + const existing = AlicePrivateTypeIndex.uri + const before = web[existing] + const created = await utilityLogic.loadOrCreateWithContentOnCreate(sym(existing), 'NEW') + + expect(created).toEqual(false) + expect(web[existing]).toEqual(before) + }) + }) + describe('followOrCreateLink', () => { it('exists', () => { expect(utilityLogic.followOrCreateLink).toBeInstanceOf(Function) @@ -125,6 +152,35 @@ describe('utilityLogic', () => { }) }) + + describe('followOrCreateLinkWithContentOnCreate', () => { + it('exists', () => { + expect(utilityLogic.followOrCreateLinkWithContentOnCreate).toBeInstanceOf(Function) + }) + it('does not create target doc when link patch fails', async () => { + const suggestion = 'https://bob.example.com/settings/prefsSuggestion.ttl' + const body = [ + '@prefix solid: .', + '<>', + ' a solid:TypeIndex ;', + ' a solid:ListedDocument.' + ].join('\n') + + statustoBeReturned = 403 // Make PATCH fail + await expect( + utilityLogic.followOrCreateLinkWithContentOnCreate( + bob, + ns.space('preferencesFile'), + sym(suggestion), + bob.doc(), + body + ) + ).rejects.toThrow(WebOperationError) + + expect(web[suggestion]).toBeUndefined() + }) + }) + describe('setSinglePeerAccess', () => { beforeEach(() => { fetchMock.mockOnceIf(