mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Reject unacceptable content types
This commit is contained in:
parent
c1aa25f314
commit
69ed2e069f
@ -7,7 +7,12 @@ export interface RepresentationPreference {
|
|||||||
*/
|
*/
|
||||||
value: string;
|
value: string;
|
||||||
/**
|
/**
|
||||||
* How important this preference is in a value going from 0 to 1.
|
* How preferred this value is in a number going from 0 to 1.
|
||||||
|
* Follows the quality values rule from RFC 7231:
|
||||||
|
*
|
||||||
|
* "The weight is normalized to a real number in the range 0 through 1,
|
||||||
|
* where 0.001 is the least preferred and 1 is the most preferred; a
|
||||||
|
* value of 0 means "not acceptable"."
|
||||||
*/
|
*/
|
||||||
weight: number;
|
weight: number;
|
||||||
}
|
}
|
||||||
|
@ -2,9 +2,10 @@ import type { Representation } from '../ldp/representation/Representation';
|
|||||||
import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences';
|
import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences';
|
||||||
import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
|
import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
|
||||||
import { getLoggerFor } from '../logging/LogUtil';
|
import { getLoggerFor } from '../logging/LogUtil';
|
||||||
import { matchingMediaType } from '../util/Util';
|
import { InternalServerError } from '../util/errors/InternalServerError';
|
||||||
import type { Conditions } from './Conditions';
|
import type { Conditions } from './Conditions';
|
||||||
import type { RepresentationConverter } from './conversion/RepresentationConverter';
|
import { matchingTypes } from './conversion/ConversionUtil';
|
||||||
|
import type { RepresentationConverter, RepresentationConverterArgs } from './conversion/RepresentationConverter';
|
||||||
import { PassthroughStore } from './PassthroughStore';
|
import { PassthroughStore } from './PassthroughStore';
|
||||||
import type { ResourceStore } from './ResourceStore';
|
import type { ResourceStore } from './ResourceStore';
|
||||||
|
|
||||||
@ -48,11 +49,7 @@ export class RepresentationConvertingStore<T extends ResourceStore = ResourceSto
|
|||||||
public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences,
|
public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences,
|
||||||
conditions?: Conditions): Promise<Representation> {
|
conditions?: Conditions): Promise<Representation> {
|
||||||
const representation = await super.getRepresentation(identifier, preferences, conditions);
|
const representation = await super.getRepresentation(identifier, preferences, conditions);
|
||||||
if (!this.outConverter || this.matchesPreferences(representation, preferences)) {
|
return this.convertRepresentation({ identifier, representation, preferences }, this.outConverter);
|
||||||
return representation;
|
|
||||||
}
|
|
||||||
this.logger.info(`Convert ${identifier.path} from ${representation.metadata.contentType} to ${preferences.type}`);
|
|
||||||
return this.outConverter.handleSafe({ identifier, representation, preferences });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addResource(container: ResourceIdentifier, representation: Representation,
|
public async addResource(container: ResourceIdentifier, representation: Representation,
|
||||||
@ -69,28 +66,46 @@ export class RepresentationConvertingStore<T extends ResourceStore = ResourceSto
|
|||||||
return this.source.setRepresentation(identifier, representation, conditions);
|
return this.source.setRepresentation(identifier, representation, conditions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function that checks if the given representation matches the given preferences.
|
||||||
|
*/
|
||||||
private matchesPreferences(representation: Representation, preferences: RepresentationPreferences): boolean {
|
private matchesPreferences(representation: Representation, preferences: RepresentationPreferences): boolean {
|
||||||
if (!preferences.type) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const { contentType } = representation.metadata;
|
const { contentType } = representation.metadata;
|
||||||
return Boolean(
|
|
||||||
contentType &&
|
if (!contentType) {
|
||||||
preferences.type.some((type): boolean =>
|
throw new InternalServerError('Content-Type is required for data conversion.');
|
||||||
type.weight > 0 &&
|
}
|
||||||
matchingMediaType(type.value, contentType)),
|
|
||||||
);
|
// Check if there is a result if we try to map the preferences to the content-type
|
||||||
|
return matchingTypes(preferences, [ contentType ]).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function that converts a Representation using the given args and converter,
|
||||||
|
* if the conversion is necessary and there is a converter.
|
||||||
|
*/
|
||||||
|
private async convertRepresentation(args: RepresentationConverterArgs, converter?: RepresentationConverter):
|
||||||
|
Promise<Representation> {
|
||||||
|
if (!converter || !args.preferences.type || this.matchesPreferences(args.representation, args.preferences)) {
|
||||||
|
return args.representation;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeStr = args.preferences.type.map((pref): string => `${pref.value};q=${pref.weight}`).join(', ');
|
||||||
|
this.logger.info(`Convert ${args.identifier.path} from ${args.representation.metadata.contentType} to ${typeStr}`);
|
||||||
|
|
||||||
|
return converter.handleSafe(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function that converts an incoming representation if necessary.
|
||||||
|
*/
|
||||||
private async convertInRepresentation(identifier: ResourceIdentifier, representation: Representation):
|
private async convertInRepresentation(identifier: ResourceIdentifier, representation: Representation):
|
||||||
Promise<Representation> {
|
Promise<Representation> {
|
||||||
if (!this.inType) {
|
if (!this.inType) {
|
||||||
return representation;
|
return representation;
|
||||||
}
|
}
|
||||||
const inPreferences: RepresentationPreferences = { type: [{ value: this.inType, weight: 1 }]};
|
const preferences: RepresentationPreferences = { type: [{ value: this.inType, weight: 1 }]};
|
||||||
if (!inPreferences || !this.inConverter || this.matchesPreferences(representation, inPreferences)) {
|
|
||||||
return representation;
|
return this.convertRepresentation({ identifier, representation, preferences }, this.inConverter);
|
||||||
}
|
|
||||||
return this.inConverter.handleSafe({ identifier, representation, preferences: inPreferences });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,26 +1,51 @@
|
|||||||
import type { RepresentationPreference } from '../../ldp/representation/RepresentationPreference';
|
import type { RepresentationPreference } from '../../ldp/representation/RepresentationPreference';
|
||||||
import type { RepresentationPreferences } from '../../ldp/representation/RepresentationPreferences';
|
import type { RepresentationPreferences } from '../../ldp/representation/RepresentationPreferences';
|
||||||
|
import { InternalServerError } from '../../util/errors/InternalServerError';
|
||||||
import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError';
|
import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError';
|
||||||
import { matchingMediaType } from '../../util/Util';
|
import { matchingMediaType } from '../../util/Util';
|
||||||
import type { RepresentationConverterArgs } from './RepresentationConverter';
|
import type { RepresentationConverterArgs } from './RepresentationConverter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters out the media types from the preferred types that correspond to one of the supported types.
|
* Filters media types based on the given preferences.
|
||||||
|
* Based on RFC 7231 - Content negotiation.
|
||||||
|
*
|
||||||
* @param preferences - Preferences for output type.
|
* @param preferences - Preferences for output type.
|
||||||
* @param supported - Types supported by the parser.
|
* @param types - Media types to compare to the preferences.
|
||||||
*
|
*
|
||||||
* @throws UnsupportedHttpError
|
* @throws UnsupportedHttpError
|
||||||
* If the type preferences are undefined.
|
* If the type preferences are undefined or if there are duplicate preferences.
|
||||||
*
|
*
|
||||||
* @returns The filtered list of preferences.
|
* @returns The weighted and filtered list of matching types.
|
||||||
*/
|
*/
|
||||||
export const matchingTypes = (preferences: RepresentationPreferences, supported: string[]):
|
export const matchingTypes = (preferences: RepresentationPreferences, types: string[]):
|
||||||
RepresentationPreference[] => {
|
RepresentationPreference[] => {
|
||||||
if (!Array.isArray(preferences.type)) {
|
if (!Array.isArray(preferences.type)) {
|
||||||
throw new UnsupportedHttpError('Output type required for conversion.');
|
throw new UnsupportedHttpError('Output type required for conversion.');
|
||||||
}
|
}
|
||||||
return preferences.type.filter(({ value, weight }): boolean => weight > 0 &&
|
|
||||||
supported.some((type): boolean => matchingMediaType(value, type)));
|
const prefMap = preferences.type.reduce((map: Record<string, number>, pref): Record<string, number> => {
|
||||||
|
if (map[pref.value]) {
|
||||||
|
throw new UnsupportedHttpError(`Duplicate type preference found: ${pref.value}`);
|
||||||
|
}
|
||||||
|
map[pref.value] = pref.weight;
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// RFC 7231
|
||||||
|
// Media ranges can be overridden by more specific media ranges or
|
||||||
|
// specific media types. If more than one media range applies to a
|
||||||
|
// given type, the most specific reference has precedence.
|
||||||
|
const weightedSupported = types.map((type): RepresentationPreference => {
|
||||||
|
const match = /^([^/]+)\/([^\s;]+)/u.exec(type);
|
||||||
|
if (!match) {
|
||||||
|
throw new InternalServerError(`Unexpected type preference: ${type}`);
|
||||||
|
}
|
||||||
|
const [ , main, sub ] = match;
|
||||||
|
const weight = prefMap[type] ?? prefMap[`${main}/${sub}`] ?? prefMap[`${main}/*`] ?? prefMap['*/*'] ?? 0;
|
||||||
|
return { value: type, weight };
|
||||||
|
});
|
||||||
|
|
||||||
|
return weightedSupported.filter((preference): boolean => preference.weight !== 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -3,6 +3,7 @@ import { RepresentationMetadata } from '../../../src/ldp/representation/Represen
|
|||||||
import type { RepresentationConverter } from '../../../src/storage/conversion/RepresentationConverter';
|
import type { RepresentationConverter } from '../../../src/storage/conversion/RepresentationConverter';
|
||||||
import { RepresentationConvertingStore } from '../../../src/storage/RepresentationConvertingStore';
|
import { RepresentationConvertingStore } from '../../../src/storage/RepresentationConvertingStore';
|
||||||
import type { ResourceStore } from '../../../src/storage/ResourceStore';
|
import type { ResourceStore } from '../../../src/storage/ResourceStore';
|
||||||
|
import { InternalServerError } from '../../../src/util/errors/InternalServerError';
|
||||||
import { CONTENT_TYPE } from '../../../src/util/UriConstants';
|
import { CONTENT_TYPE } from '../../../src/util/UriConstants';
|
||||||
|
|
||||||
describe('A RepresentationConvertingStore', (): void => {
|
describe('A RepresentationConvertingStore', (): void => {
|
||||||
@ -30,7 +31,7 @@ describe('A RepresentationConvertingStore', (): void => {
|
|||||||
|
|
||||||
it('returns the Representation from the source if no changes are required.', async(): Promise<void> => {
|
it('returns the Representation from the source if no changes are required.', async(): Promise<void> => {
|
||||||
const result = await store.getRepresentation({ path: 'path' }, { type: [
|
const result = await store.getRepresentation({ path: 'path' }, { type: [
|
||||||
{ value: 'text/*', weight: 0 }, { value: 'text/turtle', weight: 1 },
|
{ value: 'application/*', weight: 0 }, { value: 'text/turtle', weight: 1 },
|
||||||
]});
|
]});
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
data: 'data',
|
data: 'data',
|
||||||
@ -39,7 +40,9 @@ describe('A RepresentationConvertingStore', (): void => {
|
|||||||
expect(result.metadata.contentType).toEqual('text/turtle');
|
expect(result.metadata.contentType).toEqual('text/turtle');
|
||||||
expect(source.getRepresentation).toHaveBeenCalledTimes(1);
|
expect(source.getRepresentation).toHaveBeenCalledTimes(1);
|
||||||
expect(source.getRepresentation).toHaveBeenLastCalledWith(
|
expect(source.getRepresentation).toHaveBeenLastCalledWith(
|
||||||
{ path: 'path' }, { type: [{ value: 'text/*', weight: 0 }, { value: 'text/turtle', weight: 1 }]}, undefined,
|
{ path: 'path' },
|
||||||
|
{ type: [{ value: 'application/*', weight: 0 }, { value: 'text/turtle', weight: 1 }]},
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
expect(outConverter.handleSafe).toHaveBeenCalledTimes(0);
|
expect(outConverter.handleSafe).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
@ -98,4 +101,11 @@ describe('A RepresentationConvertingStore', (): void => {
|
|||||||
expect(inConverter.handleSafe).toHaveBeenCalledTimes(2);
|
expect(inConverter.handleSafe).toHaveBeenCalledTimes(2);
|
||||||
expect(source.setRepresentation).toHaveBeenLastCalledWith(id, 'inConvert', 'conditions');
|
expect(source.setRepresentation).toHaveBeenLastCalledWith(id, 'inConvert', 'conditions');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws an error if no content-type is provided.', async(): Promise<void> => {
|
||||||
|
metadata.removeAll(CONTENT_TYPE);
|
||||||
|
const id = { path: 'identifier' };
|
||||||
|
|
||||||
|
await expect(store.addResource(id, representation, 'conditions' as any)).rejects.toThrow(InternalServerError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -3,6 +3,8 @@ import { RepresentationMetadata } from '../../../../src/ldp/representation/Repre
|
|||||||
import type { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences';
|
import type { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences';
|
||||||
import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
|
import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
|
||||||
import { checkRequest, matchingTypes } from '../../../../src/storage/conversion/ConversionUtil';
|
import { checkRequest, matchingTypes } from '../../../../src/storage/conversion/ConversionUtil';
|
||||||
|
import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
|
||||||
|
import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError';
|
||||||
|
|
||||||
describe('A ConversionUtil', (): void => {
|
describe('A ConversionUtil', (): void => {
|
||||||
const identifier: ResourceIdentifier = { path: 'path' };
|
const identifier: ResourceIdentifier = { path: 'path' };
|
||||||
@ -38,7 +40,7 @@ describe('A ConversionUtil', (): void => {
|
|||||||
it('succeeds with a valid input and output type.', async(): Promise<void> => {
|
it('succeeds with a valid input and output type.', async(): Promise<void> => {
|
||||||
metadata.contentType = 'a/x';
|
metadata.contentType = 'a/x';
|
||||||
const preferences: RepresentationPreferences = { type: [{ value: 'b/x', weight: 1 }]};
|
const preferences: RepresentationPreferences = { type: [{ value: 'b/x', weight: 1 }]};
|
||||||
expect(checkRequest({ identifier, representation, preferences }, [ '*/*' ], [ '*/*' ]))
|
expect(checkRequest({ identifier, representation, preferences }, [ 'a/x' ], [ 'b/x' ]))
|
||||||
.toBeUndefined();
|
.toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -55,5 +57,19 @@ describe('A ConversionUtil', (): void => {
|
|||||||
[{ value: 'a/x', weight: 1 }, { value: 'b/x', weight: 0.5 }, { value: 'c/x', weight: 0 }]};
|
[{ value: 'a/x', weight: 1 }, { value: 'b/x', weight: 0.5 }, { value: 'c/x', weight: 0 }]};
|
||||||
expect(matchingTypes(preferences, [ 'b/x', 'c/x' ])).toEqual([{ value: 'b/x', weight: 0.5 }]);
|
expect(matchingTypes(preferences, [ 'b/x', 'c/x' ])).toEqual([{ value: 'b/x', weight: 0.5 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('errors if there are duplicate preferences.', async(): Promise<void> => {
|
||||||
|
const preferences: RepresentationPreferences =
|
||||||
|
{ type: [{ value: 'b/x', weight: 1 }, { value: 'b/x', weight: 0 }]};
|
||||||
|
expect((): any => matchingTypes(preferences, [ 'b/x' ]))
|
||||||
|
.toThrow(new UnsupportedHttpError(`Duplicate type preference found: b/x`));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors if there invalid types.', async(): Promise<void> => {
|
||||||
|
const preferences: RepresentationPreferences =
|
||||||
|
{ type: [{ value: 'b/x', weight: 1 }]};
|
||||||
|
expect((): any => matchingTypes(preferences, [ 'noType' ]))
|
||||||
|
.toThrow(new InternalServerError(`Unexpected type preference: noType`));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user