diff --git a/config/identity/interaction/routing/pod/resource.json b/config/identity/interaction/routing/pod/resource.json index 14efe3e59..942a2dd73 100644 --- a/config/identity/interaction/routing/pod/resource.json +++ b/config/identity/interaction/routing/pod/resource.json @@ -2,10 +2,55 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", "@graph": [ { - "comment": "This route is only used when creating new pod resources as no further interactions are supported.", - "@id": "urn:solid-server:default:AccountPodIdRoute", - "@type": "BasePodIdRoute", - "base": { "@id": "urn:solid-server:default:AccountPodRoute" } + "comment": "Handles updating pod settings.", + "@id": "urn:solid-server:default:AccountPodIdRouter", + "@type": "AuthorizedRouteHandler", + "route": { + "@id": "urn:solid-server:default:AccountPodIdRoute", + "@type": "BasePodIdRoute", + "base": { "@id": "urn:solid-server:default:AccountPodRoute" } + }, + "source":{ + "@type": "ViewInteractionHandler", + "source": { + "@id": "urn:solid-server:default:PodResourceHandler", + "@type": "UpdateOwnerHandler", + "podStore": { "@id": "urn:solid-server:default:PodStore" }, + "podRoute": { "@id": "urn:solid-server:default:AccountPodIdRoute" } + } + } + }, + + + { + "@id": "urn:solid-server:default:MetadataWriter", + "@type": "ParallelHandler", + "handlers": [ + { + "comment": "Adds owner link headers to responses.", + "@id": "urn:solid-server:default:MetadataWriter_Owner", + "@type": "OwnerMetadataWriter", + "podStore": { "@id": "urn:solid-server:default:PodStore" }, + "storageStrategy": { "@id": "urn:solid-server:default:StorageLocationStrategy" } + } + ] + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:AccountPodIdRouter" }] + }, + + { + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templates": [{ + "@id": "urn:solid-server:default:UpdatePodHtml", + "@type": "HtmlViewEntry", + "filePath": "@css:templates/identity/account/pod-settings.html.ejs", + "route": { "@id": "urn:solid-server:default:AccountPodIdRoute" } + }] } ] } diff --git a/config/ldp/authorization/readers/ownership.json b/config/ldp/authorization/readers/ownership.json index bf4d14dd2..2a9c114a7 100644 --- a/config/ldp/authorization/readers/ownership.json +++ b/config/ldp/authorization/readers/ownership.json @@ -6,7 +6,6 @@ "@id": "urn:solid-server:default:OwnerPermissionReader", "@type": "OwnerPermissionReader", "podStore": { "@id": "urn:solid-server:default:PodStore" }, - "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" }, "storageStrategy": { "@id": "urn:solid-server:default:StorageLocationStrategy" } } ] diff --git a/documentation/markdown/usage/account/json-api.md b/documentation/markdown/usage/account/json-api.md index 39f79d676..2ba4a657f 100644 --- a/documentation/markdown/usage/account/json-api.md +++ b/documentation/markdown/usage/account/json-api.md @@ -122,7 +122,26 @@ GET requests return all pods created by this account in the following format: } ``` -Creates a Solid pod for the account on POST requests. +The URL value is the resource URL corresponding to the link with this WebID. +Doing a GET request to this resource will return the base URl of the pod, and all its owners of a pod, as shown below. +You can send a POST request to this resource with a `webId` and `visible: boolean` field +to add/update an owner and set its visibility. +Visibility determines whether the owner is exposed through a link header when requesting the pod. +You can also send a POST request to this resource with a `webId` and `remove: true` field to remove the owner. + +```json +{ + "baseUrl": "http://localhost:3000/my-pod/", + "owners": [ + { + "webId": "http://localhost:3000/my-pod/profile/card#me", + "visible": false + } + ] +} +``` + +POST requests to `controls.account.pod` create a Solid pod for the account. The only required field is `name`, which will determine the name of the pod. Additionally, a `settings` object can be sent along, diff --git a/src/authorization/OwnerPermissionReader.ts b/src/authorization/OwnerPermissionReader.ts index 1a2c88fc2..5d0b1d42c 100644 --- a/src/authorization/OwnerPermissionReader.ts +++ b/src/authorization/OwnerPermissionReader.ts @@ -1,7 +1,6 @@ import type { AuxiliaryIdentifierStrategy } from '../http/auxiliary/AuxiliaryIdentifierStrategy'; import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; import type { PodStore } from '../identity/interaction/pod/util/PodStore'; -import type { WebIdStore } from '../identity/interaction/webid/util/WebIdStore'; import { getLoggerFor } from '../logging/LogUtil'; import type { StorageLocationStrategy } from '../server/description/StorageLocationStrategy'; import { filter } from '../util/IterableUtil'; @@ -12,21 +11,19 @@ import type { AclPermissionSet } from './permissions/AclPermissionSet'; import type { PermissionMap } from './permissions/Permissions'; /** - * Allows control access if the request is being made by the owner of the pod containing the resource. + * Allows control access if the request is being made by an owner of the pod containing the resource. */ export class OwnerPermissionReader extends PermissionReader { protected readonly logger = getLoggerFor(this); private readonly podStore: PodStore; - private readonly webIdStore: WebIdStore; private readonly authStrategy: AuxiliaryIdentifierStrategy; private readonly storageStrategy: StorageLocationStrategy; - public constructor(podStore: PodStore, webIdStore: WebIdStore, authStrategy: AuxiliaryIdentifierStrategy, + public constructor(podStore: PodStore, authStrategy: AuxiliaryIdentifierStrategy, storageStrategy: StorageLocationStrategy) { super(); this.podStore = podStore; - this.webIdStore = webIdStore; this.authStrategy = authStrategy; this.storageStrategy = storageStrategy; } @@ -95,14 +92,19 @@ export class OwnerPermissionReader extends PermissionReader { protected async findOwners(pods: string[]): Promise> { const owners: Record = {}; // Set to only have the unique values - for (const pod of new Set(pods)) { - const owner = await this.podStore.findAccount(pod); - if (!owner) { - this.logger.error(`Unable to find owner for ${pod}`); + for (const baseUrl of new Set(pods)) { + const pod = await this.podStore.findByBaseUrl(baseUrl); + if (!pod) { + this.logger.error(`Unable to find pod ${baseUrl}`); continue; } - owners[pod] = (await this.webIdStore.findLinks(owner)).map((link): string => link.webId); + const podOwners = await this.podStore.getOwners(pod.id); + if (!podOwners) { + this.logger.error(`Unable to find owners for ${baseUrl}`); + continue; + } + owners[baseUrl] = podOwners.map((owner): string => owner.webId); } return owners; } diff --git a/src/identity/interaction/pod/UpdateOwnerHandler.ts b/src/identity/interaction/pod/UpdateOwnerHandler.ts new file mode 100644 index 000000000..7f7f640cd --- /dev/null +++ b/src/identity/interaction/pod/UpdateOwnerHandler.ts @@ -0,0 +1,66 @@ +import { boolean, object, string } from 'yup'; +import type { ResourceIdentifier } from '../../../http/representation/ResourceIdentifier'; +import type { EmptyObject } from '../../../util/map/MapUtil'; +import { parsePath, verifyAccountId } from '../account/util/AccountUtil'; +import type { JsonRepresentation } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import type { JsonView } from '../JsonView'; +import { parseSchema, validateWithError } from '../YupUtil'; +import type { PodIdRoute } from './PodIdRoute'; +import type { PodStore } from './util/PodStore'; + +const inSchema = object({ + webId: string().trim().required(), + visible: boolean().optional().default(false), + // If true: remove the WebID as owner + remove: boolean().optional().default(false), +}); + +/** + * Responsible for adding/updating/deleting owners in pods. + */ +export class UpdateOwnerHandler extends JsonInteractionHandler implements JsonView { + private readonly podStore: PodStore; + private readonly podRoute: PodIdRoute; + + public constructor(podStore: PodStore, podRoute: PodIdRoute) { + super(); + this.podStore = podStore; + this.podRoute = podRoute; + } + + public async getView({ accountId, target }: JsonInteractionHandlerInput): Promise { + const pod = await this.findVerifiedPod(target, accountId); + const owners = await this.podStore.getOwners(pod.id); + + return { json: { ...parseSchema(inSchema), baseUrl: pod?.baseUrl, owners }}; + } + + public async handle(input: JsonInteractionHandlerInput): Promise> { + const { accountId, target, json } = input; + + const { webId, visible, remove } = await validateWithError(inSchema, json); + const pod = await this.findVerifiedPod(target, accountId); + + if (remove) { + await this.podStore.removeOwner(pod.id, webId); + } else { + await this.podStore.updateOwner(pod.id, webId, visible); + } + + return { json: {}}; + } + + /** + * Extract the pod ID from the path and find the associated pod. + * Asserts that the given account ID is the creator of this pod. + */ + protected async findVerifiedPod(target: ResourceIdentifier, accountId?: string): + Promise<{ id: string; baseUrl: string; accountId: string }> { + const { podId } = parsePath(this.podRoute, target.path); + const pod = await this.podStore.get(podId); + verifyAccountId(accountId, pod?.accountId); + return { id: podId, ...pod }; + } +} diff --git a/src/identity/interaction/pod/util/BasePodStore.ts b/src/identity/interaction/pod/util/BasePodStore.ts index ae31b7154..1cbb0f900 100644 --- a/src/identity/interaction/pod/util/BasePodStore.ts +++ b/src/identity/interaction/pod/util/BasePodStore.ts @@ -15,21 +15,38 @@ const STORAGE_DESCRIPTION = { accountId: `id:${ACCOUNT_TYPE}`, } as const; +const OWNER_TYPE = 'owner'; +const OWNER_DESCRIPTION = { + webId: 'string', + visible: 'boolean', + podId: `id:${STORAGE_TYPE}`, +} as const; + /** * A {@link PodStore} implementation using a {@link PodManager} to create pods * and a {@link AccountLoginStorage} to store the data. * Needs to be initialized before it can be used. + * + * Adds the initial WebID as the owner of the pod. + * By default, this owner is not exposed through a link header. + * This can be changed by setting the constructor `visible` parameter to `true`. */ export class BasePodStore extends Initializer implements PodStore { private readonly logger = getLoggerFor(this); - private readonly storage: AccountLoginStorage<{ [STORAGE_TYPE]: typeof STORAGE_DESCRIPTION }>; + private readonly storage: AccountLoginStorage<{ + [STORAGE_TYPE]: typeof STORAGE_DESCRIPTION; + [OWNER_TYPE]: typeof OWNER_DESCRIPTION; }>; + private readonly manager: PodManager; + private readonly visible: boolean; + private initialized = false; - public constructor(storage: AccountLoginStorage, manager: PodManager) { + public constructor(storage: AccountLoginStorage, manager: PodManager, visible = false) { super(); this.storage = storage; + this.visible = visible; this.manager = manager; } @@ -42,6 +59,8 @@ export class BasePodStore extends Initializer implements PodStore { await this.storage.defineType(STORAGE_TYPE, STORAGE_DESCRIPTION, false); await this.storage.createIndex(STORAGE_TYPE, 'accountId'); await this.storage.createIndex(STORAGE_TYPE, 'baseUrl'); + await this.storage.defineType(OWNER_TYPE, OWNER_DESCRIPTION, false); + await this.storage.createIndex(OWNER_TYPE, 'podId'); this.initialized = true; } catch (cause: unknown) { throw new InternalServerError(`Error defining pods in storage: ${createErrorMessage(cause)}`, @@ -53,6 +72,7 @@ export class BasePodStore extends Initializer implements PodStore { // Adding pod to storage first as we cannot undo creating the pod below. // This call might also fail because there is no login method yet on the account. const pod = await this.storage.create(STORAGE_TYPE, { baseUrl: settings.base.path, accountId }); + await this.storage.create(OWNER_TYPE, { podId: pod.id, webId: settings.webId, visible: this.visible }); try { await this.manager.createPod(settings, overwrite); @@ -66,16 +86,56 @@ export class BasePodStore extends Initializer implements PodStore { return pod.id; } - public async findAccount(baseUrl: string): Promise { + public async get(id: string): Promise<{ baseUrl: string; accountId: string } | undefined> { + const pod = await this.storage.get(STORAGE_TYPE, id); + if (!pod) { + return; + } + return { baseUrl: pod.baseUrl, accountId: pod.accountId }; + } + + public async findByBaseUrl(baseUrl: string): Promise<{ id: string; accountId: string } | undefined> { const result = await this.storage.find(STORAGE_TYPE, { baseUrl }); if (result.length === 0) { return; } - return result[0].accountId; + return { id: result[0].id, accountId: result[0].accountId }; } public async findPods(accountId: string): Promise<{ id: string; baseUrl: string }[]> { return (await this.storage.find(STORAGE_TYPE, { accountId })) .map(({ id, baseUrl }): { id: string; baseUrl: string } => ({ id, baseUrl })); } + + public async getOwners(id: string): Promise<{ webId: string; visible: boolean }[] | undefined> { + const results = await this.storage.find(OWNER_TYPE, { podId: id }); + if (results.length === 0) { + return; + } + return results.map((result): { webId: string; visible: boolean } => + ({ webId: result.webId, visible: result.visible })); + } + + public async updateOwner(id: string, webId: string, visible: boolean): Promise { + // Need to first check if there already is an owner with the given WebID + // so we know if we need to create or update. + const matches = await this.storage.find(OWNER_TYPE, { webId, podId: id }); + if (matches.length === 0) { + await this.storage.create(OWNER_TYPE, { webId, visible, podId: id }); + } else { + await this.storage.setField(OWNER_TYPE, matches[0].id, 'visible', visible); + } + } + + public async removeOwner(id: string, webId: string): Promise { + const owners = await this.storage.find(OWNER_TYPE, { podId: id }); + const match = owners.find((owner): boolean => owner.webId === webId); + if (!match) { + return; + } + if (owners.length === 1) { + throw new BadRequestHttpError('Unable to remove the last owner of a pod.'); + } + await this.storage.delete(OWNER_TYPE, match.id); + } } diff --git a/src/identity/interaction/pod/util/OwnerMetadataWriter.ts b/src/identity/interaction/pod/util/OwnerMetadataWriter.ts new file mode 100644 index 000000000..f7b2cad98 --- /dev/null +++ b/src/identity/interaction/pod/util/OwnerMetadataWriter.ts @@ -0,0 +1,75 @@ +import { Util } from 'n3'; +import type { MetadataWriterInput } from '../../../../http/output/metadata/MetadataWriter'; +import { MetadataWriter } from '../../../../http/output/metadata/MetadataWriter'; +import type { ResourceIdentifier } from '../../../../http/representation/ResourceIdentifier'; +import { getLoggerFor } from '../../../../logging/LogUtil'; +import type { StorageLocationStrategy } from '../../../../server/description/StorageLocationStrategy'; +import { createErrorMessage } from '../../../../util/errors/ErrorUtil'; +import { addHeader } from '../../../../util/HeaderUtil'; +import type { PodStore } from './PodStore'; +import isBlankNode = Util.isBlankNode; + +/** + * Adds link headers indicating who the owners are when accessing the base URL of a pod. + * Only owners that have decided to be visible will be shown. + * + * Solid, ยง4.1: "When a server wants to advertise the owner of a storage, + * the server MUST include the Link header with rel="http://www.w3.org/ns/solid/terms#owner" + * targeting the URI of the owner in the response of HTTP HEAD or GET requests targeting the root container." + * https://solidproject.org/TR/2022/protocol-20221231#server-storage-link-owner + */ +export class OwnerMetadataWriter extends MetadataWriter { + protected logger = getLoggerFor(this); + + protected podStore: PodStore; + protected storageStrategy: StorageLocationStrategy; + + public constructor(podStore: PodStore, storageStrategy: StorageLocationStrategy) { + super(); + this.podStore = podStore; + this.storageStrategy = storageStrategy; + } + + public async handle({ metadata, response }: MetadataWriterInput): Promise { + // Doing all checks here instead of in `canHandle` as this is currently used in a ParallelHandler, + // which doesn't correctly check the canHandle/handle combination. + if (isBlankNode(metadata.identifier)) { + // Blank nodes indicate errors + this.logger.debug('Skipping owner link headers as metadata identifier is a blank node.'); + return; + } + const identifier = { path: metadata.identifier.value }; + + let storageIdentifier: ResourceIdentifier; + try { + storageIdentifier = await this.storageStrategy.getStorageIdentifier(identifier); + } catch (error: unknown) { + this.logger + .debug(`Skipping owner link headers as no storage identifier could be found: ${createErrorMessage(error)}`); + return; + } + + // Only need to expose headers when requesting the base URl of the pod + if (identifier.path !== storageIdentifier.path) { + return; + } + + const pod = await this.podStore.findByBaseUrl(identifier.path); + if (!pod) { + this.logger.debug(`No pod object found for base URL ${identifier.path}`); + return; + } + + const owners = await this.podStore.getOwners(pod.id); + if (!owners) { + this.logger.error(`Unable to find owners for pod ${identifier.path}`); + return; + } + + for (const { webId, visible } of owners) { + if (visible) { + addHeader(response, 'Link', `<${webId}>; rel="http://www.w3.org/ns/solid/terms#owner"`); + } + } + } +} diff --git a/src/identity/interaction/pod/util/PodStore.ts b/src/identity/interaction/pod/util/PodStore.ts index 24826987d..42135234c 100644 --- a/src/identity/interaction/pod/util/PodStore.ts +++ b/src/identity/interaction/pod/util/PodStore.ts @@ -2,12 +2,14 @@ import type { PodSettings } from '../../../../pods/settings/PodSettings'; /** * Can be used to create new pods and find relevant information. + * Also keeps track of the owners of a pod. + * The `visible` parameter indicates if an owner should be publicly exposed or not. */ export interface PodStore { /** * Creates a new pod and updates the account accordingly. * - * @param accountId - Identifier of the account that is creating the account.. + * @param accountId - Identifier of the account that is creating the account. * @param settings - Settings to create a pod with. * @param overwrite - If the pod is allowed to overwrite existing data. * @@ -15,12 +17,19 @@ export interface PodStore { */ create: (accountId: string, settings: PodSettings, overwrite: boolean) => Promise; + /** + * Returns the baseURl and account that created the pod for the given pod ID. + * + * @param id - ID of the pod. + */ + get: (id: string) => Promise<{ baseUrl: string; accountId: string } | undefined>; + /** * Find the ID of the account that created the given pod. * * @param baseUrl - The pod base URL. */ - findAccount: (baseUrl: string) => Promise; + findByBaseUrl: (baseUrl: string) => Promise<{ id: string; accountId: string } | undefined>; /** * Find all the pod resources created by the given account ID. @@ -28,4 +37,32 @@ export interface PodStore { * @param accountId - Account ID to find pod resources for. */ findPods: (accountId: string) => Promise<{ id: string; baseUrl: string }[]>; + + /** + * Find all owners for the given pod ID. + * + * @param id - ID of the pod. + */ + getOwners: (id: string) => Promise<{ webId: string; visible: boolean }[] | undefined>; + + /** + * Add or update an owner of a pod. + * In case there already is an owner with this WebID, it will be updated, + * otherwise a new owner will be added. + * + * @param id - ID of the pod. + * @param webId - WebID of the owner. + * @param visible - Whether the owner wants to be exposed or not. + */ + updateOwner: (id: string, webId: string, visible: boolean) => Promise; + + /** + * Remove an owner from a pod. + * This should not remove the last owner as a pod always needs to have at least one owner. + * https://solidproject.org/TR/2022/protocol-20221231#server-storage-track-owner + * + * @param id - ID of the pod. + * @param webId - WebID of the owner. + */ + removeOwner: (id: string, webId: string) => Promise; } diff --git a/src/identity/interaction/webid/LinkWebIdHandler.ts b/src/identity/interaction/webid/LinkWebIdHandler.ts index 07e3835ef..6815cf843 100644 --- a/src/identity/interaction/webid/LinkWebIdHandler.ts +++ b/src/identity/interaction/webid/LinkWebIdHandler.ts @@ -97,9 +97,9 @@ export class LinkWebIdHandler extends JsonInteractionHandler implements // Only need to check ownership if the account did not create the pod let isCreator = false; try { - const pod = await this.storageStrategy.getStorageIdentifier({ path: webId }); - const creator = await this.podStore.findAccount(pod.path); - isCreator = accountId === creator; + const baseUrl = await this.storageStrategy.getStorageIdentifier({ path: webId }); + const pod = await this.podStore.findByBaseUrl(baseUrl.path); + isCreator = accountId === pod?.accountId; } catch { // Probably a WebID not hosted on the server } diff --git a/src/index.ts b/src/index.ts index fc3a996bc..cd9041be7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -202,11 +202,13 @@ export * from './identity/interaction/password/UpdatePasswordHandler'; // Identity/Interaction/Pod/Util export * from './identity/interaction/pod/util/BasePodStore'; +export * from './identity/interaction/pod/util/OwnerMetadataWriter'; export * from './identity/interaction/pod/util/PodStore'; // Identity/Interaction/Pod export * from './identity/interaction/pod/CreatePodHandler'; export * from './identity/interaction/pod/PodIdRoute'; +export * from './identity/interaction/pod/UpdateOwnerHandler'; // Identity/Interaction/Routing export * from './identity/interaction/routing/AbsolutePathInteractionRoute'; diff --git a/templates/identity/account/pod-settings.html.ejs b/templates/identity/account/pod-settings.html.ejs new file mode 100644 index 000000000..47fe72624 --- /dev/null +++ b/templates/identity/account/pod-settings.html.ejs @@ -0,0 +1,82 @@ +

Pod settings

+

+

+ +

Owners

+

+ All these WebIDs have full control access over all resources in the pod. + If visible, these will be returned as a header to all requests targeting the pod. +

+
    + +
    +

    + +
    +

    Add the WebID to add to the list of owners

    +
      +
    1. + + +
    2. +
    +
    + +

    + + +

    +
    + + + diff --git a/templates/identity/account/resource.html.ejs b/templates/identity/account/resource.html.ejs index 1e118a0a0..b3739d4b3 100644 --- a/templates/identity/account/resource.html.ejs +++ b/templates/identity/account/resource.html.ejs @@ -64,7 +64,7 @@ setVisibility('pods', false); } else { updateElement('createPod', controls.html.account.createPod, { href: true }); - showAccountInfo(elements.podEntries, pods, false, false); + showAccountInfo(elements.podEntries, pods, true, false); } // Update WebID entries @@ -104,25 +104,7 @@ if (addDel) { - const del = document.createElement('a'); - del.innerText = '(delete)'; - del.href = '#'; - del.addEventListener('click', async() => { - if (!confirm(confirmMsg)) { - return; - } - // Delete resource, show error if this fails - const res = await fetch(url, { method: 'DELETE' }); - if (res.status >= 400) { - const error = await res.json(); - setError(error.message); - } else { - li.remove(); - if (finishMsg) { - setError(finishMsg); - } - } - }); + const del = createUrlDeleteElement(li, url, { method: 'DELETE' }, confirmMsg, finishMsg); li.append(' '); li.append(del); } diff --git a/templates/root/intro/base/index.html b/templates/root/intro/base/index.html index 5f5528c6f..b74cf1c73 100644 --- a/templates/root/intro/base/index.html +++ b/templates/root/intro/base/index.html @@ -22,7 +22,7 @@

    Getting started as a user

    - Sign up for an account + Sign up for an account to get started with your own Pod and WebID.

    diff --git a/templates/root/prefilled/base/index.html b/templates/root/prefilled/base/index.html index ae116e142..1e420eaae 100644 --- a/templates/root/prefilled/base/index.html +++ b/templates/root/prefilled/base/index.html @@ -22,7 +22,7 @@

    Getting started as a user

    - Sign up for an account + Sign up for an account to get started with your own Pod and WebID.

    Getting started as a user

    - Sign up for an account + Sign up for an account to get started with your own Pod and WebID.