Merge branch 'main' into versions/next-major

This commit is contained in:
Joachim Van Herwegen
2023-07-25 09:43:15 +02:00
39 changed files with 801 additions and 242 deletions

View File

@@ -50,10 +50,14 @@ export class AllowAcceptHeaderWriter extends MetadataWriter {
// POST is only allowed on containers.
// Metadata only has the resource URI in case it has resource metadata.
if (this.isPostAllowed(metadata)) {
if (!this.isPostAllowed(metadata)) {
allowedMethods.delete('POST');
}
if (!this.isPutAllowed(metadata)) {
allowedMethods.delete('PUT');
}
if (!this.isDeleteAllowed(metadata)) {
allowedMethods.delete('DELETE');
}
@@ -76,7 +80,14 @@ export class AllowAcceptHeaderWriter extends MetadataWriter {
* otherwise it is just a blank node.
*/
private isPostAllowed(metadata: RepresentationMetadata): boolean {
return metadata.has(RDF.terms.type, LDP.terms.Resource) && !isContainerPath(metadata.identifier.value);
return !metadata.has(RDF.terms.type, LDP.terms.Resource) || isContainerPath(metadata.identifier.value);
}
/**
* PUT is not allowed on existing containers.
*/
private isPutAllowed(metadata: RepresentationMetadata): boolean {
return !metadata.has(RDF.terms.type, LDP.terms.Resource) || !isContainerPath(metadata.identifier.value);
}
/**

View File

@@ -1,6 +1,7 @@
import { OkResponseDescription } from '../../http/output/response/OkResponseDescription';
import type { ResponseDescription } from '../../http/output/response/ResponseDescription';
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import type { ResourceStore } from '../../storage/ResourceStore';
import { INTERNAL_QUADS } from '../../util/ContentTypes';
import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError';
@@ -32,7 +33,7 @@ export class StorageDescriptionHandler extends OperationHttpHandler {
if (method !== 'GET') {
throw new MethodNotAllowedHttpError([ method ], `Only GET requests can target the storage description.`);
}
const container = { path: ensureTrailingSlash(target.path.slice(0, -this.path.length)) };
const container = this.getStorageIdentifier(target);
const representation = await this.store.getRepresentation(container, {});
representation.data.destroy();
if (!representation.metadata.has(RDF.terms.type, PIM.terms.Storage)) {
@@ -43,10 +44,17 @@ export class StorageDescriptionHandler extends OperationHttpHandler {
}
public async handle({ operation: { target }}: OperationHttpHandlerInput): Promise<ResponseDescription> {
const quads = await this.describer.handle(target);
const quads = await this.describer.handle(this.getStorageIdentifier(target));
const representation = new BasicRepresentation(quads, INTERNAL_QUADS);
return new OkResponseDescription(representation.metadata, representation.data);
}
/**
* Determine the identifier of the root storage based on the identifier of the root storage description resource.
*/
protected getStorageIdentifier(descriptionIdentifier: ResourceIdentifier): ResourceIdentifier {
return { path: ensureTrailingSlash(descriptionIdentifier.path.slice(0, -this.path.length)) };
}
}

View File

@@ -14,7 +14,7 @@ export interface WebhookChannel2023 extends NotificationChannel {
/**
* The "WebHookChannel2023" type.
*/
type: typeof NOTIFY.WebHookChannel2023;
type: typeof NOTIFY.WebhookChannel2023;
/**
* Where the notifications have to be sent.
*/
@@ -22,7 +22,7 @@ export interface WebhookChannel2023 extends NotificationChannel {
}
export function isWebHook2023Channel(channel: NotificationChannel): channel is WebhookChannel2023 {
return channel.type === NOTIFY.WebHookChannel2023;
return channel.type === NOTIFY.WebhookChannel2023;
}
/**
@@ -47,7 +47,7 @@ export class WebhookChannel2023Type extends BaseChannelType {
*/
public constructor(route: InteractionRoute, webIdRoute: InteractionRoute, stateHandler: StateHandler,
features?: string[]) {
super(NOTIFY.terms.WebHookChannel2023,
super(NOTIFY.terms.WebhookChannel2023,
route,
features,
[{ path: NOTIFY.sendTo, minCount: 1, maxCount: 1 }]);
@@ -62,7 +62,7 @@ export class WebhookChannel2023Type extends BaseChannelType {
return {
...channel,
type: NOTIFY.WebHookChannel2023,
type: NOTIFY.WebhookChannel2023,
sendTo: sendTo.value,
};
}

View File

@@ -140,6 +140,9 @@ const mediaRange = new RegExp(`${tchar.source}+/${tchar.source}+`, 'u');
* Replaces all double quoted strings in the input string with `"0"`, `"1"`, etc.
* @param input - The Accept header string.
*
* @throws {@link BadRequestHttpError}
* Thrown if invalid characters are detected in a quoted string.
*
* @returns The transformed string and a map with keys `"0"`, etc. and values the original string that was there.
*/
export function transformQuotedStrings(input: string): { result: string; replacements: Record<string, string> } {
@@ -163,6 +166,8 @@ export function transformQuotedStrings(input: string): { result: string; replace
* Splits the input string on commas, trims all parts and filters out empty ones.
*
* @param input - Input header string.
*
* @returns An array of trimmed strings.
*/
export function splitAndClean(input: string): string[] {
return input.split(',')
@@ -175,44 +180,67 @@ export function splitAndClean(input: string): string[] {
*
* @param qvalue - Input qvalue string (so "q=....").
*
* @throws {@link BadRequestHttpError}
* Thrown on invalid syntax.
* @returns true if q value is valid, false otherwise.
*/
function testQValue(qvalue: string): void {
if (!/^(?:(?:0(?:\.\d{0,3})?)|(?:1(?:\.0{0,3})?))$/u.test(qvalue)) {
logger.warn(`Invalid q value: ${qvalue}`);
throw new BadRequestHttpError(
`Invalid q value: ${qvalue} does not match ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3("0") ] ).`,
);
function isValidQValue(qvalue: string): boolean {
return /^(?:(?:0(?:\.\d{0,3})?)|(?:1(?:\.0{0,3})?))$/u.test(qvalue);
}
/**
* Converts a qvalue to a number.
* Returns 1 if the value is not a valid number or 1 if it is more than 1.
* Returns 0 if the value is negative.
* Otherwise, the parsed value is returned.
*
* @param qvalue - Value to convert.
*/
function parseQValue(qvalue: string): number {
const result = Number(qvalue);
if (Number.isNaN(result) || result >= 1) {
return 1;
}
if (result < 0) {
return 0;
}
return result;
}
/**
* Logs a warning to indicate there was an invalid value.
* Throws a {@link BadRequestHttpError} in case `strict` is `true`.
*
* @param message - Message to log and potentially put in the error.
* @param strict - `true` if an error needs to be thrown.
*/
function handleInvalidValue(message: string, strict: boolean): void | never {
logger.warn(message);
if (strict) {
throw new BadRequestHttpError(message);
}
}
/**
* Parses a list of split parameters and checks their validity.
* Parses a list of split parameters and checks their validity. Parameters with invalid
* syntax are ignored and not returned.
*
* @param parameters - A list of split parameters (token [ "=" ( token / quoted-string ) ])
* @param replacements - The double quoted strings that need to be replaced.
*
*
* @throws {@link BadRequestHttpError}
* Thrown on invalid parameter syntax.
* @param strict - Determines if invalid values throw errors (`true`) or log warnings (`false`). Defaults to `false`.
*
* @returns An array of name/value objects corresponding to the parameters.
*/
export function parseParameters(parameters: string[], replacements: Record<string, string>):
export function parseParameters(parameters: string[], replacements: Record<string, string>, strict = false):
{ name: string; value: string }[] {
return parameters.map((param): { name: string; value: string } => {
return parameters.reduce<{ name: string; value: string }[]>((acc, param): { name: string; value: string }[] => {
const [ name, rawValue ] = param.split('=').map((str): string => str.trim());
// Test replaced string for easier check
// parameter = token "=" ( token / quoted-string )
// second part is optional for certain parameters
if (!(token.test(name) && (!rawValue || /^"\d+"$/u.test(rawValue) || token.test(rawValue)))) {
logger.warn(`Invalid parameter value: ${name}=${replacements[rawValue] || rawValue}`);
throw new BadRequestHttpError(
`Invalid parameter value: ${name}=${replacements[rawValue] || rawValue} ` +
`does not match (token ( "=" ( token / quoted-string ))?). `,
);
handleInvalidValue(`Invalid parameter value: ${name}=${replacements[rawValue] || rawValue} ` +
`does not match (token ( "=" ( token / quoted-string ))?). `, strict);
return acc;
}
let value = rawValue;
@@ -220,8 +248,9 @@ export function parseParameters(parameters: string[], replacements: Record<strin
value = replacements[rawValue];
}
return { name, value };
});
acc.push({ name, value });
return acc;
}, []);
}
/**
@@ -229,24 +258,24 @@ export function parseParameters(parameters: string[], replacements: Record<strin
* For every parameter value that is a double quoted string,
* we check if it is a key in the replacements map.
* If yes the value from the map gets inserted instead.
* Invalid q values and parameter values are ignored and not returned.
*
* @param part - A string corresponding to a media range and its corresponding parameters.
* @param replacements - The double quoted strings that need to be replaced.
* @param strict - Determines if invalid values throw errors (`true`) or log warnings (`false`). Defaults to `false`.
*
* @throws {@link BadRequestHttpError}
* Thrown on invalid type, qvalue or parameter syntax.
*
* @returns {@link Accept} object corresponding to the header string.
* @returns {@link Accept | undefined} object corresponding to the header string, or
* undefined if an invalid type or sub-type is detected.
*/
function parseAcceptPart(part: string, replacements: Record<string, string>): Accept {
function parseAcceptPart(part: string, replacements: Record<string, string>, strict: boolean): Accept | undefined {
const [ range, ...parameters ] = part.split(';').map((param): string => param.trim());
// No reason to test differently for * since we don't check if the type exists
if (!mediaRange.test(range)) {
logger.warn(`Invalid Accept range: ${range}`);
throw new BadRequestHttpError(
`Invalid Accept range: ${range} does not match ( "*/*" / ( token "/" "*" ) / ( token "/" token ) )`,
handleInvalidValue(
`Invalid Accept range: ${range} does not match ( "*/*" / ( token "/" "*" ) / ( token "/" token ) )`, strict,
);
return;
}
let weight = 1;
@@ -258,13 +287,16 @@ function parseAcceptPart(part: string, replacements: Record<string, string>): Ac
if (name === 'q') {
// Extension parameters appear after the q value
map = extensionParams;
testQValue(value);
weight = Number.parseFloat(value);
if (!isValidQValue(value)) {
handleInvalidValue(`Invalid q value for range ${range}: ${value
} does not match ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3("0") ] ).`, strict);
}
weight = parseQValue(value);
} else {
if (!value && map !== extensionParams) {
logger.warn(`Invalid Accept parameter ${name}`);
throw new BadRequestHttpError(`Invalid Accept parameter ${name}: ` +
`Accept parameter values are not optional when preceding the q value`);
handleInvalidValue(`Invalid Accept parameter ${name}: ` +
`Accept parameter values are not optional when preceding the q value`, strict);
return;
}
map[name] = value || '';
}
@@ -282,14 +314,13 @@ function parseAcceptPart(part: string, replacements: Record<string, string>): Ac
/**
* Parses an Accept-* header where each part is only a value and a weight, so roughly /.*(q=.*)?/ separated by commas.
* The returned weights default to 1 if no q value is found or the q value is invalid.
* @param input - Input header string.
*
* @throws {@link BadRequestHttpError}
* Thrown on invalid qvalue syntax.
* @param strict - Determines if invalid values throw errors (`true`) or log warnings (`false`). Defaults to `false`.
*
* @returns An array of ranges and weights.
*/
function parseNoParameters(input: string): AcceptHeader[] {
function parseNoParameters(input: string, strict = false): AcceptHeader[] {
const parts = splitAndClean(input);
return parts.map((part): AcceptHeader => {
@@ -297,12 +328,15 @@ function parseNoParameters(input: string): AcceptHeader[] {
const result = { range, weight: 1 };
if (qvalue) {
if (!qvalue.startsWith('q=')) {
logger.warn(`Only q parameters are allowed in ${input}`);
throw new BadRequestHttpError(`Only q parameters are allowed in ${input}`);
handleInvalidValue(`Only q parameters are allowed in ${input}`, strict);
return result;
}
const val = qvalue.slice(2);
testQValue(val);
result.weight = Number.parseFloat(val);
if (!isValidQValue(val)) {
handleInvalidValue(`Invalid q value for range ${range}: ${val
} does not match ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3("0") ] ).`, strict);
}
result.weight = parseQValue(val);
}
return result;
}).sort((left, right): number => right.weight - left.weight);
@@ -314,17 +348,25 @@ function parseNoParameters(input: string): AcceptHeader[] {
* Parses an Accept header string.
*
* @param input - The Accept header string.
* @param strict - Determines if invalid values throw errors (`true`) or log warnings (`false`). Defaults to `false`.
*
* @throws {@link BadRequestHttpError}
* Thrown on invalid header syntax.
*
* @returns An array of {@link Accept} objects, sorted by weight.
* @returns An array of {@link Accept} objects, sorted by weight. Accept parts
* with invalid syntax are ignored and removed from the returned array.
*/
export function parseAccept(input: string): Accept[] {
export function parseAccept(input: string, strict = false): Accept[] {
// Quoted strings could prevent split from having correct results
const { result, replacements } = transformQuotedStrings(input);
return splitAndClean(result)
.map((part): Accept => parseAcceptPart(part, replacements))
.reduce<Accept[]>((acc, part): Accept[] => {
const partOrUndef = parseAcceptPart(part, replacements, strict);
if (partOrUndef !== undefined) {
acc.push(partOrUndef);
}
return acc;
}, [])
.sort((left, right): number => right.weight - left.weight);
}
@@ -332,70 +374,65 @@ export function parseAccept(input: string): Accept[] {
* Parses an Accept-Charset header string.
*
* @param input - The Accept-Charset header string.
* @param strict - Determines if invalid values throw errors (`true`) or log warnings (`false`). Defaults to `false`.
*
* @throws {@link BadRequestHttpError}
* Thrown on invalid header syntax.
*
* @returns An array of {@link AcceptCharset} objects, sorted by weight.
* @returns An array of {@link AcceptCharset} objects, sorted by weight. Invalid ranges
* are ignored and not returned.
*/
export function parseAcceptCharset(input: string): AcceptCharset[] {
export function parseAcceptCharset(input: string, strict = false): AcceptCharset[] {
const results = parseNoParameters(input);
results.forEach((result): void => {
return results.filter((result): boolean => {
if (!token.test(result.range)) {
logger.warn(`Invalid Accept-Charset range: ${result.range}`);
throw new BadRequestHttpError(
`Invalid Accept-Charset range: ${result.range} does not match (content-coding / "identity" / "*")`,
handleInvalidValue(
`Invalid Accept-Charset range: ${result.range} does not match (content-coding / "identity" / "*")`, strict,
);
return false;
}
return true;
});
return results;
}
/**
* Parses an Accept-Encoding header string.
*
* @param input - The Accept-Encoding header string.
* @param strict - Determines if invalid values throw errors (`true`) or log warnings (`false`). Defaults to `false`.
*
* @throws {@link BadRequestHttpError}
* Thrown on invalid header syntax.
*
* @returns An array of {@link AcceptEncoding} objects, sorted by weight.
* @returns An array of {@link AcceptEncoding} objects, sorted by weight. Invalid ranges
* are ignored and not returned.
*/
export function parseAcceptEncoding(input: string): AcceptEncoding[] {
export function parseAcceptEncoding(input: string, strict = false): AcceptEncoding[] {
const results = parseNoParameters(input);
results.forEach((result): void => {
return results.filter((result): boolean => {
if (!token.test(result.range)) {
logger.warn(`Invalid Accept-Encoding range: ${result.range}`);
throw new BadRequestHttpError(`Invalid Accept-Encoding range: ${result.range} does not match (charset / "*")`);
handleInvalidValue(`Invalid Accept-Encoding range: ${result.range} does not match (charset / "*")`, strict);
return false;
}
return true;
});
return results;
}
/**
* Parses an Accept-Language header string.
*
* @param input - The Accept-Language header string.
* @param strict - Determines if invalid values throw errors (`true`) or log warnings (`false`). Defaults to `false`.
*
* @throws {@link BadRequestHttpError}
* Thrown on invalid header syntax.
*
* @returns An array of {@link AcceptLanguage} objects, sorted by weight.
* @returns An array of {@link AcceptLanguage} objects, sorted by weight. Invalid ranges
* are ignored and not returned.
*/
export function parseAcceptLanguage(input: string): AcceptLanguage[] {
export function parseAcceptLanguage(input: string, strict = false): AcceptLanguage[] {
const results = parseNoParameters(input);
results.forEach((result): void => {
return results.filter((result): boolean => {
// (1*8ALPHA *("-" 1*8alphanum)) / "*"
if (result.range !== '*' && !/^[a-zA-Z]{1,8}(?:-[a-zA-Z0-9]{1,8})*$/u.test(result.range)) {
logger.warn(
`Invalid Accept-Language range: ${result.range}`,
);
throw new BadRequestHttpError(
`Invalid Accept-Language range: ${result.range} does not match ((1*8ALPHA *("-" 1*8alphanum)) / "*")`,
handleInvalidValue(
`Invalid Accept-Language range: ${result.range} does not match ((1*8ALPHA *("-" 1*8alphanum)) / "*")`, strict,
);
return false;
}
return true;
});
return results;
}
// eslint-disable-next-line max-len
@@ -405,24 +442,21 @@ const rfc1123Date = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|
* Parses an Accept-DateTime header string.
*
* @param input - The Accept-DateTime header string.
* @param strict - Determines if invalid values throw errors (`true`) or log warnings (`false`). Defaults to `false`.
*
* @returns An array with a single {@link AcceptDatetime} object.
* @returns An array with a single {@link AcceptDatetime} object,
* or an empty array if a range in an invalid format is detected.
*/
export function parseAcceptDateTime(input: string): AcceptDatetime[] {
const results: AcceptDatetime[] = [];
export function parseAcceptDateTime(input: string, strict = false): AcceptDatetime[] {
const range = input.trim();
if (range) {
if (!rfc1123Date.test(range)) {
logger.warn(
`Invalid Accept-DateTime range: ${range}`,
);
throw new BadRequestHttpError(
`Invalid Accept-DateTime range: ${range} does not match the RFC1123 format`,
);
}
results.push({ range, weight: 1 });
if (!range) {
return [];
}
return results;
if (!rfc1123Date.test(range)) {
handleInvalidValue(`Invalid Accept-DateTime range: ${range} does not match the RFC1123 format`, strict);
return [];
}
return [{ range, weight: 1 }];
}
/**

View File

@@ -155,16 +155,31 @@ export function toCanonicalUriPath(path: string): string {
encodeURIComponent(decodeURIComponent(part)));
}
// Characters not allowed in a Windows file path
const forbiddenSymbols = {
'<': '%3C',
'>': '%3E',
':': '%3A',
'"': '%22',
'|': '%7C',
'?': '%3F',
// `*` does not get converted by `encodeUriComponent`
'*': '%2A',
} as const;
const forbiddenRegex = new RegExp(`[${Object.keys(forbiddenSymbols).join('')}]`, 'ug');
/**
* This function is used when converting a URI to a file path. Decodes all components of a URI path,
* with the exception of encoded slash characters, as this would lead to unexpected file locations
* being targeted (resulting in erroneous behaviour of the file based backend).
* Characters that would result in an illegal file path remain percent encoded.
*
* @param path - The path to decode the URI path components of.
* @returns A decoded copy of the provided URI path (ignoring encoded slash characters).
*/
export function decodeUriPathComponents(path: string): string {
return transformPathComponents(path, decodeURIComponent);
return transformPathComponents(path, (part): string => decodeURIComponent(part)
// The characters replaced below result in illegal Windows file paths so need to be encoded
.replace(forbiddenRegex, (val): string => forbiddenSymbols[val as keyof typeof forbiddenSymbols]));
}
/**

View File

@@ -210,7 +210,7 @@ export const NOTIFY = createVocabulary('http://www.w3.org/ns/solid/notifications
'topic',
'webhookAuth',
'WebHookChannel2023',
'WebhookChannel2023',
'WebSocketChannel2023',
);