mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00

The original URL does not exist so should use an internal value for now. Renamed to `activity` as predicates tend to be lower case.
714 lines
31 KiB
TypeScript
714 lines
31 KiB
TypeScript
import arrayifyStream from 'arrayify-stream';
|
||
import { DataFactory } from 'n3';
|
||
import type { NamedNode, Term } from 'rdf-js';
|
||
import { v4 as uuid } from 'uuid';
|
||
import type { AuxiliaryStrategy } from '../http/auxiliary/AuxiliaryStrategy';
|
||
import { BasicRepresentation } from '../http/representation/BasicRepresentation';
|
||
import type { Patch } from '../http/representation/Patch';
|
||
import type { Representation } from '../http/representation/Representation';
|
||
import { RepresentationMetadata } from '../http/representation/RepresentationMetadata';
|
||
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
|
||
import { getLoggerFor } from '../logging/LogUtil';
|
||
import { INTERNAL_QUADS } from '../util/ContentTypes';
|
||
import { BadRequestHttpError } from '../util/errors/BadRequestHttpError';
|
||
import { ConflictHttpError } from '../util/errors/ConflictHttpError';
|
||
import { createErrorMessage } from '../util/errors/ErrorUtil';
|
||
import { ForbiddenHttpError } from '../util/errors/ForbiddenHttpError';
|
||
import { MethodNotAllowedHttpError } from '../util/errors/MethodNotAllowedHttpError';
|
||
import { NotFoundHttpError } from '../util/errors/NotFoundHttpError';
|
||
import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError';
|
||
import { PreconditionFailedHttpError } from '../util/errors/PreconditionFailedHttpError';
|
||
import type { IdentifierStrategy } from '../util/identifiers/IdentifierStrategy';
|
||
import { concat } from '../util/IterableUtil';
|
||
import { IdentifierMap } from '../util/map/IdentifierMap';
|
||
import {
|
||
ensureTrailingSlash,
|
||
isContainerIdentifier,
|
||
isContainerPath,
|
||
trimTrailingSlashes,
|
||
toCanonicalUriPath,
|
||
} from '../util/PathUtil';
|
||
import { addResourceMetadata, updateModifiedDate } from '../util/ResourceUtil';
|
||
import {
|
||
DC,
|
||
SOLID_HTTP,
|
||
LDP,
|
||
POSIX,
|
||
PIM,
|
||
RDF,
|
||
XSD,
|
||
SOLID_META,
|
||
PREFERRED_PREFIX_TERM,
|
||
CONTENT_TYPE_TERM,
|
||
SOLID_AS,
|
||
AS,
|
||
} from '../util/Vocabularies';
|
||
import type { DataAccessor } from './accessors/DataAccessor';
|
||
import type { Conditions } from './Conditions';
|
||
import type { ResourceStore, ChangeMap } from './ResourceStore';
|
||
import namedNode = DataFactory.namedNode;
|
||
|
||
/**
|
||
* ResourceStore which uses a DataAccessor for backend access.
|
||
*
|
||
* The DataAccessor interface provides elementary store operations such as read and write.
|
||
* This DataAccessorBasedStore uses those elementary store operations
|
||
* to implement the more high-level ResourceStore contact, abstracting all common functionality
|
||
* such that new stores can be added by implementing the more simple DataAccessor contract.
|
||
* DataAccessorBasedStore thereby provides behaviours for reuse across different stores, such as:
|
||
* * Converting container metadata to data
|
||
* * Converting slug to URI
|
||
* * Checking if addResource target is a container
|
||
* * Checking if no containment triples are written to a container
|
||
* * etc.
|
||
*
|
||
* Currently "metadata" is seen as something that is not directly accessible.
|
||
* That means that a consumer can't write directly to the metadata of a resource, only indirectly through headers.
|
||
* (Except for containers where data and metadata overlap).
|
||
*
|
||
* The one thing this store does not take care of (yet?) are containment triples for containers
|
||
*
|
||
* Work has been done to minimize the number of required calls to the DataAccessor,
|
||
* but the main disadvantage is that sometimes multiple calls are required where a specific store might only need one.
|
||
*/
|
||
export class DataAccessorBasedStore implements ResourceStore {
|
||
protected readonly logger = getLoggerFor(this);
|
||
|
||
private readonly accessor: DataAccessor;
|
||
private readonly identifierStrategy: IdentifierStrategy;
|
||
private readonly auxiliaryStrategy: AuxiliaryStrategy;
|
||
private readonly metadataStrategy: AuxiliaryStrategy;
|
||
|
||
public constructor(accessor: DataAccessor, identifierStrategy: IdentifierStrategy,
|
||
auxiliaryStrategy: AuxiliaryStrategy, metadataStrategy: AuxiliaryStrategy) {
|
||
this.accessor = accessor;
|
||
this.identifierStrategy = identifierStrategy;
|
||
this.auxiliaryStrategy = auxiliaryStrategy;
|
||
this.metadataStrategy = metadataStrategy;
|
||
}
|
||
|
||
public async hasResource(identifier: ResourceIdentifier): Promise<boolean> {
|
||
try {
|
||
this.validateIdentifier(identifier);
|
||
if (this.metadataStrategy.isAuxiliaryIdentifier(identifier)) {
|
||
identifier = this.metadataStrategy.getSubjectIdentifier(identifier);
|
||
}
|
||
await this.accessor.getMetadata(identifier);
|
||
return true;
|
||
} catch (error: unknown) {
|
||
if (NotFoundHttpError.isInstance(error)) {
|
||
return false;
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
public async getRepresentation(identifier: ResourceIdentifier): Promise<Representation> {
|
||
this.validateIdentifier(identifier);
|
||
let isMetadata = false;
|
||
|
||
if (this.metadataStrategy.isAuxiliaryIdentifier(identifier)) {
|
||
identifier = this.metadataStrategy.getSubjectIdentifier(identifier);
|
||
isMetadata = true;
|
||
}
|
||
|
||
// In the future we want to use getNormalizedMetadata and redirect in case the identifier differs
|
||
let metadata = await this.accessor.getMetadata(identifier);
|
||
let representation: Representation;
|
||
|
||
// Potentially add auxiliary related metadata
|
||
// Solid, §4.3: "Clients can discover auxiliary resources associated with a subject resource by making an HTTP HEAD
|
||
// or GET request on the target URL, and checking the HTTP Link header with the rel parameter"
|
||
// https://solid.github.io/specification/protocol#auxiliary-resources
|
||
await this.auxiliaryStrategy.addMetadata(metadata);
|
||
|
||
const isContainer = isContainerPath(metadata.identifier.value);
|
||
let data = metadata.quads();
|
||
if (isContainer || isMetadata) {
|
||
if (isContainer) {
|
||
// Add containment triples of non-auxiliary resources
|
||
for await (const child of this.accessor.getChildren(identifier)) {
|
||
if (!this.auxiliaryStrategy.isAuxiliaryIdentifier({ path: child.identifier.value })) {
|
||
if (!isMetadata) {
|
||
metadata.addQuads(child.quads());
|
||
}
|
||
metadata.add(LDP.terms.contains, child.identifier as NamedNode, SOLID_META.terms.ResponseMetadata);
|
||
}
|
||
}
|
||
data = metadata.quads();
|
||
|
||
if (isMetadata) {
|
||
metadata = new RepresentationMetadata(this.metadataStrategy.getAuxiliaryIdentifier(identifier));
|
||
}
|
||
}
|
||
metadata.addQuad(DC.terms.namespace, PREFERRED_PREFIX_TERM, 'dc', SOLID_META.terms.ResponseMetadata);
|
||
metadata.addQuad(LDP.terms.namespace, PREFERRED_PREFIX_TERM, 'ldp', SOLID_META.terms.ResponseMetadata);
|
||
metadata.addQuad(POSIX.terms.namespace, PREFERRED_PREFIX_TERM, 'posix', SOLID_META.terms.ResponseMetadata);
|
||
metadata.addQuad(XSD.terms.namespace, PREFERRED_PREFIX_TERM, 'xsd', SOLID_META.terms.ResponseMetadata);
|
||
}
|
||
|
||
if (isContainer) {
|
||
representation = new BasicRepresentation(data, metadata, INTERNAL_QUADS);
|
||
} else if (isMetadata) {
|
||
representation = new BasicRepresentation(
|
||
metadata.quads(), this.metadataStrategy.getAuxiliaryIdentifier(identifier), INTERNAL_QUADS,
|
||
);
|
||
} else {
|
||
representation = new BasicRepresentation(await this.accessor.getData(identifier), metadata);
|
||
}
|
||
|
||
return representation;
|
||
}
|
||
|
||
public async addResource(container: ResourceIdentifier, representation: Representation, conditions?: Conditions):
|
||
Promise<ChangeMap> {
|
||
this.validateIdentifier(container);
|
||
|
||
const parentMetadata = await this.getSafeNormalizedMetadata(container);
|
||
|
||
// Solid, §5.3: "When a POST method request targets a resource without an existing representation,
|
||
// the server MUST respond with the 404 status code."
|
||
// https://solid.github.io/specification/protocol#writing-resources
|
||
if (!parentMetadata) {
|
||
throw new NotFoundHttpError();
|
||
}
|
||
|
||
// Not using `container` since `getSafeNormalizedMetadata` might return metadata for a different identifier.
|
||
// Solid, §5: "Servers MUST respond with the 405 status code to requests using HTTP methods
|
||
// that are not supported by the target resource."
|
||
// https://solid.github.io/specification/protocol#reading-writing-resources
|
||
if (!isContainerPath(parentMetadata.identifier.value)) {
|
||
throw new MethodNotAllowedHttpError([ 'POST' ], 'The given path is not a container.');
|
||
}
|
||
|
||
this.validateConditions(conditions, parentMetadata);
|
||
|
||
// Solid, §5.1: "Servers MAY allow clients to suggest the URI of a resource created through POST,
|
||
// using the HTTP Slug header as defined in [RFC5023].
|
||
// Clients who want the server to assign a URI of a resource, MUST use the POST request."
|
||
// https://solid.github.io/specification/protocol#resource-type-heuristics
|
||
const newID = await this.createSafeUri(container, representation.metadata);
|
||
const isContainer = isContainerIdentifier(newID);
|
||
|
||
// Ensure the representation is supported by the accessor
|
||
// Containers are not checked because uploaded representations are treated as metadata
|
||
if (!isContainer) {
|
||
await this.accessor.canHandle(representation);
|
||
}
|
||
|
||
// Write the data. New containers should never be made for a POST request.
|
||
return this.writeData(newID, representation, isContainer, false, false);
|
||
}
|
||
|
||
public async setRepresentation(identifier: ResourceIdentifier, representation: Representation,
|
||
conditions?: Conditions): Promise<ChangeMap> {
|
||
this.validateIdentifier(identifier);
|
||
|
||
// Check if the resource already exists
|
||
const oldMetadata = await this.getSafeNormalizedMetadata(identifier);
|
||
// We do not allow PUT on an already existing Container
|
||
// See https://github.com/CommunitySolidServer/CommunitySolidServer/issues/1027#issuecomment-1023371546
|
||
if (oldMetadata && isContainerIdentifier(identifier)) {
|
||
throw new ConflictHttpError('Existing containers cannot be updated via PUT.');
|
||
}
|
||
|
||
// Preserve the old metadata
|
||
if (oldMetadata && representation.metadata.has(
|
||
SOLID_META.terms.preserve,
|
||
namedNode(this.metadataStrategy.getAuxiliaryIdentifier(identifier).path),
|
||
)) {
|
||
// Preserve all the quads from the old metadata apart from the ContentType
|
||
oldMetadata.contentType = undefined;
|
||
const quads = oldMetadata.quads();
|
||
representation.metadata.addQuads(quads);
|
||
}
|
||
|
||
// Might want to redirect in the future.
|
||
// See #480
|
||
// Solid, §3.1: "If two URIs differ only in the trailing slash, and the server has associated a resource with
|
||
// one of them, then the other URI MUST NOT correspond to another resource. Instead, the server MAY respond to
|
||
// requests for the latter URI with a 301 redirect to the former."
|
||
// https://solid.github.io/specification/protocol#uri-slash-semantics
|
||
if (oldMetadata && oldMetadata.identifier.value !== identifier.path) {
|
||
throw new ConflictHttpError(`${identifier.path} conflicts with existing path ${oldMetadata.identifier.value}`);
|
||
}
|
||
|
||
// Solid, §3.1: "Paths ending with a slash denote a container resource."
|
||
// https://solid.github.io/specification/protocol#uri-slash-semantics
|
||
const isContainer = isContainerIdentifier(identifier);
|
||
if (!isContainer && this.isContainerType(representation.metadata)) {
|
||
throw new BadRequestHttpError('Containers should have a `/` at the end of their path, resources should not.');
|
||
}
|
||
|
||
// Ensure the representation is supported by the accessor
|
||
// Metadata and containers are not checked since they get converted to RepresentationMetadata objects.
|
||
if (!isContainer && !this.metadataStrategy.isAuxiliaryIdentifier(identifier)) {
|
||
await this.accessor.canHandle(representation);
|
||
}
|
||
|
||
this.validateConditions(conditions, oldMetadata);
|
||
|
||
if (this.metadataStrategy.isAuxiliaryIdentifier(identifier)) {
|
||
return await this.writeMetadata(identifier, representation);
|
||
}
|
||
|
||
// Potentially have to create containers if it didn't exist yet
|
||
return this.writeData(identifier, representation, isContainer, !oldMetadata, Boolean(oldMetadata));
|
||
}
|
||
|
||
public async modifyResource(identifier: ResourceIdentifier, patch: Patch,
|
||
conditions?: Conditions): Promise<never> {
|
||
if (conditions) {
|
||
let metadata: RepresentationMetadata | undefined;
|
||
try {
|
||
metadata = await this.accessor.getMetadata(identifier);
|
||
} catch (error: unknown) {
|
||
if (!NotFoundHttpError.isInstance(error)) {
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
this.validateConditions(conditions, metadata);
|
||
}
|
||
|
||
throw new NotImplementedHttpError('Patches are not supported by the default store.');
|
||
}
|
||
|
||
public async deleteResource(identifier: ResourceIdentifier, conditions?: Conditions): Promise<ChangeMap> {
|
||
this.validateIdentifier(identifier);
|
||
|
||
// https://github.com/CommunitySolidServer/CommunitySolidServer/issues/1027#issuecomment-988664970
|
||
// DELETE is not allowed on metadata
|
||
if (this.metadataStrategy.isAuxiliaryIdentifier(identifier)) {
|
||
throw new ConflictHttpError('Not allowed to delete metadata resources directly.');
|
||
}
|
||
|
||
const metadata = await this.accessor.getMetadata(identifier);
|
||
// Solid, §5.4: "When a DELETE request targets storage’s root container or its associated ACL resource,
|
||
// the server MUST respond with the 405 status code."
|
||
// https://solid.github.io/specification/protocol#deleting-resources
|
||
if (this.isRootStorage(metadata)) {
|
||
throw new MethodNotAllowedHttpError([ 'DELETE' ], 'Cannot delete a root storage container.');
|
||
}
|
||
if (this.auxiliaryStrategy.isAuxiliaryIdentifier(identifier) &&
|
||
this.auxiliaryStrategy.isRequiredInRoot(identifier)) {
|
||
const subjectIdentifier = this.auxiliaryStrategy.getSubjectIdentifier(identifier);
|
||
const parentMetadata = await this.accessor.getMetadata(subjectIdentifier);
|
||
if (this.isRootStorage(parentMetadata)) {
|
||
throw new MethodNotAllowedHttpError([ 'DELETE' ],
|
||
`Cannot delete ${identifier.path} from a root storage container.`);
|
||
}
|
||
}
|
||
|
||
// Solid, §5.4: "When a DELETE request is made to a container, the server MUST delete the container
|
||
// if it contains no resources. If the container contains resources,
|
||
// the server MUST respond with the 409 status code and response body describing the error."
|
||
// https://solid.github.io/specification/protocol#deleting-resources
|
||
// Auxiliary resources are not counted when deleting a container since they will also be deleted.
|
||
if (isContainerIdentifier(identifier) && await this.hasProperChildren(identifier)) {
|
||
throw new ConflictHttpError('Can only delete empty containers.');
|
||
}
|
||
|
||
this.validateConditions(conditions, metadata);
|
||
|
||
// Solid, §5.4: "When a contained resource is deleted,
|
||
// the server MUST also delete the associated auxiliary resources"
|
||
// https://solid.github.io/specification/protocol#deleting-resources
|
||
const changes: ChangeMap = new IdentifierMap();
|
||
if (!this.auxiliaryStrategy.isAuxiliaryIdentifier(identifier)) {
|
||
const auxiliaries = this.auxiliaryStrategy.getAuxiliaryIdentifiers(identifier);
|
||
for (const deletedId of await this.safelyDeleteAuxiliaryResources(auxiliaries)) {
|
||
this.addActivityMetadata(changes, deletedId, AS.terms.Delete);
|
||
}
|
||
}
|
||
|
||
if (!this.identifierStrategy.isRootContainer(identifier)) {
|
||
const container = this.identifierStrategy.getParentContainer(identifier);
|
||
this.addActivityMetadata(changes, container, AS.terms.Update);
|
||
|
||
// Update modified date of parent
|
||
await this.updateContainerModifiedDate(container);
|
||
}
|
||
|
||
await this.accessor.deleteResource(identifier);
|
||
this.addActivityMetadata(changes, identifier, AS.terms.Delete);
|
||
return changes;
|
||
}
|
||
|
||
/**
|
||
* Verify if the given identifier matches the stored base.
|
||
*/
|
||
protected validateIdentifier(identifier: ResourceIdentifier): void {
|
||
if (!this.identifierStrategy.supportsIdentifier(identifier)) {
|
||
throw new NotFoundHttpError();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Verify if the given metadata matches the conditions.
|
||
*/
|
||
protected validateConditions(conditions?: Conditions, metadata?: RepresentationMetadata): void {
|
||
// The 412 (Precondition Failed) status code indicates
|
||
// that one or more conditions given in the request header fields evaluated to false when tested on the server.
|
||
if (conditions && !conditions.matchesMetadata(metadata)) {
|
||
throw new PreconditionFailedHttpError();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns the metadata matching the identifier, ignoring the presence of a trailing slash or not.
|
||
*
|
||
* Solid, §3.1: "If two URIs differ only in the trailing slash,
|
||
* and the server has associated a resource with one of them,
|
||
* then the other URI MUST NOT correspond to another resource."
|
||
* https://solid.github.io/specification/protocol#uri-slash-semantics
|
||
*
|
||
* First the identifier gets requested and if no result is found
|
||
* the identifier with differing trailing slash is requested.
|
||
* @param identifier - Identifier that needs to be checked.
|
||
*/
|
||
protected async getNormalizedMetadata(identifier: ResourceIdentifier): Promise<RepresentationMetadata> {
|
||
const hasSlash = isContainerIdentifier(identifier);
|
||
try {
|
||
return await this.accessor.getMetadata(identifier);
|
||
} catch (error: unknown) {
|
||
if (NotFoundHttpError.isInstance(error)) {
|
||
const otherIdentifier =
|
||
{ path: hasSlash ? trimTrailingSlashes(identifier.path) : ensureTrailingSlash(identifier.path) };
|
||
|
||
// Only try to access other identifier if it is valid in the scope of the DataAccessor
|
||
this.validateIdentifier(otherIdentifier);
|
||
return this.accessor.getMetadata(otherIdentifier);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns the result of `getNormalizedMetadata` or undefined if a 404 error is thrown.
|
||
*/
|
||
protected async getSafeNormalizedMetadata(identifier: ResourceIdentifier):
|
||
Promise<RepresentationMetadata | undefined> {
|
||
try {
|
||
return await this.getNormalizedMetadata(identifier);
|
||
} catch (error: unknown) {
|
||
if (!NotFoundHttpError.isInstance(error)) {
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Write the given metadata resource to the DataAccessor.
|
||
* @param identifier - Identifier of the metadata.
|
||
* @param representation - Corresponding Representation.
|
||
*
|
||
* @returns Identifiers of resources that were possibly modified.
|
||
*/
|
||
protected async writeMetadata(identifier: ResourceIdentifier, representation: Representation):
|
||
Promise<ChangeMap> {
|
||
const subjectIdentifier = this.metadataStrategy.getSubjectIdentifier(identifier);
|
||
|
||
// Cannot create metadata without a corresponding resource
|
||
if (!await this.hasResource(subjectIdentifier)) {
|
||
throw new ConflictHttpError('Metadata resources can not be created directly.');
|
||
}
|
||
|
||
// https://github.com/CommunitySolidServer/CommunitySolidServer/issues/1027#issuecomment-988664970
|
||
// It must not be possible to create .meta.meta resources
|
||
if (this.metadataStrategy.isAuxiliaryIdentifier(subjectIdentifier)) {
|
||
throw new ConflictHttpError(
|
||
'Not allowed to create metadata resources on a metadata resource.',
|
||
);
|
||
}
|
||
|
||
const changes: ChangeMap = new IdentifierMap();
|
||
|
||
// Tranform representation data to quads and add them to the metadata object
|
||
const metadata = new RepresentationMetadata(subjectIdentifier);
|
||
const quads = await arrayifyStream(representation.data);
|
||
metadata.addQuads(quads);
|
||
|
||
// Remove the response metadata as this must not be stored
|
||
this.removeResponseMetadata(metadata);
|
||
await this.accessor.writeMetadata(subjectIdentifier, metadata);
|
||
|
||
this.addActivityMetadata(changes, subjectIdentifier, AS.terms.Update);
|
||
return changes;
|
||
}
|
||
|
||
/**
|
||
* Write the given resource to the DataAccessor. Metadata will be updated with necessary triples.
|
||
* In case of containers `handleContainerData` will be used to verify the data.
|
||
* @param identifier - Identifier of the resource.
|
||
* @param representation - Corresponding Representation.
|
||
* @param isContainer - Is the incoming resource a container?
|
||
* @param createContainers - Should parent containers (potentially) be created?
|
||
* @param exists - If the resource already exists.
|
||
*
|
||
* @returns Identifiers of resources that were possibly modified.
|
||
*/
|
||
protected async writeData(identifier: ResourceIdentifier, representation: Representation, isContainer: boolean,
|
||
createContainers: boolean, exists: boolean): Promise<ChangeMap> {
|
||
// Make sure the metadata has the correct identifier and correct type quads
|
||
// Need to do this before handling container data to have the correct identifier
|
||
representation.metadata.identifier = DataFactory.namedNode(identifier.path);
|
||
addResourceMetadata(representation.metadata, isContainer);
|
||
|
||
// Validate container data
|
||
if (isContainer) {
|
||
await this.handleContainerData(representation);
|
||
}
|
||
|
||
// Validate auxiliary data
|
||
if (this.auxiliaryStrategy.isAuxiliaryIdentifier(identifier)) {
|
||
await this.auxiliaryStrategy.validate(representation);
|
||
}
|
||
|
||
// Add date modified metadata
|
||
updateModifiedDate(representation.metadata);
|
||
|
||
// Root container should not have a parent container
|
||
// Solid, §5.3: "Servers MUST create intermediate containers and include corresponding containment triples
|
||
// in container representations derived from the URI path component of PUT and PATCH requests."
|
||
// https://solid.github.io/specification/protocol#writing-resources
|
||
let changes: ChangeMap = new IdentifierMap();
|
||
if (!this.identifierStrategy.isRootContainer(identifier) && !exists) {
|
||
const parent = this.identifierStrategy.getParentContainer(identifier);
|
||
|
||
if (createContainers) {
|
||
changes = await this.createRecursiveContainers(parent);
|
||
}
|
||
|
||
// No changes means the parent container exists and will be updated
|
||
if (changes.size === 0) {
|
||
this.addActivityMetadata(changes, parent, AS.terms.Update);
|
||
}
|
||
|
||
// Parent container is also modified
|
||
await this.updateContainerModifiedDate(parent);
|
||
}
|
||
|
||
// Remove all generated metadata to prevent it from being stored permanently
|
||
this.removeResponseMetadata(representation.metadata);
|
||
|
||
await (isContainer ?
|
||
this.accessor.writeContainer(identifier, representation.metadata) :
|
||
this.accessor.writeDocument(identifier, representation.data, representation.metadata));
|
||
|
||
this.addActivityMetadata(changes, identifier, exists ? AS.terms.Update : AS.terms.Create);
|
||
return changes;
|
||
}
|
||
|
||
/**
|
||
* Warns when the representation has data and removes the content-type from the metadata.
|
||
*
|
||
* @param representation - Container representation.
|
||
*/
|
||
protected async handleContainerData(representation: Representation): Promise<void> {
|
||
// https://github.com/CommunitySolidServer/CommunitySolidServer/issues/1027#issuecomment-1022214820
|
||
// Make it not possible via PUT to add metadata during the creation of a container
|
||
// Thus the contents are ignored and a warning is sent
|
||
if (!representation.isEmpty) {
|
||
this.logger.warn('The contents of the body are ignored when creating a container.');
|
||
}
|
||
|
||
// Input content type doesn't matter anymore
|
||
representation.metadata.removeAll(CONTENT_TYPE_TERM);
|
||
}
|
||
|
||
/**
|
||
* Removes all generated data from metadata to prevent it from being stored permanently.
|
||
*/
|
||
protected removeResponseMetadata(metadata: RepresentationMetadata): void {
|
||
metadata.removeQuads(
|
||
metadata.quads(null, null, null, SOLID_META.terms.ResponseMetadata),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Updates the last modified date of the given container
|
||
*/
|
||
protected async updateContainerModifiedDate(container: ResourceIdentifier): Promise<void> {
|
||
const parentMetadata = await this.accessor.getMetadata(container);
|
||
updateModifiedDate(parentMetadata);
|
||
this.removeResponseMetadata(parentMetadata);
|
||
await this.accessor.writeContainer(container, parentMetadata);
|
||
}
|
||
|
||
/**
|
||
* Generates a new URI for a resource in the given container, potentially using the given slug.
|
||
*
|
||
* Solid, §5.3: "Servers MUST allow creating new resources with a POST request to URI path ending `/`.
|
||
* Servers MUST create a resource with URI path ending `/{id}` in container `/`.
|
||
* Servers MUST create a container with URI path ending `/{id}/` in container `/` for requests
|
||
* including the HTTP Link header with rel="type" targeting a valid LDP container type."
|
||
* https://solid.github.io/specification/protocol#writing-resources
|
||
*
|
||
* @param container - Parent container of the new URI.
|
||
* @param isContainer - Does the new URI represent a container?
|
||
* @param slug - Slug to use for the new URI.
|
||
*/
|
||
protected createURI(container: ResourceIdentifier, isContainer: boolean, slug?: string): ResourceIdentifier {
|
||
this.validateSlug(isContainer, slug);
|
||
const base = ensureTrailingSlash(container.path);
|
||
const name = (slug && this.cleanSlug(slug)) ?? uuid();
|
||
const suffix = isContainer ? '/' : '';
|
||
return { path: `${base}${name}${suffix}` };
|
||
}
|
||
|
||
/**
|
||
* Validates if the slug and headers are valid.
|
||
* Errors if slug exists, ends on slash, but ContainerType Link header is NOT present
|
||
* @param isContainer - Is the slug supposed to represent a container?
|
||
* @param slug - Is the requested slug (if any).
|
||
*/
|
||
protected validateSlug(isContainer: boolean, slug?: string): void {
|
||
if (slug && isContainerPath(slug) && !isContainer) {
|
||
throw new BadRequestHttpError('Only slugs used to create containers can end with a `/`.');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clean http Slug to be compatible with the server. Makes sure there are no unwanted characters
|
||
* e.g.: cleanslug('&%26') returns '%26%26'
|
||
* @param slug - the slug to clean
|
||
*/
|
||
protected cleanSlug(slug: string): string {
|
||
if (/\/[^/]/u.test(slug)) {
|
||
throw new BadRequestHttpError('Slugs should not contain slashes');
|
||
}
|
||
return toCanonicalUriPath(trimTrailingSlashes(slug));
|
||
}
|
||
|
||
/**
|
||
* Generate a valid URI to store a new Resource in the given container.
|
||
* URI will be based on the slug header if there is one and is guaranteed to not exist yet.
|
||
*
|
||
* @param container - Identifier of the target container.
|
||
* @param metadata - Metadata of the new resource.
|
||
*/
|
||
protected async createSafeUri(container: ResourceIdentifier, metadata: RepresentationMetadata):
|
||
Promise<ResourceIdentifier> {
|
||
// Get all values needed for naming the resource
|
||
const isContainer = this.isContainerType(metadata);
|
||
const slug = metadata.get(SOLID_HTTP.terms.slug)?.value;
|
||
metadata.removeAll(SOLID_HTTP.terms.slug);
|
||
|
||
let newID: ResourceIdentifier = this.createURI(container, isContainer, slug);
|
||
|
||
// Solid, §5.3: "When a POST method request with the Slug header targets an auxiliary resource,
|
||
// the server MUST respond with the 403 status code and response body describing the error."
|
||
// https://solid.github.io/specification/protocol#writing-resources
|
||
if (this.auxiliaryStrategy.isAuxiliaryIdentifier(newID)) {
|
||
throw new ForbiddenHttpError('Slug bodies that would result in an auxiliary resource are forbidden');
|
||
}
|
||
|
||
// Make sure we don't already have a resource with this exact name (or with differing trailing slash)
|
||
const withSlash = { path: ensureTrailingSlash(newID.path) };
|
||
const withoutSlash = { path: trimTrailingSlashes(newID.path) };
|
||
if (await this.hasResource(withSlash) || await this.hasResource(withoutSlash)) {
|
||
newID = this.createURI(container, isContainer);
|
||
}
|
||
|
||
return newID;
|
||
}
|
||
|
||
/**
|
||
* Checks if the given metadata represents a (potential) container,
|
||
* based on the metadata.
|
||
* @param metadata - Metadata of the (new) resource.
|
||
*/
|
||
protected isContainerType(metadata: RepresentationMetadata): boolean {
|
||
return this.hasContainerType(metadata.getAll(RDF.terms.type));
|
||
}
|
||
|
||
/**
|
||
* Checks in a list of types if any of them match a Container type.
|
||
*/
|
||
protected hasContainerType(rdfTypes: Term[]): boolean {
|
||
return rdfTypes.some((type): boolean => type.value === LDP.Container || type.value === LDP.BasicContainer);
|
||
}
|
||
|
||
/**
|
||
* Verifies if this is the metadata of a root storage container.
|
||
*/
|
||
protected isRootStorage(metadata: RepresentationMetadata): boolean {
|
||
return metadata.getAll(RDF.terms.type).some((term): boolean => term.value === PIM.Storage);
|
||
}
|
||
|
||
/**
|
||
* Checks if the given container has any non-auxiliary resources.
|
||
*/
|
||
protected async hasProperChildren(container: ResourceIdentifier): Promise<boolean> {
|
||
for await (const child of this.accessor.getChildren(container)) {
|
||
if (!this.auxiliaryStrategy.isAuxiliaryIdentifier({ path: child.identifier.value })) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Deletes the given array of auxiliary identifiers.
|
||
* Does not throw an error if something goes wrong.
|
||
*/
|
||
protected async safelyDeleteAuxiliaryResources(identifiers: ResourceIdentifier[]): Promise<ResourceIdentifier[]> {
|
||
const deleted: ResourceIdentifier[] = [];
|
||
await Promise.all(identifiers.map(async(identifier): Promise<void> => {
|
||
try {
|
||
await this.accessor.deleteResource(identifier);
|
||
deleted.push(identifier);
|
||
} catch (error: unknown) {
|
||
if (!NotFoundHttpError.isInstance(error)) {
|
||
this.logger.error(`Error deleting auxiliary resource ${identifier.path}: ${createErrorMessage(error)}`);
|
||
}
|
||
}
|
||
}));
|
||
return deleted;
|
||
}
|
||
|
||
/**
|
||
* Create containers starting from the root until the given identifier corresponds to an existing container.
|
||
* Will throw errors if the identifier of the last existing "container" corresponds to an existing document.
|
||
* @param container - Identifier of the container which will need to exist.
|
||
*/
|
||
protected async createRecursiveContainers(container: ResourceIdentifier): Promise<ChangeMap> {
|
||
// Verify whether the container already exists
|
||
try {
|
||
const metadata = await this.getNormalizedMetadata(container);
|
||
// See https://github.com/CommunitySolidServer/CommunitySolidServer/issues/480
|
||
// Solid, §3.1: "If two URIs differ only in the trailing slash, and the server has associated a resource with
|
||
// one of them, then the other URI MUST NOT correspond to another resource. Instead, the server MAY respond to
|
||
// requests for the latter URI with a 301 redirect to the former."
|
||
// https://solid.github.io/specification/protocol#uri-slash-semantics
|
||
if (!isContainerPath(metadata.identifier.value)) {
|
||
throw new ForbiddenHttpError(`Creating container ${container.path} conflicts with an existing resource.`);
|
||
}
|
||
return new IdentifierMap();
|
||
} catch (error: unknown) {
|
||
if (!NotFoundHttpError.isInstance(error)) {
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Create the container, starting with its parent
|
||
const ancestors: ChangeMap = this.identifierStrategy.isRootContainer(container) ?
|
||
new IdentifierMap() :
|
||
await this.createRecursiveContainers(this.identifierStrategy.getParentContainer(container));
|
||
const changes = await this.writeData(container, new BasicRepresentation([], container), true, false, false);
|
||
|
||
return new IdentifierMap(concat([ changes, ancestors ]));
|
||
}
|
||
|
||
/**
|
||
* Generates activity metadata for a resource and adds it to the {@link ChangeMap}
|
||
* @param map - ChangeMap to update.
|
||
* @param id - Identifier of the resource being changed.
|
||
* @param activity - Which activity is taking place.
|
||
*/
|
||
private addActivityMetadata(map: ChangeMap, id: ResourceIdentifier, activity: NamedNode): void {
|
||
map.set(id, new RepresentationMetadata(id, { [SOLID_AS.activity]: activity }));
|
||
}
|
||
}
|