diff --git a/.componentsignore b/.componentsignore index 69b9e7e76d..94cc4b5f58 100644 --- a/.componentsignore +++ b/.componentsignore @@ -23,6 +23,7 @@ "Readonly", "RegExp", "Server", + "SetMultiMap", "Shorthand", "SubscriptionType", "Template", diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 67c7664f3e..9697a9a903 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,6 +6,8 @@ - The server can be configured to use [ACP](https://solidproject.org/TR/acp) instead of WebACL. `config/file-acp.json` is an example of a configuration that uses this authorization scheme instead. +- Support for the new [WebSocket Notification protocol](https://solidproject.org/TR/websocket-subscription-2021) + was added. ### Data migration @@ -22,6 +24,10 @@ The following changes pertain to the imports in the default configs: - All default configurations which had setup disabled have been updated to also disable registration. This is done to prevent configurations with accidental nested storage containers. +- All references to WebSockets have been removed from the `http/middleware` and `http/server-factory` imports. +- A new `http/notifications` set of import options have been added + to determine which notification specification a CSS instance should use. + All default configurations have been updated to use `http/notifications/websockets.json`. The following changes are relevant for v5 custom configs that replaced certain features. @@ -30,12 +36,22 @@ The following changes are relevant for v5 custom configs that replaced certain f - `/app/main/default.json` now imports the above config file. - All files configuring template engines. - Several minor changes due to support ACP. - - `ldp/authorization/*` + - `/ldp/authorization/*` - Resource generation was changed to there is 1 reusable resource generator. - - `init/initializers/*` - - `setup/handlers/setup.json` - - `identity/access/initializers/*` - - `identity/pod/*` + - `/init/initializers/*` + - `/setup/handlers/setup.json` + - `/identity/access/initializers/*` + - `/identity/pod/*` +- Creating an HTTP(S) server is now separate from attaching a handler to it. + - `/http/server-factory/*` +- The WebSocket middleware was moved to the relevant WebSocket configuration. + - `/http/middleware/*` +- Storage description support was added. + - `/http/handler/*` + - `/ldp/metadata-writer/*` +- Notification support was added. + - `/http/handler/*` + - `/notifications/*` ### Interface changes @@ -50,6 +66,7 @@ These changes are relevant if you wrote custom modules for the server that depen - `TemplatedResourcesGenerator` has been renamed to `BaseResourcesGenerator` and has a different interface now. - `CredentialSet` was replaced by a single `Credentials` interface. This impacts all authentication and authorization related classes. +- `HttpServerFactory.startServer` function was renamed to `createServer` and is no longer expected to start the server. ## v5.1.0 diff --git a/config/default.json b/config/default.json index 7449c73595..6cc0d60b1d 100644 --- a/config/default.json +++ b/config/default.json @@ -7,7 +7,7 @@ "css:config/app/variables/default.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/config/dynamic.json b/config/dynamic.json index 045cd9c667..ada10aafcc 100644 --- a/config/dynamic.json +++ b/config/dynamic.json @@ -7,7 +7,7 @@ "css:config/app/variables/default.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/config/example-https-file.json b/config/example-https-file.json index 6cab2b5685..b29378d42a 100644 --- a/config/example-https-file.json +++ b/config/example-https-file.json @@ -7,6 +7,7 @@ "css:config/app/variables/default.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", + "css:config/http/notifications/websockets.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/config/file-acp.json b/config/file-acp.json index e5ba6e6b6b..be953dbc2d 100644 --- a/config/file-acp.json +++ b/config/file-acp.json @@ -7,6 +7,7 @@ "css:config/app/variables/default.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/config/file-no-setup.json b/config/file-no-setup.json index 28632e9492..d56e44cd9f 100644 --- a/config/file-no-setup.json +++ b/config/file-no-setup.json @@ -7,7 +7,7 @@ "css:config/app/variables/default.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/config/file.json b/config/file.json index 8a678b5a9a..c5bf8ee2fb 100644 --- a/config/file.json +++ b/config/file.json @@ -7,7 +7,7 @@ "css:config/app/variables/default.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/config/http/README.md b/config/http/README.md index fb008000f3..a602d2f32e 100644 --- a/config/http/README.md +++ b/config/http/README.md @@ -14,19 +14,26 @@ Sets up all the handlers a request will potentially pass through. A set of handlers that will always be run on all requests to add some metadata and then pass the request along. -* *no-websockets*: The default setup but without the websocket-related metadata. -* *websockets*: The default setup with several handlers. +* *default*: The default setup with several handlers. + +## Notifications + +Determines how notifications should be sent out from the server when resources change. + +* *disabled*: No notifications are sent out. +* *legacy-websocket*: Follows the legacy Solid WebSocket + [specification](https://github.com/solid/solid-spec/blob/master/api-websockets.md). +* *websockets*: Follows the WebSocketSubscription2021 + [specification](https://solidproject.org/TR/websocket-subscription-2021). ## Server-Factory The factory used to create the actual server object. -* *no-websockets*: Only HTTP. -* *websockets*: HTTP and websockets. -* *https-no-websockets*: Only HTTPS. Adds 2 new CLI params to set the key/cert paths. -* *https-websockets*: HTTPS and websockets. Adds 2 new CLI params to set the key/cert paths. -* *https-example*: An example configuration to use HTTPS directly at the server (instead of at a reverse proxy) - by adding the key/cert paths to the config itself. +* *http*: A HTTP server. +* *https*: A HTTPS server. +* *https-no-cli-example*: An example of how to set up an HTTPS server + by defining the key/cert paths directly in the config itself. ## Static diff --git a/config/http/handler/handlers/storage-description.json b/config/http/handler/handlers/storage-description.json index 0ae135f9e2..47a49b0b39 100644 --- a/config/http/handler/handlers/storage-description.json +++ b/config/http/handler/handlers/storage-description.json @@ -2,8 +2,8 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", "@graph": [ { - "comment": "The suffix appended to a storage container to find its description resource.", - "@id": "urn:solid-server:default:variable:storageDescriptionSuffix", + "comment": "The relative path appended to a storage container URL to find its description resource.", + "@id": "urn:solid-server:default:variable:storageDescriptionPath", "valueRaw": ".well-known/solid" }, { @@ -22,7 +22,7 @@ "operationHandler": { "@type": "StorageDescriptionHandler", "store": { "@id": "urn:solid-server:default:ResourceStore" }, - "suffix": { "@id": "urn:solid-server:default:variable:storageDescriptionSuffix" }, + "path": { "@id": "urn:solid-server:default:variable:storageDescriptionPath" }, "converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, "describer": { "@id": "urn:solid-server:default:StorageDescriber" } } diff --git a/config/http/notifications/base/description.json b/config/http/notifications/base/description.json index a7379557d9..19f6506505 100644 --- a/config/http/notifications/base/description.json +++ b/config/http/notifications/base/description.json @@ -14,6 +14,6 @@ "@type": "RelativePathInteractionRoute", "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, "relativePath": "/.notifications/" - }, + } ] } diff --git a/config/http/notifications/websockets.json b/config/http/notifications/websockets.json new file mode 100644 index 0000000000..4d24863362 --- /dev/null +++ b/config/http/notifications/websockets.json @@ -0,0 +1,18 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", + "import": [ + "css:config/http/notifications/base/description.json", + "css:config/http/notifications/base/handler.json", + "css:config/http/notifications/base/listener.json", + "css:config/http/notifications/base/storage.json", + "css:config/http/notifications/websockets/description.json", + "css:config/http/notifications/websockets/handler.json", + "css:config/http/notifications/websockets/http.json", + "css:config/http/notifications/websockets/subscription.json" + ], + "@graph": [ + { + "comment": "All the relevant components are made in the specific imports seen above." + } + ] +} diff --git a/config/http/notifications/websockets/description.json b/config/http/notifications/websockets/description.json new file mode 100644 index 0000000000..2f560b2809 --- /dev/null +++ b/config/http/notifications/websockets/description.json @@ -0,0 +1,18 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", + "@graph": [ + { + "@id": "urn:solid-server:default:StorageDescriber", + "@type": "ArrayUnionHandler", + "handlers": [ + { + "comment": "Handles the storage description triples used for discovery of a WebSocketSubscription2021 endpoint.", + "@type": "NotificationDescriber", + "route": { "@id": "urn:solid-server:default:WebSocket2021Route" }, + "relative": "#websocketNotification", + "type": "http://www.w3.org/ns/solid/notifications#WebSocketSubscription2021" + } + ] + } + ] +} diff --git a/config/http/notifications/websockets/handler.json b/config/http/notifications/websockets/handler.json new file mode 100644 index 0000000000..c9040fbb33 --- /dev/null +++ b/config/http/notifications/websockets/handler.json @@ -0,0 +1,31 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles the generation and serialization of notifications for WebSocketSubscription2021.", + "@id": "urn:solid-server:default:WebSocket2021NotificationHandler", + "@type": "TypedNotificationHandler", + "type": "WebSocketSubscription2021", + "source": { + "@type": "ComposedNotificationHandler", + "generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" }, + "serializer": { "@id": "urn:solid-server:default:BaseNotificationSerializer" }, + "emitter": { "@id": "urn:solid-server:default:WebSocket2021Emitter" } + } + }, + { + "comment": "Emits serialized notifications through WebSockets.", + "@id": "urn:solid-server:default:WebSocket2021Emitter", + "@type": "WebSocket2021Emitter", + "socketMap": { "@id": "urn:solid-server:default:WebSocketMap" } + }, + + { + "@id": "urn:solid-server:default:NotificationHandler", + "@type": "WaterfallHandler", + "handlers": [ + { "@id": "urn:solid-server:default:WebSocket2021NotificationHandler" } + ] + } + ] +} diff --git a/config/http/notifications/websockets/http.json b/config/http/notifications/websockets/http.json new file mode 100644 index 0000000000..3f092eac14 --- /dev/null +++ b/config/http/notifications/websockets/http.json @@ -0,0 +1,46 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Catches newly opened WebSockets and verifies if they belong to a subscription.", + "@id": "urn:solid-server:default:WebSocket2021Listener", + "@type": "WebSocket2021Listener", + "storage": { "@id": "urn:solid-server:default:SubscriptionStorage" }, + "route": { "@id": "urn:solid-server:default:WebSocket2021Route" }, + "handler": { + "@type": "SequenceHandler", + "handlers": [ + { "@id": "urn:solid-server:default:WebSocket2021Storer" }, + { "@id": "urn:solid-server:default:WebSocket2021StateHandler" } + ] + } + }, + { + "comment": "Opened WebSockets will be stored in this Map.", + "@id": "urn:solid-server:default:WebSocketMap", + "@type": "WebSocketMap" + }, + { + "comment": "Stores the opened WebSockets for reuse.", + "@id": "urn:solid-server:default:WebSocket2021Storer", + "@type": "WebSocket2021Storer", + "storage": { "@id": "urn:solid-server:default:SubscriptionStorage" }, + "socketMap": { "@id": "urn:solid-server:default:WebSocketMap" } + }, + { + "comment": "Handles the state feature of a WebSocketSubscription2021 subscription.", + "@id": "urn:solid-server:default:WebSocket2021StateHandler", + "@type": "BaseStateHandler", + "handler": { "@id": "urn:solid-server:default:WebSocket2021NotificationHandler" }, + "storage": { "@id": "urn:solid-server:default:SubscriptionStorage" } + }, + + { + "@id": "urn:solid-server:default:ServerConfigurator", + "@type": "ParallelHandler", + "handlers": [ + { "@id": "urn:solid-server:default:WebSocket2021Listener" } + ] + } + ] +} diff --git a/config/http/notifications/websockets/subscription.json b/config/http/notifications/websockets/subscription.json new file mode 100644 index 0000000000..ce85eee9b7 --- /dev/null +++ b/config/http/notifications/websockets/subscription.json @@ -0,0 +1,41 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles the subscriptions targeting a WebSocketSubscription2021.", + "@id": "urn:solid-server:default:WebSocket2021Subscriber", + "@type": "OperationRouterHandler", + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "allowedMethods": [ "POST" ], + "allowedPathNames": [ "/WebSocketSubscription2021/" ], + "handler": { + "@type": "NotificationSubscriber", + "subscriptionType": { "@id": "urn:solid-server:default:WebSocketSubscription2021" }, + "credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" }, + "permissionReader": { "@id": "urn:solid-server:default:PermissionReader" }, + "authorizer": { "@id": "urn:solid-server:default:Authorizer" } + } + }, + { + "@id": "urn:solid-server:default:WebSocket2021Route", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:NotificationRoute" }, + "relativePath": "/WebSocketSubscription2021/" + }, + { + "comment": "Contains all the metadata relevant for a WebSocketSubscription2021.", + "@id": "urn:solid-server:default:WebSocketSubscription2021", + "@type": "WebSocketSubscription2021", + "storage": { "@id": "urn:solid-server:default:SubscriptionStorage" }, + "route": { "@id": "urn:solid-server:default:WebSocket2021Route" } + }, + + { + "@id": "urn:solid-server:default:NotificationTypeHandler", + "@type": "WaterfallHandler", + "handlers": [ + { "@id": "urn:solid-server:default:WebSocket2021Subscriber" } + ] + } + ] +} diff --git a/config/https-file-cli.json b/config/https-file-cli.json index 55fcfd0ba2..a972f67014 100644 --- a/config/https-file-cli.json +++ b/config/https-file-cli.json @@ -7,7 +7,7 @@ "css:config/app/variables/default.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/https.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/config/ldp/metadata-writer/writers/storage-description.json b/config/ldp/metadata-writer/writers/storage-description.json index 0c463fd6d5..0c8e64ec6a 100644 --- a/config/ldp/metadata-writer/writers/storage-description.json +++ b/config/ldp/metadata-writer/writers/storage-description.json @@ -8,7 +8,7 @@ "targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" }, "store": { "@id": "urn:solid-server:default:ResourceStore" }, - "suffix": { "@id": "urn:solid-server:default:variable:storageDescriptionSuffix" } + "path": { "@id": "urn:solid-server:default:variable:storageDescriptionPath" } } ] } diff --git a/config/memory-subdomains.json b/config/memory-subdomains.json index 5e63e00ce0..5e00d522f0 100644 --- a/config/memory-subdomains.json +++ b/config/memory-subdomains.json @@ -7,7 +7,7 @@ "css:config/app/variables/default.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/config/path-routing.json b/config/path-routing.json index 06a6d1374f..cb1ba63a6f 100644 --- a/config/path-routing.json +++ b/config/path-routing.json @@ -7,7 +7,7 @@ "css:config/app/variables/default.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/config/quota-file.json b/config/quota-file.json index fa0485d783..d07f153eb6 100644 --- a/config/quota-file.json +++ b/config/quota-file.json @@ -7,7 +7,7 @@ "css:config/app/variables/default.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/config/restrict-idp.json b/config/restrict-idp.json index 427287fa53..28448a6304 100644 --- a/config/restrict-idp.json +++ b/config/restrict-idp.json @@ -7,7 +7,7 @@ "css:config/app/variables/default.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/restricted.json", diff --git a/config/sparql-endpoint-no-setup.json b/config/sparql-endpoint-no-setup.json index 675b2e7a04..f09003a25e 100644 --- a/config/sparql-endpoint-no-setup.json +++ b/config/sparql-endpoint-no-setup.json @@ -7,7 +7,7 @@ "css:config/app/variables/default.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/config/sparql-endpoint.json b/config/sparql-endpoint.json index eaddc6fa7d..47bd97c4f1 100644 --- a/config/sparql-endpoint.json +++ b/config/sparql-endpoint.json @@ -7,7 +7,7 @@ "css:config/app/variables/default.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/config/sparql-file-storage.json b/config/sparql-file-storage.json index 9aafcd06d3..dd60b97b12 100644 --- a/config/sparql-file-storage.json +++ b/config/sparql-file-storage.json @@ -7,7 +7,7 @@ "css:config/app/variables/default.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/src/http/output/metadata/StorageDescriptionAdvertiser.ts b/src/http/output/metadata/StorageDescriptionAdvertiser.ts index 0a063e25b4..b9adc557ae 100644 --- a/src/http/output/metadata/StorageDescriptionAdvertiser.ts +++ b/src/http/output/metadata/StorageDescriptionAdvertiser.ts @@ -13,7 +13,7 @@ import { MetadataWriter } from './MetadataWriter'; /** * Adds a link header pointing to the relevant storage description resource. * Recursively checks parent containers until a storage container is found, - * and then appends the provided suffix to determine the storage description resource. + * and then appends the provided relative path to determine the storage description resource. */ export class StorageDescriptionAdvertiser extends MetadataWriter { protected readonly logger = getLoggerFor(this); @@ -21,15 +21,15 @@ export class StorageDescriptionAdvertiser extends MetadataWriter { private readonly targetExtractor: TargetExtractor; private readonly identifierStrategy: IdentifierStrategy; private readonly store: ResourceStore; - private readonly suffix: string; + private readonly path: string; public constructor(targetExtractor: TargetExtractor, identifierStrategy: IdentifierStrategy, store: ResourceStore, - suffix: string) { + path: string) { super(); this.identifierStrategy = identifierStrategy; this.targetExtractor = targetExtractor; this.store = store; - this.suffix = suffix; + this.path = path; } public async handle({ response, metadata }: MetadataWriterInput): Promise { @@ -45,7 +45,7 @@ export class StorageDescriptionAdvertiser extends MetadataWriter { this.logger.error(`Unable to find storage root: ${createErrorMessage(error)}`); return; } - const storageDescription = joinUrl(storageRoot.path, this.suffix); + const storageDescription = joinUrl(storageRoot.path, this.path); addHeader(response, 'Link', `<${storageDescription}>; rel="${SOLID.storageDescription}"`); } diff --git a/src/index.ts b/src/index.ts index 1c196a130c..54f6770fd6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -317,6 +317,15 @@ export * from './server/notifications/generate/StateNotificationGenerator'; export * from './server/notifications/serialize/ConvertingNotificationSerializer'; export * from './server/notifications/serialize/JsonLdNotificationSerializer'; export * from './server/notifications/serialize/NotificationSerializer'; + +// Server/Notifications/WebSocketSubscription2021 +export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Emitter'; +export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Handler'; +export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Listener'; +export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Storer'; +export * from './server/notifications/WebSocketSubscription2021/WebSocketMap'; +export * from './server/notifications/WebSocketSubscription2021/WebSocketSubscription2021'; + // Server/Notifications export * from './server/notifications/ActivityEmitter'; export * from './server/notifications/BaseStateHandler'; diff --git a/src/server/description/StaticStorageDescriber.ts b/src/server/description/StaticStorageDescriber.ts index 638f46ad20..43ee9fc901 100644 --- a/src/server/description/StaticStorageDescriber.ts +++ b/src/server/description/StaticStorageDescriber.ts @@ -9,6 +9,9 @@ import namedNode = DataFactory.namedNode; /** * Adds a fixed set of triples to the storage description resource, * with the resource identifier as subject. + * + * This can be used to add descriptions that a storage always needs to have, + * such as the `<> a pim:Storage` triple. */ export class StaticStorageDescriber extends StorageDescriber { private readonly terms: ReadonlyMap; diff --git a/src/server/description/StorageDescriptionHandler.ts b/src/server/description/StorageDescriptionHandler.ts index d7cad65a44..bfb4cc9b16 100644 --- a/src/server/description/StorageDescriptionHandler.ts +++ b/src/server/description/StorageDescriptionHandler.ts @@ -14,20 +14,20 @@ import type { StorageDescriber } from './StorageDescriber'; /** * Generates the response for GET requests targeting a storage description resource. - * The suffix needs to match the suffix used to generate storage description resources - * and will be used to verify the container it is linked to is an actual storage. + * The input path needs to match the relative path used to generate storage description resources + * and will be used to verify if the container it is linked to is an actual storage. */ export class StorageDescriptionHandler extends OperationHttpHandler { private readonly store: ResourceStore; - private readonly suffix: string; + private readonly path: string; private readonly converter: RepresentationConverter; private readonly describer: StorageDescriber; - public constructor(store: ResourceStore, suffix: string, converter: RepresentationConverter, + public constructor(store: ResourceStore, path: string, converter: RepresentationConverter, describer: StorageDescriber) { super(); this.store = store; - this.suffix = suffix; + this.path = path; this.converter = converter; this.describer = describer; } @@ -36,7 +36,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.suffix.length)) }; + const container = { path: ensureTrailingSlash(target.path.slice(0, -this.path.length)) }; const representation = await this.store.getRepresentation(container, {}); representation.data.destroy(); if (!representation.metadata.has(RDF.terms.type, PIM.terms.Storage)) { diff --git a/src/server/notifications/Notification.ts b/src/server/notifications/Notification.ts index 7844309c00..1894adec2f 100644 --- a/src/server/notifications/Notification.ts +++ b/src/server/notifications/Notification.ts @@ -2,7 +2,7 @@ export const CONTEXT_ACTIVITYSTREAMS = 'https://www.w3.org/ns/activitystreams'; export const CONTEXT_NOTIFICATION = 'https://www.w3.org/ns/solid/notification/v1'; /** - * The minimal expected fields for a Notification + * The minimally expected fields for a Notification * as defined in https://solidproject.org/TR/notifications-protocol#notification-data-model. */ export interface Notification { diff --git a/src/server/notifications/WebSocketSubscription2021/WebSocket2021Emitter.ts b/src/server/notifications/WebSocketSubscription2021/WebSocket2021Emitter.ts new file mode 100644 index 0000000000..efac51cc7a --- /dev/null +++ b/src/server/notifications/WebSocketSubscription2021/WebSocket2021Emitter.ts @@ -0,0 +1,36 @@ +import type { WebSocket } from 'ws'; +import { getLoggerFor } from '../../../logging/LogUtil'; +import type { SetMultiMap } from '../../../util/map/SetMultiMap'; +import { readableToString } from '../../../util/StreamUtil'; +import { NotificationEmitter } from '../NotificationEmitter'; +import type { NotificationEmitterInput } from '../NotificationEmitter'; + +/** + * Emits notifications on WebSocketSubscription2021 subscription. + * Uses the WebSockets found in the provided map. + * The key should be the identifier of the matching subscription. + */ +export class WebSocket2021Emitter extends NotificationEmitter { + protected readonly logger = getLoggerFor(this); + + private readonly socketMap: SetMultiMap; + + public constructor(socketMap: SetMultiMap) { + super(); + + this.socketMap = socketMap; + } + + public async handle({ info, representation }: NotificationEmitterInput): Promise { + // Called as a NotificationEmitter: emit the notification + const webSockets = this.socketMap.get(info.id); + if (webSockets) { + const data = await readableToString(representation.data); + for (const webSocket of webSockets) { + webSocket.send(data); + } + } else { + representation.data.destroy(); + } + } +} diff --git a/src/server/notifications/WebSocketSubscription2021/WebSocket2021Handler.ts b/src/server/notifications/WebSocketSubscription2021/WebSocket2021Handler.ts new file mode 100644 index 0000000000..3d3a076287 --- /dev/null +++ b/src/server/notifications/WebSocketSubscription2021/WebSocket2021Handler.ts @@ -0,0 +1,13 @@ +import type { WebSocket } from 'ws'; +import { AsyncHandler } from '../../../util/handlers/AsyncHandler'; +import type { SubscriptionInfo } from '../SubscriptionStorage'; + +export interface WebSocket2021HandlerInput { + info: SubscriptionInfo; + webSocket: WebSocket; +} + +/** + * A handler that is called when a valid WebSocketSubscription2021 connection has been made. + */ +export abstract class WebSocket2021Handler extends AsyncHandler {} diff --git a/src/server/notifications/WebSocketSubscription2021/WebSocket2021Listener.ts b/src/server/notifications/WebSocketSubscription2021/WebSocket2021Listener.ts new file mode 100644 index 0000000000..9d29e5c75a --- /dev/null +++ b/src/server/notifications/WebSocketSubscription2021/WebSocket2021Listener.ts @@ -0,0 +1,56 @@ +import type { IncomingMessage } from 'http'; +import type { WebSocket } from 'ws'; +import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute'; +import { getLoggerFor } from '../../../logging/LogUtil'; +import { WebSocketServerConfigurator } from '../../WebSocketServerConfigurator'; +import type { SubscriptionStorage } from '../SubscriptionStorage'; +import type { WebSocket2021Handler } from './WebSocket2021Handler'; + +/** + * Listens for WebSocket connections and verifies if they are valid WebSocketSubscription2021 connections, + * in which case its {@link WebSocket2021Handler} will be alerted. + */ +export class WebSocket2021Listener extends WebSocketServerConfigurator { + protected readonly logger = getLoggerFor(this); + + private readonly storage: SubscriptionStorage; + private readonly handler: WebSocket2021Handler; + private readonly path: string; + + public constructor(storage: SubscriptionStorage, handler: WebSocket2021Handler, route: InteractionRoute) { + super(); + this.storage = storage; + this.handler = handler; + this.path = new URL(route.getPath()).pathname; + } + + protected async handleConnection(webSocket: WebSocket, upgradeRequest: IncomingMessage): Promise { + // Base doesn't matter since we just want the path and query parameter + const { pathname, searchParams } = new URL(upgradeRequest.url ?? '', 'http://example.com'); + + if (pathname !== this.path) { + webSocket.send('Unknown WebSocket target.'); + return webSocket.close(); + } + + const auth = searchParams.get('auth'); + + if (!auth) { + webSocket.send('Missing auth parameter from WebSocket URL.'); + return webSocket.close(); + } + + const id = decodeURI(auth); + const info = await this.storage.get(id); + + if (!info) { + // Info not being there implies it has expired + webSocket.send(`Subscription has expired`); + return webSocket.close(); + } + + this.logger.info(`Accepted WebSocket connection listening to changes on ${info.topic}`); + + await this.handler.handleSafe({ info, webSocket }); + } +} diff --git a/src/server/notifications/WebSocketSubscription2021/WebSocket2021Storer.ts b/src/server/notifications/WebSocketSubscription2021/WebSocket2021Storer.ts new file mode 100644 index 0000000000..8c89e3edf3 --- /dev/null +++ b/src/server/notifications/WebSocketSubscription2021/WebSocket2021Storer.ts @@ -0,0 +1,58 @@ +import type { WebSocket } from 'ws'; +import { getLoggerFor } from '../../../logging/LogUtil'; +import type { SetMultiMap } from '../../../util/map/SetMultiMap'; +import { setSafeInterval } from '../../../util/TimerUtil'; +import type { SubscriptionStorage } from '../SubscriptionStorage'; +import type { WebSocket2021HandlerInput } from './WebSocket2021Handler'; +import { WebSocket2021Handler } from './WebSocket2021Handler'; + +/** + * Keeps track of the WebSockets that were opened for a WebSocketSubscription2021 subscription. + * The WebSockets are stored in the map using the identifier of the matching subscription. + * + * `cleanupTimer` defines in minutes how often the stored WebSockets are closed + * if their corresponding subscription has expired. + * Defaults to 60 minutes. + * Open WebSockets will not receive notifications if their subscription expired. + */ +export class WebSocket2021Storer extends WebSocket2021Handler { + protected readonly logger = getLoggerFor(this); + + private readonly storage: SubscriptionStorage; + private readonly socketMap: SetMultiMap; + + public constructor(storage: SubscriptionStorage, socketMap: SetMultiMap, cleanupTimer = 60) { + super(); + this.socketMap = socketMap; + this.storage = storage; + + const timer = setSafeInterval(this.logger, + 'Failed to remove closed WebSockets', + this.closeExpiredSockets.bind(this), + cleanupTimer * 60 * 1000); + timer.unref(); + } + + public async handle({ webSocket, info }: WebSocket2021HandlerInput): Promise { + this.socketMap.add(info.id, webSocket); + webSocket.on('error', (): boolean => this.socketMap.deleteEntry(info.id, webSocket)); + webSocket.on('close', (): boolean => this.socketMap.deleteEntry(info.id, webSocket)); + } + + /** + * Close all WebSockets that are attached to a subscription that no longer exists. + */ + private async closeExpiredSockets(): Promise { + this.logger.debug('Closing expired WebSockets'); + for (const [ id, sockets ] of this.socketMap.entrySets()) { + const result = await this.storage.get(id); + if (!result) { + for (const socket of sockets) { + // Due to the attached listener this also deletes the entries + socket.close(); + } + } + } + this.logger.debug('Finished closing expired WebSockets'); + } +} diff --git a/src/server/notifications/WebSocketSubscription2021/WebSocketMap.ts b/src/server/notifications/WebSocketSubscription2021/WebSocketMap.ts new file mode 100644 index 0000000000..a1871be44a --- /dev/null +++ b/src/server/notifications/WebSocketSubscription2021/WebSocketMap.ts @@ -0,0 +1,10 @@ +import type { WebSocket } from 'ws'; +import type { SingleThreaded } from '../../../init/cluster/SingleThreaded'; +import { WrappedSetMultiMap } from '../../../util/map/WrappedSetMultiMap'; + +/** + * A {@link SetMultiMap} linking identifiers to a set of WebSockets. + * An extension of {@link WrappedSetMultiMap} to make sure Components.js allows us to create this in the config, + * as {@link WrappedSetMultiMap} has a constructor not supported. + */ +export class WebSocketMap extends WrappedSetMultiMap implements SingleThreaded {} diff --git a/src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.ts b/src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.ts new file mode 100644 index 0000000000..8936dfec0e --- /dev/null +++ b/src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.ts @@ -0,0 +1,57 @@ +import { string } from 'yup'; +import type { AccessMap } from '../../../authorization/permissions/Permissions'; +import { AccessMode } from '../../../authorization/permissions/Permissions'; +import { BasicRepresentation } from '../../../http/representation/BasicRepresentation'; +import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute'; +import { getLoggerFor } from '../../../logging/LogUtil'; +import { APPLICATION_LD_JSON } from '../../../util/ContentTypes'; +import { IdentifierSetMultiMap } from '../../../util/map/IdentifierMap'; +import { CONTEXT_NOTIFICATION } from '../Notification'; +import type { Subscription } from '../Subscription'; +import { SUBSCRIBE_SCHEMA } from '../Subscription'; +import type { SubscriptionStorage } from '../SubscriptionStorage'; +import type { SubscriptionResponse, SubscriptionType } from '../SubscriptionType'; + +const type = 'WebSocketSubscription2021'; +const schema = SUBSCRIBE_SCHEMA.shape({ + type: string().required().oneOf([ type ]), +}); + +/** + * The notification subscription type WebSocketSubscription2021 as described in + * https://solidproject.org/TR/websocket-subscription-2021 + * + * Requires read permissions on a resource to be able to receive notifications. + */ +export class WebSocketSubscription2021 implements SubscriptionType { + protected readonly logger = getLoggerFor(this); + + private readonly storage: SubscriptionStorage; + private readonly path: string; + + public readonly type = type; + public readonly schema = schema; + + public constructor(storage: SubscriptionStorage, route: InteractionRoute) { + this.storage = storage; + this.path = route.getPath(); + } + + public async extractModes(subscription: Subscription): Promise { + return new IdentifierSetMultiMap([[{ path: subscription.topic }, AccessMode.read ]]); + } + + public async subscribe(subscription: Subscription): Promise { + const info = this.storage.create(subscription, {}); + await this.storage.add(info); + + const jsonld = { + '@context': [ CONTEXT_NOTIFICATION ], + type: this.type, + source: `ws${this.path.slice('http'.length)}?auth=${encodeURI(info.id)}`, + }; + const response = new BasicRepresentation(JSON.stringify(jsonld), APPLICATION_LD_JSON); + + return { response, info }; + } +} diff --git a/test/integration/WebSocketSubscription2021.test.ts b/test/integration/WebSocketSubscription2021.test.ts new file mode 100644 index 0000000000..2775f42e58 --- /dev/null +++ b/test/integration/WebSocketSubscription2021.test.ts @@ -0,0 +1,268 @@ +import { fetch } from 'cross-fetch'; +import type { NamedNode } from 'n3'; +import { DataFactory, Parser, Store } from 'n3'; +import { WebSocket } from 'ws'; +import { BasicRepresentation } from '../../src/http/representation/BasicRepresentation'; +import type { App } from '../../src/init/App'; +import type { ResourceStore } from '../../src/storage/ResourceStore'; +import { joinUrl } from '../../src/util/PathUtil'; +import { NOTIFY, RDF } from '../../src/util/Vocabularies'; +import { getPort } from '../util/Util'; +import { + getDefaultVariables, + getPresetConfigPath, + getTestConfigPath, + getTestFolder, + instantiateFromConfig, removeFolder, +} from './Config'; +import quad = DataFactory.quad; +import namedNode = DataFactory.namedNode; + +const port = getPort('WebSocketSubscription2021'); +const baseUrl = `http://localhost:${port}/`; + +const rootFilePath = getTestFolder('WebSocketSubscription2021'); +const stores: [string, any][] = [ + [ 'in-memory storage', { + configs: [ 'storage/backend/memory.json', 'util/resource-locker/memory.json' ], + teardown: jest.fn(), + }], + [ 'on-disk storage', { + // Switch to file locker after https://github.com/CommunitySolidServer/CommunitySolidServer/issues/1452 + configs: [ 'storage/backend/file.json', 'util/resource-locker/memory.json' ], + teardown: async(): Promise => removeFolder(rootFilePath), + }], +]; + +// Send the subscribe request and check the response +async function subscribe(subscriptionUrl: string, topic: string, features: Record = {}): +Promise { + const subscription = { + '@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], + type: 'WebSocketSubscription2021', + topic, + ...features, + }; + + const response = await fetch(subscriptionUrl, { + method: 'POST', + headers: { 'content-type': 'application/ld+json' }, + body: JSON.stringify(subscription), + }); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/ld+json'); + const { type, source } = await response.json(); + expect(type).toBe('WebSocketSubscription2021'); + + return source; +} + +// Check if a notification has the correct format +function expectNotification(notification: unknown, topic: string, type: 'Create' | 'Update' | 'Delete'): void { + const expected: any = { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://www.w3.org/ns/solid/notification/v1', + ], + id: expect.stringContaining(topic), + type: [ type ], + object: { + id: topic, + type: [], + }, + published: expect.anything(), + }; + if (type !== 'Delete') { + expected.state = expect.anything(); + expected.object.type.push('http://www.w3.org/ns/ldp#Resource'); + } + expect(notification).toEqual(expected); +} + +describe.each(stores)('A server supporting WebSocketSubscription2021 using %s', (name, { configs, teardown }): void => { + let app: App; + let store: ResourceStore; + const webId = 'http://example.com/card/#me'; + const topic = joinUrl(baseUrl, '/foo'); + let storageDescriptionUrl: string; + let subscriptionUrl: string; + let webSocketUrl: string; + + beforeAll(async(): Promise => { + const variables = { + ...getDefaultVariables(port, baseUrl), + 'urn:solid-server:default:variable:rootFilePath': rootFilePath, + }; + + // Create and start the server + const instances = await instantiateFromConfig( + 'urn:solid-server:test:Instances', + [ + ...configs.map(getPresetConfigPath), + getTestConfigPath('websocket-notifications.json'), + ], + variables, + ) as Record; + ({ app, store } = instances); + + await app.start(); + }); + + afterAll(async(): Promise => { + await teardown(); + await app.stop(); + }); + + it('links to the storage description.', async(): Promise => { + const response = await fetch(baseUrl); + expect(response.status).toBe(200); + const linkHeader = response.headers.get('link'); + expect(linkHeader).not.toBeNull(); + const match = /<([^>]+)>; rel="http:\/\/www\.w3\.org\/ns\/solid\/terms#storageDescription"/u.exec(linkHeader!); + expect(match).not.toBeNull(); + storageDescriptionUrl = match![1]; + }); + + it('exposes metadata on how to subscribe in the storage description.', async(): Promise => { + const response = await fetch(storageDescriptionUrl, { headers: { accept: 'text/turtle' }}); + expect(response.status).toBe(200); + const quads = new Store(new Parser().parse(await response.text())); + + // Find the notification channel for websockets + const channels = quads.getObjects(storageDescriptionUrl, NOTIFY.terms.notificationChannel, null); + const websocketChannels = channels.filter((channel): boolean => quads.has( + quad(channel as NamedNode, RDF.terms.type, namedNode(`${NOTIFY.namespace}WebSocketSubscription2021`)), + )); + expect(websocketChannels).toHaveLength(1); + const subscriptionUrls = quads.getObjects(websocketChannels[0], NOTIFY.terms.subscription, null); + expect(subscriptionUrls).toHaveLength(1); + subscriptionUrl = subscriptionUrls[0].value; + }); + + it('supports subscribing.', async(): Promise => { + webSocketUrl = await subscribe(subscriptionUrl, topic); + }); + + it('emits Created events.', async(): Promise => { + const socket = new WebSocket(webSocketUrl); + + const notificationPromise = new Promise((resolve): any => socket.on('message', resolve)); + await new Promise((resolve): any => socket.on('open', resolve)); + + const response = await fetch(topic, { + method: 'PUT', + headers: { 'content-type': 'text/plain' }, + body: 'abc', + }); + expect(response.status).toBe(201); + + const notification = JSON.parse((await notificationPromise).toString()); + socket.close(); + + expectNotification(notification, topic, 'Create'); + }); + + it('emits Update events.', async(): Promise => { + const socket = new WebSocket(webSocketUrl); + const notificationPromise = new Promise((resolve): any => socket.on('message', resolve)); + await new Promise((resolve): any => socket.on('open', resolve)); + + const response = await fetch(topic, { + method: 'PUT', + headers: { 'content-type': 'text/plain' }, + body: 'def', + }); + expect(response.status).toBe(205); + + const notification = JSON.parse((await notificationPromise).toString()); + socket.close(); + + expectNotification(notification, topic, 'Update'); + }); + + it('emits Delete events.', async(): Promise => { + const socket = new WebSocket(webSocketUrl); + const notificationPromise = new Promise((resolve): any => socket.on('message', resolve)); + await new Promise((resolve): any => socket.on('open', resolve)); + + const response = await fetch(topic, { method: 'DELETE' }); + expect(response.status).toBe(205); + + const notification = JSON.parse((await notificationPromise).toString()); + socket.close(); + + expectNotification(notification, topic, 'Delete'); + }); + + it('prevents subscribing to restricted resources.', async(): Promise => { + const restricted = joinUrl(baseUrl, '/restricted'); + + // Only allow our WebID to read + const restrictedAcl = `@prefix acl: . +@prefix foaf: . + +<#authorization> + a acl:Authorization; + acl:agent <${webId}>; + acl:mode acl:Read; + acl:accessTo <./restricted>.`; + await store.setRepresentation({ path: `${restricted}.acl` }, new BasicRepresentation(restrictedAcl, 'text/turtle')); + + const subscription = { + '@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], + type: 'WebSocketSubscription2021', + topic: restricted, + }; + + // Unauthenticated fetch fails + let response = await fetch(subscriptionUrl, { + method: 'POST', + headers: { 'content-type': 'application/ld+json' }, + body: JSON.stringify(subscription), + }); + expect(response.status).toBe(401); + + // (debug) Authenticated fetch succeeds + response = await fetch(subscriptionUrl, { + method: 'POST', + headers: { + authorization: `WebID ${webId}`, + 'content-type': 'application/ld+json', + }, + body: JSON.stringify(subscription), + }); + expect(response.status).toBe(200); + }); + + it('sends a notification if a state value was sent along.', async(): Promise => { + const response = await fetch(topic, { + method: 'PUT', + headers: { 'content-type': 'text/plain' }, + body: 'abc', + }); + expect(response.status).toBe(201); + + const source = await subscribe(subscriptionUrl, topic, { state: 'abc' }); + + const socket = new WebSocket(source); + const notificationPromise = new Promise((resolve): any => socket.on('message', resolve)); + await new Promise((resolve): any => socket.on('open', resolve)); + + // Will receive a notification even though the resource did not change after the socket was open + const notification = JSON.parse((await notificationPromise).toString()); + socket.close(); + + expectNotification(notification, topic, 'Update'); + }); + + it('removes expired subscriptions.', async(): Promise => { + const source = await subscribe(subscriptionUrl, topic, { expiration: 1 }); + + const socket = new WebSocket(source); + const messagePromise = new Promise((resolve): any => socket.on('message', resolve)); + await new Promise((resolve): any => socket.on('close', resolve)); + + const message = (await messagePromise).toString(); + expect(message).toBe('Subscription has expired'); + }); +}); diff --git a/test/integration/config/ldp-with-acp.json b/test/integration/config/ldp-with-acp.json index 2e6b98340f..cc644b5fe2 100644 --- a/test/integration/config/ldp-with-acp.json +++ b/test/integration/config/ldp-with-acp.json @@ -4,9 +4,9 @@ "css:config/app/main/default.json", "css:config/app/init/default.json", "css:config/app/setup/disabled.json", - "css:config/http/handler/simple.json", "css:config/http/middleware/default.json", + "css:config/http/notifications/disabled.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/test/integration/config/quota-global.json b/test/integration/config/quota-global.json index cf59e6f72b..def0821cb0 100644 --- a/test/integration/config/quota-global.json +++ b/test/integration/config/quota-global.json @@ -6,7 +6,7 @@ "css:config/app/setup/disabled.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/test/integration/config/quota-pod.json b/test/integration/config/quota-pod.json index eed5b35509..e72f5b8566 100644 --- a/test/integration/config/quota-pod.json +++ b/test/integration/config/quota-pod.json @@ -6,7 +6,7 @@ "css:config/app/setup/disabled.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/test/integration/config/restricted-idp.json b/test/integration/config/restricted-idp.json index 55600a7b2c..d26e2c271f 100644 --- a/test/integration/config/restricted-idp.json +++ b/test/integration/config/restricted-idp.json @@ -6,7 +6,7 @@ "css:config/app/setup/disabled.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/restricted.json", diff --git a/test/integration/config/server-file.json b/test/integration/config/server-file.json index 38ce8f3167..7625d3d1c1 100644 --- a/test/integration/config/server-file.json +++ b/test/integration/config/server-file.json @@ -6,7 +6,7 @@ "css:config/app/setup/disabled.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/test/integration/config/server-memory.json b/test/integration/config/server-memory.json index 2bff26e5b0..b8e688a206 100644 --- a/test/integration/config/server-memory.json +++ b/test/integration/config/server-memory.json @@ -6,7 +6,7 @@ "css:config/app/setup/disabled.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/test/integration/config/server-middleware.json b/test/integration/config/server-middleware.json index 5c3f0c2a45..aa84d373dd 100644 --- a/test/integration/config/server-middleware.json +++ b/test/integration/config/server-middleware.json @@ -6,7 +6,7 @@ "css:config/app/setup/disabled.json", "css:config/http/handler/simple.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/test/integration/config/server-without-auth.json b/test/integration/config/server-without-auth.json index 2598e84b3b..674957bb6e 100644 --- a/test/integration/config/server-without-auth.json +++ b/test/integration/config/server-without-auth.json @@ -6,7 +6,7 @@ "css:config/app/setup/disabled.json", "css:config/http/handler/simple.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", diff --git a/test/integration/config/setup-memory.json b/test/integration/config/setup-memory.json index 82f9af3fe1..87c5974e86 100644 --- a/test/integration/config/setup-memory.json +++ b/test/integration/config/setup-memory.json @@ -6,7 +6,7 @@ "css:config/app/setup/required.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", - "css:config/http/notifications/legacy-websockets.json", + "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", diff --git a/test/integration/config/websocket-notifications.json b/test/integration/config/websocket-notifications.json new file mode 100644 index 0000000000..c92349f090 --- /dev/null +++ b/test/integration/config/websocket-notifications.json @@ -0,0 +1,52 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", + "import": [ + "css:config/app/main/default.json", + "css:config/app/init/initialize-root.json", + "css:config/app/setup/disabled.json", + "css:config/http/handler/default.json", + "css:config/http/middleware/default.json", + "css:config/http/notifications/websockets.json", + "css:config/http/server-factory/http.json", + "css:config/http/static/default.json", + "css:config/identity/access/public.json", + "css:config/identity/email/default.json", + "css:config/identity/handler/default.json", + "css:config/identity/ownership/token.json", + "css:config/identity/pod/static.json", + "css:config/identity/registration/enabled.json", + "css:config/ldp/authentication/debug-auth-header.json", + "css:config/ldp/authorization/webacl.json", + "css:config/ldp/handler/default.json", + "css:config/ldp/metadata-parser/default.json", + "css:config/ldp/metadata-writer/default.json", + "css:config/ldp/modes/default.json", + + "css:config/storage/key-value/resource-store.json", + "css:config/storage/middleware/default.json", + "css:config/util/auxiliary/acl.json", + "css:config/util/identifiers/suffix.json", + "css:config/util/index/default.json", + "css:config/util/logging/winston.json", + "css:config/util/representation-conversion/default.json", + + "css:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": "WebSocket notifications with debug authentication.", + "@id": "urn:solid-server:test:Instances", + "@type": "RecordObject", + "record": [ + { + "RecordObject:_record_key": "app", + "RecordObject:_record_value": { "@id": "urn:solid-server:default:App" } + }, + { + "RecordObject:_record_key": "store", + "RecordObject:_record_value": { "@id": "urn:solid-server:default:ResourceStore" } + } + ] + } + ] +} diff --git a/test/unit/http/output/metadata/StorageDescriptionAdvertiser.test.ts b/test/unit/http/output/metadata/StorageDescriptionAdvertiser.test.ts index 189f5d8b6a..66a41f36ba 100644 --- a/test/unit/http/output/metadata/StorageDescriptionAdvertiser.test.ts +++ b/test/unit/http/output/metadata/StorageDescriptionAdvertiser.test.ts @@ -15,7 +15,7 @@ describe('A StorageDescriptionAdvertiser', (): void => { let response: jest.Mocked; let input: MetadataWriterInput; const baseUrl = 'http://example.com/'; - const suffix = '.well-known/solid'; + const path = '.well-known/solid'; let targetExtractor: jest.Mocked; const identifierStrategy = new SingleRootIdentifierStrategy(baseUrl); let store: jest.Mocked; @@ -41,7 +41,7 @@ describe('A StorageDescriptionAdvertiser', (): void => { getRepresentation: jest.fn().mockResolvedValue(new BasicRepresentation('', { [RDF.type]: PIM.terms.Storage })), } as any; - advertiser = new StorageDescriptionAdvertiser(targetExtractor, identifierStrategy, store, suffix); + advertiser = new StorageDescriptionAdvertiser(targetExtractor, identifierStrategy, store, path); }); it('adds a storage description link header.', async(): Promise => { diff --git a/test/unit/server/description/StorageDescriptionHandler.test.ts b/test/unit/server/description/StorageDescriptionHandler.test.ts index 6c807d8e6d..16cf450b5d 100644 --- a/test/unit/server/description/StorageDescriptionHandler.test.ts +++ b/test/unit/server/description/StorageDescriptionHandler.test.ts @@ -15,7 +15,7 @@ import quad = DataFactory.quad; import namedNode = DataFactory.namedNode; describe('A StorageDescriptionHandler', (): void => { - const suffix = '.well-known/solid'; + const path = '.well-known/solid'; const request: HttpRequest = {} as any; const response: HttpResponse = {} as any; let operation: Operation; @@ -28,7 +28,7 @@ describe('A StorageDescriptionHandler', (): void => { beforeEach(async(): Promise => { operation = { method: 'GET', - target: { path: `http://example.com/${suffix}` }, + target: { path: `http://example.com/${path}` }, body: new BasicRepresentation(), preferences: {}, }; @@ -50,7 +50,7 @@ describe('A StorageDescriptionHandler', (): void => { [ quad(namedNode(target.path), RDF.terms.type, PIM.terms.Storage) ]), } as any; - handler = new StorageDescriptionHandler(store, suffix, converter, describer); + handler = new StorageDescriptionHandler(store, path, converter, describer); }); it('only handles GET requests.', async(): Promise => { diff --git a/test/unit/server/notifications/WebSocketSubscription2021/WebSocket2021Emitter.test.ts b/test/unit/server/notifications/WebSocketSubscription2021/WebSocket2021Emitter.test.ts new file mode 100644 index 0000000000..d89d0702b0 --- /dev/null +++ b/test/unit/server/notifications/WebSocketSubscription2021/WebSocket2021Emitter.test.ts @@ -0,0 +1,84 @@ +import { EventEmitter } from 'events'; +import type { WebSocket } from 'ws'; +import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation'; +import type { + SubscriptionInfo, +} from '../../../../../src/server/notifications/SubscriptionStorage'; +import { + WebSocket2021Emitter, +} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Emitter'; +import type { SetMultiMap } from '../../../../../src/util/map/SetMultiMap'; +import { WrappedSetMultiMap } from '../../../../../src/util/map/WrappedSetMultiMap'; + +describe('A WebSocket2021Emitter', (): void => { + const info: SubscriptionInfo = { + id: 'id', + topic: 'http://example.com/foo', + type: 'type', + features: {}, + lastEmit: 0, + }; + + let webSocket: jest.Mocked; + let socketMap: SetMultiMap; + let emitter: WebSocket2021Emitter; + + beforeEach(async(): Promise => { + webSocket = new EventEmitter() as any; + webSocket.send = jest.fn(); + webSocket.close = jest.fn(); + + socketMap = new WrappedSetMultiMap(); + + emitter = new WebSocket2021Emitter(socketMap); + }); + + it('emits notifications to the stored WebSockets.', async(): Promise => { + socketMap.add(info.id, webSocket); + + const representation = new BasicRepresentation('notification', 'text/plain'); + await expect(emitter.handle({ info, representation })).resolves.toBeUndefined(); + expect(webSocket.send).toHaveBeenCalledTimes(1); + expect(webSocket.send).toHaveBeenLastCalledWith('notification'); + }); + + it('destroys the representation if there is no matching WebSocket.', async(): Promise => { + const representation = new BasicRepresentation('notification', 'text/plain'); + await expect(emitter.handle({ info, representation })).resolves.toBeUndefined(); + expect(webSocket.send).toHaveBeenCalledTimes(0); + expect(representation.data.destroyed).toBe(true); + }); + + it('can send to multiple matching WebSockets.', async(): Promise => { + const webSocket2: jest.Mocked = new EventEmitter() as any; + webSocket2.send = jest.fn(); + + socketMap.add(info.id, webSocket); + socketMap.add(info.id, webSocket2); + + const representation = new BasicRepresentation('notification', 'text/plain'); + await expect(emitter.handle({ info, representation })).resolves.toBeUndefined(); + expect(webSocket.send).toHaveBeenCalledTimes(1); + expect(webSocket.send).toHaveBeenLastCalledWith('notification'); + expect(webSocket2.send).toHaveBeenCalledTimes(1); + expect(webSocket2.send).toHaveBeenLastCalledWith('notification'); + }); + + it('only sends to the matching WebSockets.', async(): Promise => { + const webSocket2: jest.Mocked = new EventEmitter() as any; + webSocket2.send = jest.fn(); + const info2: SubscriptionInfo = { + ...info, + id: 'other', + }; + + socketMap.add(info.id, webSocket); + socketMap.add(info2.id, webSocket2); + + const representation = new BasicRepresentation('notification', 'text/plain'); + await expect(emitter.handle({ info, representation })).resolves.toBeUndefined(); + expect(webSocket.send).toHaveBeenCalledTimes(1); + expect(webSocket.send).toHaveBeenLastCalledWith('notification'); + expect(webSocket2.send).toHaveBeenCalledTimes(0); + }); +}); diff --git a/test/unit/server/notifications/WebSocketSubscription2021/WebSocket2021Listener.test.ts b/test/unit/server/notifications/WebSocketSubscription2021/WebSocket2021Listener.test.ts new file mode 100644 index 0000000000..0d36da4299 --- /dev/null +++ b/test/unit/server/notifications/WebSocketSubscription2021/WebSocket2021Listener.test.ts @@ -0,0 +1,124 @@ +import { EventEmitter } from 'events'; +import type { Server } from 'http'; +import type { WebSocket } from 'ws'; +import { + AbsolutePathInteractionRoute, +} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute'; +import type { HttpRequest } from '../../../../../src/server/HttpRequest'; +import type { + SubscriptionInfo, + SubscriptionStorage, +} from '../../../../../src/server/notifications/SubscriptionStorage'; +import type { + WebSocket2021Handler, +} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Handler'; +import { + WebSocket2021Listener, +} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Listener'; +import { flushPromises } from '../../../../util/Util'; + +jest.mock('ws', (): any => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + WebSocketServer: jest.fn().mockImplementation((): any => ({ + handleUpgrade(upgradeRequest: any, socket: any, head: any, callback: any): void { + callback(socket, upgradeRequest); + }, + })), +})); + +describe('A WebSocket2021Listener', (): void => { + const info: SubscriptionInfo = { + id: 'id', + topic: 'http://example.com/foo', + type: 'type', + features: {}, + lastEmit: 0, + }; + const auth = '123456'; + let server: Server; + let webSocket: WebSocket; + let upgradeRequest: HttpRequest; + let storage: jest.Mocked; + let handler: jest.Mocked; + const route = new AbsolutePathInteractionRoute('http://example.com/foo'); + let listener: WebSocket2021Listener; + + beforeEach(async(): Promise => { + server = new EventEmitter() as any; + webSocket = new EventEmitter() as any; + webSocket.send = jest.fn(); + webSocket.close = jest.fn(); + + upgradeRequest = { url: `/foo?auth=${auth}` } as any; + + storage = { + get: jest.fn().mockResolvedValue(info), + } as any; + + handler = { + handleSafe: jest.fn(), + } as any; + + listener = new WebSocket2021Listener(storage, handler, route); + await listener.handle(server); + }); + + it('rejects request targeting an unknown path.', async(): Promise => { + upgradeRequest.url = '/wrong'; + server.emit('upgrade', upgradeRequest, webSocket); + + await flushPromises(); + + expect(webSocket.send).toHaveBeenCalledTimes(1); + expect(webSocket.send).toHaveBeenLastCalledWith('Unknown WebSocket target.'); + expect(webSocket.close).toHaveBeenCalledTimes(1); + expect(handler.handleSafe).toHaveBeenCalledTimes(0); + }); + + it('rejects request with no url.', async(): Promise => { + delete upgradeRequest.url; + server.emit('upgrade', upgradeRequest, webSocket); + + await flushPromises(); + + expect(webSocket.send).toHaveBeenCalledTimes(1); + expect(webSocket.send).toHaveBeenLastCalledWith('Unknown WebSocket target.'); + expect(webSocket.close).toHaveBeenCalledTimes(1); + expect(handler.handleSafe).toHaveBeenCalledTimes(0); + }); + + it('rejects requests without an auth parameter.', async(): Promise => { + upgradeRequest.url = '/foo'; + server.emit('upgrade', upgradeRequest, webSocket); + + await flushPromises(); + + expect(webSocket.send).toHaveBeenCalledTimes(1); + expect(webSocket.send).toHaveBeenLastCalledWith('Missing auth parameter from WebSocket URL.'); + expect(webSocket.close).toHaveBeenCalledTimes(1); + expect(handler.handleSafe).toHaveBeenCalledTimes(0); + }); + + it('rejects requests with an unknown auth parameter.', async(): Promise => { + storage.get.mockResolvedValue(undefined); + server.emit('upgrade', upgradeRequest, webSocket); + + await flushPromises(); + + expect(webSocket.send).toHaveBeenCalledTimes(1); + expect(webSocket.send).toHaveBeenLastCalledWith(`Subscription has expired`); + expect(webSocket.close).toHaveBeenCalledTimes(1); + expect(handler.handleSafe).toHaveBeenCalledTimes(0); + }); + + it('calls the handler when receiving a valid request.', async(): Promise => { + server.emit('upgrade', upgradeRequest, webSocket); + + await flushPromises(); + + expect(webSocket.send).toHaveBeenCalledTimes(0); + expect(webSocket.close).toHaveBeenCalledTimes(0); + expect(handler.handleSafe).toHaveBeenCalledTimes(1); + expect(handler.handleSafe).toHaveBeenLastCalledWith({ webSocket, info }); + }); +}); diff --git a/test/unit/server/notifications/WebSocketSubscription2021/WebSocket2021Storer.test.ts b/test/unit/server/notifications/WebSocketSubscription2021/WebSocket2021Storer.test.ts new file mode 100644 index 0000000000..9ca625ff87 --- /dev/null +++ b/test/unit/server/notifications/WebSocketSubscription2021/WebSocket2021Storer.test.ts @@ -0,0 +1,96 @@ +import { EventEmitter } from 'events'; +import type { WebSocket } from 'ws'; +import type { + SubscriptionInfo, + SubscriptionStorage, +} from '../../../../../src/server/notifications/SubscriptionStorage'; + +import { + WebSocket2021Storer, +} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Storer'; +import type { SetMultiMap } from '../../../../../src/util/map/SetMultiMap'; +import { WrappedSetMultiMap } from '../../../../../src/util/map/WrappedSetMultiMap'; +import { flushPromises } from '../../../../util/Util'; + +describe('A WebSocket2021Storer', (): void => { + const info: SubscriptionInfo = { + id: 'id', + topic: 'http://example.com/foo', + type: 'type', + features: {}, + lastEmit: 0, + }; + let webSocket: jest.Mocked; + let storage: jest.Mocked; + let socketMap: SetMultiMap; + let storer: WebSocket2021Storer; + + beforeEach(async(): Promise => { + webSocket = new EventEmitter() as any; + webSocket.close = jest.fn(); + + storage = { + get: jest.fn(), + } as any; + + socketMap = new WrappedSetMultiMap(); + + storer = new WebSocket2021Storer(storage, socketMap); + }); + + it('stores WebSockets.', async(): Promise => { + await expect(storer.handle({ info, webSocket })).resolves.toBeUndefined(); + expect([ ...socketMap.keys() ]).toHaveLength(1); + expect(socketMap.has(info.id)).toBe(true); + }); + + it('removes closed WebSockets.', async(): Promise => { + await expect(storer.handle({ info, webSocket })).resolves.toBeUndefined(); + expect(socketMap.has(info.id)).toBe(true); + webSocket.emit('close'); + expect(socketMap.has(info.id)).toBe(false); + }); + + it('removes erroring WebSockets.', async(): Promise => { + await expect(storer.handle({ info, webSocket })).resolves.toBeUndefined(); + expect(socketMap.has(info.id)).toBe(true); + webSocket.emit('error'); + expect(socketMap.has(info.id)).toBe(false); + }); + + it('removes expired WebSockets.', async(): Promise => { + jest.useFakeTimers(); + + // Need to create class after fake timers have been enabled + storer = new WebSocket2021Storer(storage, socketMap); + + const webSocket2: jest.Mocked = new EventEmitter() as any; + webSocket2.close = jest.fn(); + const webSocketOther: jest.Mocked = new EventEmitter() as any; + webSocketOther.close = jest.fn(); + const infoOther: SubscriptionInfo = { + ...info, + id: 'other', + }; + await expect(storer.handle({ info, webSocket })).resolves.toBeUndefined(); + await expect(storer.handle({ info, webSocket: webSocket2 })).resolves.toBeUndefined(); + await expect(storer.handle({ info: infoOther, webSocket: webSocketOther })).resolves.toBeUndefined(); + + // `info` expired, `infoOther` did not + storage.get.mockImplementation((id): any => { + if (id === infoOther.id) { + return infoOther; + } + }); + + jest.advanceTimersToNextTimer(); + + await flushPromises(); + + expect(webSocket.close).toHaveBeenCalledTimes(1); + expect(webSocket2.close).toHaveBeenCalledTimes(1); + expect(webSocketOther.close).toHaveBeenCalledTimes(0); + + jest.useRealTimers(); + }); +}); diff --git a/test/unit/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.test.ts b/test/unit/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.test.ts new file mode 100644 index 0000000000..659303ac8e --- /dev/null +++ b/test/unit/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.test.ts @@ -0,0 +1,69 @@ +import { AccessMode } from '../../../../../src/authorization/permissions/Permissions'; +import { + AbsolutePathInteractionRoute, +} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute'; +import type { Subscription } from '../../../../../src/server/notifications/Subscription'; +import type { SubscriptionStorage } from '../../../../../src/server/notifications/SubscriptionStorage'; +import { + WebSocketSubscription2021, +} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021'; +import { IdentifierSetMultiMap } from '../../../../../src/util/map/IdentifierMap'; +import { readJsonStream } from '../../../../../src/util/StreamUtil'; + +describe('A WebSocketSubscription2021', (): void => { + let subscription: Subscription; + let storage: jest.Mocked; + const route = new AbsolutePathInteractionRoute('http://example.com/foo'); + let subscriptionType: WebSocketSubscription2021; + + beforeEach(async(): Promise => { + subscription = { + '@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], + type: 'WebSocketSubscription2021', + topic: 'https://storage.example/resource', + state: undefined, + expiration: undefined, + accept: undefined, + rate: undefined, + }; + + storage = { + create: jest.fn().mockReturnValue({ + id: '123', + topic: 'http://example.com/foo', + type: 'WebSocketSubscription2021', + lastEmit: 0, + features: {}, + }), + add: jest.fn(), + } as any; + + subscriptionType = new WebSocketSubscription2021(storage, route); + }); + + it('has the correct type.', async(): Promise => { + expect(subscriptionType.type).toBe('WebSocketSubscription2021'); + }); + + it('correctly parses subscriptions.', async(): Promise => { + await expect(subscriptionType.schema.isValid(subscription)).resolves.toBe(true); + + subscription.type = 'something else'; + await expect(subscriptionType.schema.isValid(subscription)).resolves.toBe(false); + }); + + it('requires Read permissions on the topic.', async(): Promise => { + await expect(subscriptionType.extractModes(subscription)).resolves + .toEqual(new IdentifierSetMultiMap([[{ path: subscription.topic }, AccessMode.read ]])); + }); + + it('stores the info and returns a valid response when subscribing.', async(): Promise => { + const { response } = await subscriptionType.subscribe(subscription); + expect(response.metadata.contentType).toBe('application/ld+json'); + await expect(readJsonStream(response.data)).resolves.toEqual({ + '@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], + type: 'WebSocketSubscription2021', + source: expect.stringMatching(/^ws:\/\/example.com\/foo\?auth=.+/u), + }); + }); +}); diff --git a/test/util/Util.ts b/test/util/Util.ts index f25e23274f..d3ee0d8cff 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -29,6 +29,7 @@ const portNames = [ 'SetupMemory', 'SparqlStorage', 'Subdomains', + 'WebSocketSubscription2021', // Unit 'BaseServerFactory',