From cc1e33239417fe9cd45c07af0e5f03d09c5980a1 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 14 Jul 2021 13:54:46 +0200 Subject: [PATCH] feat: Add IndexRepresentationStore to support index resources --- config/storage/resource-store/dynamic.json | 19 +++-- config/storage/resource-store/file.json | 21 +++--- config/storage/resource-store/memory.json | 21 +++--- config/storage/resource-store/regex.json | 17 +++-- config/storage/resource-store/sparql.json | 29 ++++--- config/util/index/default.json | 5 ++ config/util/index/example.json | 5 ++ src/index.ts | 1 + src/storage/IndexRepresentationStore.ts | 64 ++++++++++++++++ .../storage/IndexRepresentationstore.test.ts | 75 +++++++++++++++++++ 10 files changed, 212 insertions(+), 45 deletions(-) create mode 100644 src/storage/IndexRepresentationStore.ts create mode 100644 test/unit/storage/IndexRepresentationstore.test.ts diff --git a/config/storage/resource-store/dynamic.json b/config/storage/resource-store/dynamic.json index 35601dde3..f36570e2e 100644 --- a/config/storage/resource-store/dynamic.json +++ b/config/storage/resource-store/dynamic.json @@ -14,14 +14,17 @@ "@id": "urn:solid-server:default:ResourceStore", "@type": "MonitoringStore", "MonitoringStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_Locking", - "LockingResourceStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_Patching", - "PatchingStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_Converting", - "RepresentationConvertingStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_Routing", - "comment": "A router rule will be defined when activating dynamic pods." + "@id": "urn:solid-server:default:ResourceStore_Index", + "IndexRepresentationStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_Locking", + "LockingResourceStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_Patching", + "PatchingStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_Converting", + "RepresentationConvertingStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_Routing", + "comment": "A router rule will be defined when activating dynamic pods." + } } } } diff --git a/config/storage/resource-store/file.json b/config/storage/resource-store/file.json index ed1741165..2cfcce1a0 100644 --- a/config/storage/resource-store/file.json +++ b/config/storage/resource-store/file.json @@ -13,15 +13,18 @@ "@id": "urn:solid-server:default:ResourceStore", "@type": "MonitoringStore", "MonitoringStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_Locking", - "LockingResourceStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_Patching", - "PatchingStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_Converting", - "RepresentationConvertingStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_DataAccessor", - "DataAccessorBasedStore:_accessor": { - "@id": "urn:solid-server:default:FileDataAccessor" + "@id": "urn:solid-server:default:ResourceStore_Index", + "IndexRepresentationStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_Locking", + "LockingResourceStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_Patching", + "PatchingStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_Converting", + "RepresentationConvertingStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_DataAccessor", + "DataAccessorBasedStore:_accessor": { + "@id": "urn:solid-server:default:FileDataAccessor" + } } } } diff --git a/config/storage/resource-store/memory.json b/config/storage/resource-store/memory.json index 0801531b8..cdd84fdc3 100644 --- a/config/storage/resource-store/memory.json +++ b/config/storage/resource-store/memory.json @@ -13,15 +13,18 @@ "@id": "urn:solid-server:default:ResourceStore", "@type": "MonitoringStore", "MonitoringStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_Locking", - "LockingResourceStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_Patching", - "PatchingStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_Converting", - "RepresentationConvertingStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_DataAccessor", - "DataAccessorBasedStore:_accessor": { - "@id": "urn:solid-server:default:MemoryDataAccessor" + "@id": "urn:solid-server:default:ResourceStore_Index", + "IndexRepresentationStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_Locking", + "LockingResourceStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_Patching", + "PatchingStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_Converting", + "RepresentationConvertingStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_DataAccessor", + "DataAccessorBasedStore:_accessor": { + "@id": "urn:solid-server:default:MemoryDataAccessor" + } } } } diff --git a/config/storage/resource-store/regex.json b/config/storage/resource-store/regex.json index 0b9ea6533..19290ac19 100644 --- a/config/storage/resource-store/regex.json +++ b/config/storage/resource-store/regex.json @@ -15,13 +15,16 @@ "@id": "urn:solid-server:default:ResourceStore", "@type": "MonitoringStore", "MonitoringStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_Locking", - "LockingResourceStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_Patching", - "PatchingStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_Converting", - "RepresentationConvertingStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_Routing" + "@id": "urn:solid-server:default:ResourceStore_Index", + "IndexRepresentationStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_Locking", + "LockingResourceStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_Patching", + "PatchingStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_Converting", + "RepresentationConvertingStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_Routing" + } } } } diff --git a/config/storage/resource-store/sparql.json b/config/storage/resource-store/sparql.json index 2b4fd5b8b..da64f60fd 100644 --- a/config/storage/resource-store/sparql.json +++ b/config/storage/resource-store/sparql.json @@ -13,18 +13,23 @@ "@id": "urn:solid-server:default:ResourceStore", "@type": "MonitoringStore", "MonitoringStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_Locking", - "LockingResourceStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_Patching", - "PatchingStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_Converting", - "comment": "This makes it so all incoming data is converted to quad objects.", - "RepresentationConvertingStore:_options_inConverter": { "@id": "urn:solid-server:default:RepresentationConverter" }, - "RepresentationConvertingStore:_options_inType": "internal/quads", - "RepresentationConvertingStore:_source": { - "@id": "urn:solid-server:default:ResourceStore_DataAccessor", - "DataAccessorBasedStore:_accessor": { - "@id": "urn:solid-server:default:SparqlDataAccessor" + "@id": "urn:solid-server:default:ResourceStore_Index", + "IndexRepresentationStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_Locking", + "LockingResourceStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_Patching", + "PatchingStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_Converting", + "comment": "This makes it so all incoming data is converted to quad objects.", + "RepresentationConvertingStore:_options_inConverter": { + "@id": "urn:solid-server:default:RepresentationConverter" + }, + "RepresentationConvertingStore:_options_inType": "internal/quads", + "RepresentationConvertingStore:_source": { + "@id": "urn:solid-server:default:ResourceStore_DataAccessor", + "DataAccessorBasedStore:_accessor": { + "@id": "urn:solid-server:default:SparqlDataAccessor" + } } } } diff --git a/config/util/index/default.json b/config/util/index/default.json index a09e86955..b3dcb3535 100644 --- a/config/util/index/default.json +++ b/config/util/index/default.json @@ -1,6 +1,11 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", "@graph": [ + { + "comment": "When a container with an index.html document is accessed, serve that HTML document instead of the container.", + "@id": "urn:solid-server:default:ResourceStore_Index", + "@type": "IndexRepresentationStore" + }, { "comment": "This value can be used to set a custom handler for index files. See the example file.", "@id": "urn:solid-server:default:DefaultUiConverter", diff --git a/config/util/index/example.json b/config/util/index/example.json index d4bba659b..162902767 100644 --- a/config/util/index/example.json +++ b/config/util/index/example.json @@ -1,6 +1,11 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", "@graph": [ + { + "comment": "This an example on how to disable the routing to index.html when accessing a container.", + "@id": "urn:solid-server:default:ResourceStore_Index", + "@type": "PassthroughStore" + }, { "comment": [ "This converter replaces every browser request for text/html with the Databrowser UI (which then in turn loads a Turtle or other representation of the same resource).", diff --git a/src/index.ts b/src/index.ts index d41c459d3..daaf77a53 100644 --- a/src/index.ts +++ b/src/index.ts @@ -265,6 +265,7 @@ export * from './storage/AtomicResourceStore'; export * from './storage/BaseResourceStore'; export * from './storage/Conditions'; export * from './storage/DataAccessorBasedStore'; +export * from './storage/IndexRepresentationStore'; export * from './storage/LockingResourceStore'; export * from './storage/MonitoringStore'; export * from './storage/PassthroughStore'; diff --git a/src/storage/IndexRepresentationStore.ts b/src/storage/IndexRepresentationStore.ts new file mode 100644 index 000000000..928f72e95 --- /dev/null +++ b/src/storage/IndexRepresentationStore.ts @@ -0,0 +1,64 @@ +import assert from 'assert'; +import type { Representation } from '../ldp/representation/Representation'; +import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences'; +import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; +import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; +import { isContainerIdentifier } from '../util/PathUtil'; +import type { Conditions } from './Conditions'; +import { cleanPreferences, matchesMediaType } from './conversion/ConversionUtil'; +import { PassthroughStore } from './PassthroughStore'; +import type { ResourceStore } from './ResourceStore'; + +/** + * Allow containers to have a custom representation. + * The index representation will be returned when the following conditions are fulfilled: + * * The request targets a container. + * * A resource with the given `indexName` exists in the container. (default: "index.html") + * * The highest weighted preference matches the `mediaRange` (default: "text/html") + * Otherwise the request will be passed on to the source store. + * In case the index representation should always be returned when it exists, + * the `mediaRange` should be set to "\*∕\*". + * + * Note: this functionality is not yet part of the specification. Relevant issues are: + * - https://github.com/solid/specification/issues/69 + * - https://github.com/solid/specification/issues/198 + * - https://github.com/solid/specification/issues/109 + * - https://github.com/solid/web-access-control-spec/issues/36 + */ +export class IndexRepresentationStore extends PassthroughStore { + private readonly indexName: string; + private readonly mediaRange: string; + + public constructor(source: ResourceStore, indexName = 'index.html', mediaRange = 'text/html') { + super(source); + assert(/^[\w.-]+$/u.test(indexName), 'Invalid index name'); + this.indexName = indexName; + this.mediaRange = mediaRange; + } + + public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences, + conditions?: Conditions): Promise { + if (isContainerIdentifier(identifier) && this.matchesPreferences(preferences)) { + try { + const indexIdentifier = { path: `${identifier.path}${this.indexName}` }; + return await this.source.getRepresentation(indexIdentifier, preferences, conditions); + } catch (error: unknown) { + if (!NotFoundHttpError.isInstance(error)) { + throw error; + } + } + } + + return this.source.getRepresentation(identifier, preferences, conditions); + } + + /** + * Makes sure the stored media range matches the highest weight preference. + */ + private matchesPreferences(preferences: RepresentationPreferences): boolean { + const cleaned = cleanPreferences(preferences.type); + const max = Math.max(...Object.values(cleaned)); + return Object.entries(cleaned).some(([ range, weight ]): boolean => + matchesMediaType(range, this.mediaRange) && weight === max); + } +} diff --git a/test/unit/storage/IndexRepresentationstore.test.ts b/test/unit/storage/IndexRepresentationstore.test.ts new file mode 100644 index 000000000..9c1d223d7 --- /dev/null +++ b/test/unit/storage/IndexRepresentationstore.test.ts @@ -0,0 +1,75 @@ +import { BasicRepresentation } from '../../../src/ldp/representation/BasicRepresentation'; +import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier'; +import { IndexRepresentationStore } from '../../../src/storage/IndexRepresentationStore'; +import type { ResourceStore } from '../../../src/storage/ResourceStore'; +import { ConflictHttpError } from '../../../src/util/errors/ConflictHttpError'; +import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; +import { readableToString } from '../../../src/util/StreamUtil'; + +describe('An IndexRepresentationStore', (): void => { + const baseUrl = 'http://test.com/'; + const emptyContainer = { path: `${baseUrl}/container/` }; + let source: jest.Mocked; + let store: IndexRepresentationStore; + + beforeEach(async(): Promise => { + source = { + getRepresentation: jest.fn((identifier: ResourceIdentifier): any => { + if (identifier.path === `${baseUrl}index.html`) { + return new BasicRepresentation('index data', 'text/html'); + } + if (identifier.path.endsWith('/')) { + return new BasicRepresentation('container data', 'text/turtle'); + } + throw new NotFoundHttpError(); + }), + } as any; + + store = new IndexRepresentationStore(source); + }); + + it('errors on invalid index names.', async(): Promise => { + expect((): any => new IndexRepresentationStore(source, '../secretContainer/secret.key')) + .toThrow('Invalid index name'); + }); + + it('retrieves the index resource if it exists.', async(): Promise => { + const result = await store.getRepresentation({ path: baseUrl }, {}); + await expect(readableToString(result.data)).resolves.toBe('index data'); + expect(source.getRepresentation).toHaveBeenCalledTimes(1); + }); + + it('errors if a non-404 error was thrown when accessing the index resource.', async(): Promise => { + source.getRepresentation.mockRejectedValueOnce(new ConflictHttpError('conflict!')); + await expect(store.getRepresentation({ path: baseUrl }, {})).rejects.toThrow('conflict!'); + expect(source.getRepresentation).toHaveBeenCalledTimes(1); + }); + + it('requests the usual data if there is no index resource.', async(): Promise => { + const result = await store.getRepresentation(emptyContainer, {}); + await expect(readableToString(result.data)).resolves.toBe('container data'); + expect(source.getRepresentation).toHaveBeenCalledTimes(2); + expect(source.getRepresentation).toHaveBeenCalledWith({ path: `${emptyContainer.path}index.html` }, {}, undefined); + expect(source.getRepresentation).toHaveBeenLastCalledWith(emptyContainer, {}, undefined); + }); + + it('requests the usual data if the index media range is not the most preferred.', async(): Promise => { + const preferences = { type: { 'text/turtle': 0.8, 'text/html': 0.5 }}; + const result = await store.getRepresentation({ path: baseUrl }, preferences); + await expect(readableToString(result.data)).resolves.toBe('container data'); + expect(source.getRepresentation).toHaveBeenCalledTimes(1); + expect(source.getRepresentation).toHaveBeenLastCalledWith({ path: baseUrl }, preferences, undefined); + }); + + it('always returns the index resource if the media range is set to */*.', async(): Promise => { + store = new IndexRepresentationStore(source, 'base.html', '*/*'); + // Mocking because we also change the index name + source.getRepresentation.mockResolvedValueOnce(new BasicRepresentation('index data', 'text/html')); + + const preferences = { type: { 'text/turtle': 0.8, 'text/html': 0.5 }}; + const result = await store.getRepresentation({ path: baseUrl }, preferences); + await expect(readableToString(result.data)).resolves.toBe('index data'); + expect(source.getRepresentation).toHaveBeenCalledTimes(1); + expect(source.getRepresentation).toHaveBeenLastCalledWith({ path: `${baseUrl}base.html` }, preferences, undefined); + }); +});