mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Create conditions based on input headers
This commit is contained in:
@@ -83,6 +83,10 @@ export * from './ldp/auxiliary/RoutingAuxiliaryStrategy';
|
||||
export * from './ldp/auxiliary/SuffixAuxiliaryIdentifierStrategy';
|
||||
export * from './ldp/auxiliary/Validator';
|
||||
|
||||
// LDP/HTTP/Conditions
|
||||
export * from './ldp/http/conditions/BasicConditionsParser';
|
||||
export * from './ldp/http/conditions/ConditionsParser';
|
||||
|
||||
// LDP/HTTP/Metadata
|
||||
export * from './ldp/http/metadata/ConstantMetadataWriter';
|
||||
export * from './ldp/http/metadata/ContentTypeParser';
|
||||
@@ -258,6 +262,7 @@ export * from './storage/routing/RouterRule';
|
||||
// Storage
|
||||
export * from './storage/AtomicResourceStore';
|
||||
export * from './storage/BaseResourceStore';
|
||||
export * from './storage/BasicConditions';
|
||||
export * from './storage/Conditions';
|
||||
export * from './storage/DataAccessorBasedStore';
|
||||
export * from './storage/IndexRepresentationStore';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InternalServerError } from '../../util/errors/InternalServerError';
|
||||
import type { Operation } from '../operations/Operation';
|
||||
import { RepresentationMetadata } from '../representation/RepresentationMetadata';
|
||||
import type { BodyParser } from './BodyParser';
|
||||
import type { ConditionsParser } from './conditions/ConditionsParser';
|
||||
import type { MetadataParser } from './metadata/MetadataParser';
|
||||
import type { PreferenceParser } from './PreferenceParser';
|
||||
import { RequestParser } from './RequestParser';
|
||||
@@ -15,17 +16,20 @@ export interface BasicRequestParserArgs {
|
||||
targetExtractor: TargetExtractor;
|
||||
preferenceParser: PreferenceParser;
|
||||
metadataParser: MetadataParser;
|
||||
conditionsParser: ConditionsParser;
|
||||
bodyParser: BodyParser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an {@link Operation} from an incoming {@link HttpRequest} by aggregating the results
|
||||
* of a {@link TargetExtractor}, {@link PreferenceParser}, {@link MetadataParser}, and {@link BodyParser}.
|
||||
* of a {@link TargetExtractor}, {@link PreferenceParser}, {@link MetadataParser},
|
||||
* {@link ConditionsParser} and {@link BodyParser}.
|
||||
*/
|
||||
export class BasicRequestParser extends RequestParser {
|
||||
private readonly targetExtractor!: TargetExtractor;
|
||||
private readonly preferenceParser!: PreferenceParser;
|
||||
private readonly metadataParser!: MetadataParser;
|
||||
private readonly conditionsParser!: ConditionsParser;
|
||||
private readonly bodyParser!: BodyParser;
|
||||
|
||||
public constructor(args: BasicRequestParserArgs) {
|
||||
@@ -42,8 +46,9 @@ export class BasicRequestParser extends RequestParser {
|
||||
const preferences = await this.preferenceParser.handleSafe({ request });
|
||||
const metadata = new RepresentationMetadata(target);
|
||||
await this.metadataParser.handleSafe({ request, metadata });
|
||||
const conditions = await this.conditionsParser.handleSafe(request);
|
||||
const body = await this.bodyParser.handleSafe({ request, metadata });
|
||||
|
||||
return { method, target, preferences, body };
|
||||
return { method, target, preferences, conditions, body };
|
||||
}
|
||||
}
|
||||
|
||||
63
src/ldp/http/conditions/BasicConditionsParser.ts
Normal file
63
src/ldp/http/conditions/BasicConditionsParser.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { getLoggerFor } from '../../../logging/LogUtil';
|
||||
import type { HttpRequest } from '../../../server/HttpRequest';
|
||||
import type { BasicConditionsOptions } from '../../../storage/BasicConditions';
|
||||
import { BasicConditions } from '../../../storage/BasicConditions';
|
||||
import type { Conditions } from '../../../storage/Conditions';
|
||||
import { ConditionsParser } from './ConditionsParser';
|
||||
|
||||
/**
|
||||
* Creates a Conditions object based on the the following headers:
|
||||
* - If-Modified-Since
|
||||
* - If-Unmodified-Since
|
||||
* - If-Match
|
||||
* - If-None-Match
|
||||
*
|
||||
* Implementation based on RFC7232
|
||||
*/
|
||||
export class BasicConditionsParser extends ConditionsParser {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
public async handle(request: HttpRequest): Promise<Conditions | undefined> {
|
||||
const options: BasicConditionsOptions = {
|
||||
matchesETag: this.parseTagHeader(request, 'if-match'),
|
||||
notMatchesETag: this.parseTagHeader(request, 'if-none-match'),
|
||||
};
|
||||
|
||||
// A recipient MUST ignore If-Modified-Since if the request contains an If-None-Match header field
|
||||
// A recipient MUST ignore the If-Modified-Since header field ... if the request method is neither GET nor HEAD.
|
||||
if (!options.notMatchesETag && (request.method === 'GET' || request.method === 'HEAD')) {
|
||||
options.modifiedSince = this.parseDateHeader(request, 'if-modified-since');
|
||||
}
|
||||
|
||||
// A recipient MUST ignore If-Unmodified-Since if the request contains an If-Match header field
|
||||
if (!options.matchesETag) {
|
||||
options.unmodifiedSince = this.parseDateHeader(request, 'if-unmodified-since');
|
||||
}
|
||||
|
||||
// Only return a Conditions object if there is at least one condition; undefined otherwise
|
||||
this.logger.debug(`Found the following conditions: ${JSON.stringify(options)}`);
|
||||
if (Object.values(options).some((val): boolean => typeof val !== 'undefined')) {
|
||||
return new BasicConditions(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a request header containing a datetime string to an actual Date object.
|
||||
* Undefined if there is no value for the given header name.
|
||||
*/
|
||||
private parseDateHeader(request: HttpRequest, header: 'if-modified-since' | 'if-unmodified-since'): Date | undefined {
|
||||
const headerVal = request.headers[header];
|
||||
if (headerVal) {
|
||||
const timestamp = Date.parse(headerVal);
|
||||
return Number.isNaN(timestamp) ? undefined : new Date(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a request header containing ETags to an array of ETags.
|
||||
* Undefined if there is no value for the given header name.
|
||||
*/
|
||||
private parseTagHeader(request: HttpRequest, header: 'if-match' | 'if-none-match'): string[] | undefined {
|
||||
return request.headers[header]?.trim().split(/\s*,\s*/u);
|
||||
}
|
||||
}
|
||||
8
src/ldp/http/conditions/ConditionsParser.ts
Normal file
8
src/ldp/http/conditions/ConditionsParser.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { HttpRequest } from '../../../server/HttpRequest';
|
||||
import type { Conditions } from '../../../storage/Conditions';
|
||||
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
|
||||
|
||||
/**
|
||||
* Creates a Conditions object based on the input HttpRequest.
|
||||
*/
|
||||
export abstract class ConditionsParser extends AsyncHandler<HttpRequest, Conditions | undefined> {}
|
||||
@@ -24,7 +24,7 @@ export class DeleteOperationHandler extends OperationHandler {
|
||||
}
|
||||
|
||||
public async handle(input: Operation): Promise<ResponseDescription> {
|
||||
await this.store.deleteResource(input.target);
|
||||
await this.store.deleteResource(input.target, input.conditions);
|
||||
return new ResetResponseDescription();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export class GetOperationHandler extends OperationHandler {
|
||||
}
|
||||
|
||||
public async handle(input: Operation): Promise<ResponseDescription> {
|
||||
const body = await this.store.getRepresentation(input.target, input.preferences);
|
||||
const body = await this.store.getRepresentation(input.target, input.preferences, input.conditions);
|
||||
|
||||
input.authorization?.addMetadata(body.metadata);
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export class HeadOperationHandler extends OperationHandler {
|
||||
}
|
||||
|
||||
public async handle(input: Operation): Promise<ResponseDescription> {
|
||||
const body = await this.store.getRepresentation(input.target, input.preferences);
|
||||
const body = await this.store.getRepresentation(input.target, input.preferences, input.conditions);
|
||||
|
||||
// Close the Readable as we will not return it.
|
||||
body.data.destroy();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Authorization } from '../../authorization/Authorization';
|
||||
import type { Conditions } from '../../storage/Conditions';
|
||||
import type { Representation } from '../representation/Representation';
|
||||
import type { RepresentationPreferences } from '../representation/RepresentationPreferences';
|
||||
import type { ResourceIdentifier } from '../representation/ResourceIdentifier';
|
||||
@@ -19,6 +20,10 @@ export interface Operation {
|
||||
* Representation preferences of the response. Will be empty if there are none.
|
||||
*/
|
||||
preferences: RepresentationPreferences;
|
||||
/**
|
||||
* Conditions the resource must fulfill for a valid operation.
|
||||
*/
|
||||
conditions?: Conditions;
|
||||
/**
|
||||
* This value will be set if the Operation was authorized by an Authorizer.
|
||||
*/
|
||||
|
||||
@@ -36,7 +36,7 @@ export class PatchOperationHandler extends OperationHandler {
|
||||
this.logger.warn('No Content-Type header specified on PATCH request');
|
||||
throw new BadRequestHttpError('No Content-Type header specified on PATCH request');
|
||||
}
|
||||
await this.store.modifyResource(input.target, input.body as Patch);
|
||||
await this.store.modifyResource(input.target, input.body as Patch, input.conditions);
|
||||
return new ResetResponseDescription();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export class PostOperationHandler extends OperationHandler {
|
||||
this.logger.warn('No Content-Type header specified on POST request');
|
||||
throw new BadRequestHttpError('No Content-Type header specified on POST request');
|
||||
}
|
||||
const identifier = await this.store.addResource(input.target, input.body);
|
||||
const identifier = await this.store.addResource(input.target, input.body, input.conditions);
|
||||
return new CreatedResponseDescription(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export class PutOperationHandler extends OperationHandler {
|
||||
this.logger.warn('No Content-Type header specified on PUT request');
|
||||
throw new BadRequestHttpError('No Content-Type header specified on PUT request');
|
||||
}
|
||||
await this.store.setRepresentation(input.target, input.body);
|
||||
await this.store.setRepresentation(input.target, input.body, input.conditions);
|
||||
return new ResetResponseDescription();
|
||||
}
|
||||
}
|
||||
|
||||
69
src/storage/BasicConditions.ts
Normal file
69
src/storage/BasicConditions.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { RepresentationMetadata } from '../ldp/representation/RepresentationMetadata';
|
||||
import { DC } from '../util/Vocabularies';
|
||||
import { getETag } from './Conditions';
|
||||
import type { Conditions } from './Conditions';
|
||||
|
||||
export interface BasicConditionsOptions {
|
||||
matchesETag?: string[];
|
||||
notMatchesETag?: string[];
|
||||
modifiedSince?: Date;
|
||||
unmodifiedSince?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores all the relevant Conditions values and matches them based on RFC7232.
|
||||
*/
|
||||
export class BasicConditions implements Conditions {
|
||||
public readonly matchesETag?: string[];
|
||||
public readonly notMatchesETag?: string[];
|
||||
public readonly modifiedSince?: Date;
|
||||
public readonly unmodifiedSince?: Date;
|
||||
|
||||
public constructor(options: BasicConditionsOptions) {
|
||||
this.matchesETag = options.matchesETag;
|
||||
this.notMatchesETag = options.notMatchesETag;
|
||||
this.modifiedSince = options.modifiedSince;
|
||||
this.unmodifiedSince = options.unmodifiedSince;
|
||||
}
|
||||
|
||||
public matchesMetadata(metadata?: RepresentationMetadata): boolean {
|
||||
if (!metadata) {
|
||||
// RFC7232: ...If-Match... If the field-value is "*", the condition is false if the origin server
|
||||
// does not have a current representation for the target resource.
|
||||
return !this.matchesETag?.includes('*');
|
||||
}
|
||||
|
||||
const modified = metadata.get(DC.terms.modified);
|
||||
const modifiedDate = modified ? new Date(modified.value) : undefined;
|
||||
const etag = getETag(metadata);
|
||||
return this.matches(etag, modifiedDate);
|
||||
}
|
||||
|
||||
public matches(eTag?: string, lastModified?: Date): boolean {
|
||||
// RFC7232: ...If-None-Match... If the field-value is "*", the condition is false if the origin server
|
||||
// has a current representation for the target resource.
|
||||
if (this.notMatchesETag?.includes('*')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (eTag) {
|
||||
if (this.matchesETag && !this.matchesETag.includes(eTag) && !this.matchesETag.includes('*')) {
|
||||
return false;
|
||||
}
|
||||
if (this.notMatchesETag?.includes(eTag)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastModified) {
|
||||
if (this.modifiedSince && lastModified < this.modifiedSince) {
|
||||
return false;
|
||||
}
|
||||
if (this.unmodifiedSince && lastModified > this.unmodifiedSince) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,13 @@ export interface Conditions {
|
||||
|
||||
/**
|
||||
* Checks validity based on the given metadata.
|
||||
* @param metadata - Metadata of the representation.
|
||||
* @param metadata - Metadata of the representation. Undefined if the resource does not exist.
|
||||
*/
|
||||
matchesMetadata: (metadata: RepresentationMetadata) => boolean;
|
||||
matchesMetadata: (metadata?: RepresentationMetadata) => boolean;
|
||||
/**
|
||||
* Checks validity based on the given ETag and/or date.
|
||||
* This function assumes the resource being checked exists.
|
||||
* If not, the `matchesMetadata` function should be used.
|
||||
* @param eTag - Condition based on ETag.
|
||||
* @param lastModified - Condition based on last modified date.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user