diff --git a/config/http/middleware/handlers/cors.json b/config/http/middleware/handlers/cors.json index e7ca29330..18e50ab22 100644 --- a/config/http/middleware/handlers/cors.json +++ b/config/http/middleware/handlers/cors.json @@ -17,6 +17,8 @@ "options_credentials": true, "options_exposedHeaders": [ "Accept-Patch", + "ETag", + "Last-Modified", "Link", "Location", "MS-Author-Via", diff --git a/config/ldp/metadata-writer/default.json b/config/ldp/metadata-writer/default.json index e4dd301d3..579cb32a3 100644 --- a/config/ldp/metadata-writer/default.json +++ b/config/ldp/metadata-writer/default.json @@ -4,6 +4,7 @@ "files-scs:config/ldp/metadata-writer/writers/constant.json", "files-scs:config/ldp/metadata-writer/writers/link-rel.json", "files-scs:config/ldp/metadata-writer/writers/mapped.json", + "files-scs:config/ldp/metadata-writer/writers/modified.json", "files-scs:config/ldp/metadata-writer/writers/wac-allow.json", "files-scs:config/ldp/metadata-writer/writers/www-auth.json" ], @@ -15,6 +16,7 @@ "handlers": [ { "@id": "urn:solid-server:default:MetadataWriter_Constant" }, { "@id": "urn:solid-server:default:MetadataWriter_Mapped" }, + { "@id": "urn:solid-server:default:MetadataWriter_Modified" }, { "@id": "urn:solid-server:default:MetadataWriter_LinkRel" }, { "@id": "urn:solid-server:default:MetadataWriter_WacAllow" }, { "@id": "urn:solid-server:default:MetadataWriter_WwwAuth" } diff --git a/config/ldp/metadata-writer/writers/modified.json b/config/ldp/metadata-writer/writers/modified.json new file mode 100644 index 000000000..16cf9e618 --- /dev/null +++ b/config/ldp/metadata-writer/writers/modified.json @@ -0,0 +1,10 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Adds the Last-Modified and ETag headers.", + "@id": "urn:solid-server:default:MetadataWriter_Modified", + "@type": "ModifiedMetadataWriter" + } + ] +} diff --git a/src/index.ts b/src/index.ts index 7d49359b4..692d5a18d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -91,6 +91,7 @@ export * from './ldp/http/metadata/LinkTypeParser'; export * from './ldp/http/metadata/MappedMetadataWriter'; export * from './ldp/http/metadata/MetadataParser'; export * from './ldp/http/metadata/MetadataWriter'; +export * from './ldp/http/metadata/ModifiedMetadataWriter'; export * from './ldp/http/metadata/SlugParser'; export * from './ldp/http/metadata/WacAllowMetadataWriter'; export * from './ldp/http/metadata/WwwAuthMetadataWriter'; diff --git a/src/ldp/http/metadata/ModifiedMetadataWriter.ts b/src/ldp/http/metadata/ModifiedMetadataWriter.ts new file mode 100644 index 000000000..93504c837 --- /dev/null +++ b/src/ldp/http/metadata/ModifiedMetadataWriter.ts @@ -0,0 +1,23 @@ +import type { HttpResponse } from '../../../server/HttpResponse'; +import { getETag } from '../../../storage/Conditions'; +import { addHeader } from '../../../util/HeaderUtil'; +import { DC } from '../../../util/Vocabularies'; +import type { RepresentationMetadata } from '../../representation/RepresentationMetadata'; +import { MetadataWriter } from './MetadataWriter'; + +/** + * A {@link MetadataWriter} that generates all the necessary headers related to the modification date of a resource. + */ +export class ModifiedMetadataWriter extends MetadataWriter { + public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise { + const modified = input.metadata.get(DC.terms.modified); + if (modified) { + const date = new Date(modified.value); + addHeader(input.response, 'Last-Modified', date.toUTCString()); + } + const etag = getETag(input.metadata); + if (etag) { + addHeader(input.response, 'ETag', etag); + } + } +} diff --git a/src/storage/Conditions.ts b/src/storage/Conditions.ts index c5eef7a49..af2b57980 100644 --- a/src/storage/Conditions.ts +++ b/src/storage/Conditions.ts @@ -1,4 +1,5 @@ import type { RepresentationMetadata } from '../ldp/representation/RepresentationMetadata'; +import { DC } from '../util/Vocabularies'; /** * The conditions of an HTTP conditional request. @@ -7,11 +8,11 @@ export interface Conditions { /** * Valid if matching any of the given ETags. */ - matchesEtag: string[]; + matchesETag?: string[]; /** * Valid if not matching any of the given ETags. */ - notMatchesEtag: string[]; + notMatchesETag?: string[]; /** * Valid if modified since the given date. */ @@ -27,9 +28,23 @@ export interface Conditions { */ matchesMetadata: (metadata: RepresentationMetadata) => boolean; /** - * Checks validity based on the given ETag and/org date. + * Checks validity based on the given ETag and/or date. * @param eTag - Condition based on ETag. * @param lastModified - Condition based on last modified date. */ matches: (eTag?: string, lastModified?: Date) => boolean; } + +/** + * Generates an ETag based on the last modified date of a resource. + * @param metadata - Metadata of the resource. + * + * @returns the generated ETag. Undefined if no last modified date was found. + */ +export function getETag(metadata: RepresentationMetadata): string | undefined { + const modified = metadata.get(DC.terms.modified); + if (modified) { + const date = new Date(modified.value); + return `"${date.getTime()}"`; + } +} diff --git a/test/integration/Middleware.test.ts b/test/integration/Middleware.test.ts index bcad1af87..48ef13674 100644 --- a/test/integration/Middleware.test.ts +++ b/test/integration/Middleware.test.ts @@ -99,6 +99,13 @@ describe('An http server with middleware', (): void => { expect(exposed.split(/\s*,\s*/u)).toContain('Accept-Patch'); }); + it('exposes the Last-Modified and ETag headers via CORS.', async(): Promise => { + const res = await request(server).get('/').expect(200); + const exposed = res.header['access-control-expose-headers']; + expect(exposed.split(/\s*,\s*/u)).toContain('ETag'); + expect(exposed.split(/\s*,\s*/u)).toContain('Last-Modified'); + }); + it('exposes the Link header via CORS.', async(): Promise => { const res = await request(server).get('/').expect(200); const exposed = res.header['access-control-expose-headers']; diff --git a/test/unit/ldp/http/metadata/ModifiedMetadataWriter.test.ts b/test/unit/ldp/http/metadata/ModifiedMetadataWriter.test.ts new file mode 100644 index 000000000..b383dd162 --- /dev/null +++ b/test/unit/ldp/http/metadata/ModifiedMetadataWriter.test.ts @@ -0,0 +1,29 @@ +import { createResponse } from 'node-mocks-http'; +import { ModifiedMetadataWriter } from '../../../../../src/ldp/http/metadata/ModifiedMetadataWriter'; +import { RepresentationMetadata } from '../../../../../src/ldp/representation/RepresentationMetadata'; +import type { HttpResponse } from '../../../../../src/server/HttpResponse'; +import { updateModifiedDate } from '../../../../../src/util/ResourceUtil'; +import { DC } from '../../../../../src/util/Vocabularies'; + +describe('A ModifiedMetadataWriter', (): void => { + const writer = new ModifiedMetadataWriter(); + + it('adds the Last-Modified and ETag header if there is dc:modified metadata.', async(): Promise => { + const response = createResponse() as HttpResponse; + const metadata = new RepresentationMetadata(); + updateModifiedDate(metadata); + const dateTime = metadata.get(DC.terms.modified)!.value; + await expect(writer.handle({ response, metadata })).resolves.toBeUndefined(); + expect(response.getHeaders()).toEqual({ + 'last-modified': new Date(dateTime).toUTCString(), + etag: `"${new Date(dateTime).getTime()}"`, + }); + }); + + it('does nothing if there is no matching metadata.', async(): Promise => { + const response = createResponse() as HttpResponse; + const metadata = new RepresentationMetadata(); + await expect(writer.handle({ response, metadata })).resolves.toBeUndefined(); + expect(response.getHeaders()).toEqual({}); + }); +});