feat: Add support for WebSocketSubscription2021

This commit is contained in:
Joachim Van Herwegen 2022-09-30 09:32:10 +02:00
parent cbc07c6ef3
commit b1f7a6a8b1
54 changed files with 1173 additions and 56 deletions

View File

@ -23,6 +23,7 @@
"Readonly", "Readonly",
"RegExp", "RegExp",
"Server", "Server",
"SetMultiMap",
"Shorthand", "Shorthand",
"SubscriptionType", "SubscriptionType",
"Template", "Template",

View File

@ -6,6 +6,8 @@
- The server can be configured to use [ACP](https://solidproject.org/TR/acp) instead of WebACL. - 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. `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 ### 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. - 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. 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. 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. - `/app/main/default.json` now imports the above config file.
- All files configuring template engines. - All files configuring template engines.
- Several minor changes due to support ACP. - Several minor changes due to support ACP.
- `ldp/authorization/*` - `/ldp/authorization/*`
- Resource generation was changed to there is 1 reusable resource generator. - Resource generation was changed to there is 1 reusable resource generator.
- `init/initializers/*` - `/init/initializers/*`
- `setup/handlers/setup.json` - `/setup/handlers/setup.json`
- `identity/access/initializers/*` - `/identity/access/initializers/*`
- `identity/pod/*` - `/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 ### 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. - `TemplatedResourcesGenerator` has been renamed to `BaseResourcesGenerator` and has a different interface now.
- `CredentialSet` was replaced by a single `Credentials` interface. - `CredentialSet` was replaced by a single `Credentials` interface.
This impacts all authentication and authorization related classes. 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 ## v5.1.0

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json", "css:config/app/variables/default.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json", "css:config/app/variables/default.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -7,6 +7,7 @@
"css:config/app/variables/default.json", "css:config/app/variables/default.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/default.json", "css:config/http/middleware/default.json",
"css:config/http/notifications/websockets.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -7,6 +7,7 @@
"css:config/app/variables/default.json", "css:config/app/variables/default.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/default.json", "css:config/http/middleware/default.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json", "css:config/http/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json", "css:config/app/variables/default.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json", "css:config/app/variables/default.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -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 A set of handlers that will always be run on all requests to add some metadata
and then pass the request along. and then pass the request along.
* *no-websockets*: The default setup but without the websocket-related metadata. * *default*: The default setup with several handlers.
* *websockets*: 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 ## Server-Factory
The factory used to create the actual server object. The factory used to create the actual server object.
* *no-websockets*: Only HTTP. * *http*: A HTTP server.
* *websockets*: HTTP and websockets. * *https*: A HTTPS server.
* *https-no-websockets*: Only HTTPS. Adds 2 new CLI params to set the key/cert paths. * *https-no-cli-example*: An example of how to set up an HTTPS server
* *https-websockets*: HTTPS and websockets. Adds 2 new CLI params to set the key/cert paths. by defining the key/cert paths directly in the config itself.
* *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.
## Static ## Static

View File

@ -2,8 +2,8 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"@graph": [ "@graph": [
{ {
"comment": "The suffix appended to a storage container to find its description resource.", "comment": "The relative path appended to a storage container URL to find its description resource.",
"@id": "urn:solid-server:default:variable:storageDescriptionSuffix", "@id": "urn:solid-server:default:variable:storageDescriptionPath",
"valueRaw": ".well-known/solid" "valueRaw": ".well-known/solid"
}, },
{ {
@ -22,7 +22,7 @@
"operationHandler": { "operationHandler": {
"@type": "StorageDescriptionHandler", "@type": "StorageDescriptionHandler",
"store": { "@id": "urn:solid-server:default:ResourceStore" }, "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" }, "converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"describer": { "@id": "urn:solid-server:default:StorageDescriber" } "describer": { "@id": "urn:solid-server:default:StorageDescriber" }
} }

View File

@ -14,6 +14,6 @@
"@type": "RelativePathInteractionRoute", "@type": "RelativePathInteractionRoute",
"base": { "@id": "urn:solid-server:default:variable:baseUrl" }, "base": { "@id": "urn:solid-server:default:variable:baseUrl" },
"relativePath": "/.notifications/" "relativePath": "/.notifications/"
}, }
] ]
} }

View File

@ -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."
}
]
}

View File

@ -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"
}
]
}
]
}

View File

@ -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" }
]
}
]
}

View File

@ -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" }
]
}
]
}

View File

@ -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" }
]
}
]
}

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json", "css:config/app/variables/default.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/https.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -8,7 +8,7 @@
"targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, "targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" },
"identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" }, "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" },
"store": { "@id": "urn:solid-server:default:ResourceStore" }, "store": { "@id": "urn:solid-server:default:ResourceStore" },
"suffix": { "@id": "urn:solid-server:default:variable:storageDescriptionSuffix" } "path": { "@id": "urn:solid-server:default:variable:storageDescriptionPath" }
} }
] ]
} }

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json", "css:config/app/variables/default.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json", "css:config/app/variables/default.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json", "css:config/app/variables/default.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json", "css:config/app/variables/default.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/restricted.json", "css:config/identity/access/restricted.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json", "css:config/app/variables/default.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json", "css:config/app/variables/default.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json", "css:config/app/variables/default.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -13,7 +13,7 @@ import { MetadataWriter } from './MetadataWriter';
/** /**
* Adds a link header pointing to the relevant storage description resource. * Adds a link header pointing to the relevant storage description resource.
* Recursively checks parent containers until a storage container is found, * 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 { export class StorageDescriptionAdvertiser extends MetadataWriter {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
@ -21,15 +21,15 @@ export class StorageDescriptionAdvertiser extends MetadataWriter {
private readonly targetExtractor: TargetExtractor; private readonly targetExtractor: TargetExtractor;
private readonly identifierStrategy: IdentifierStrategy; private readonly identifierStrategy: IdentifierStrategy;
private readonly store: ResourceStore; private readonly store: ResourceStore;
private readonly suffix: string; private readonly path: string;
public constructor(targetExtractor: TargetExtractor, identifierStrategy: IdentifierStrategy, store: ResourceStore, public constructor(targetExtractor: TargetExtractor, identifierStrategy: IdentifierStrategy, store: ResourceStore,
suffix: string) { path: string) {
super(); super();
this.identifierStrategy = identifierStrategy; this.identifierStrategy = identifierStrategy;
this.targetExtractor = targetExtractor; this.targetExtractor = targetExtractor;
this.store = store; this.store = store;
this.suffix = suffix; this.path = path;
} }
public async handle({ response, metadata }: MetadataWriterInput): Promise<void> { public async handle({ response, metadata }: MetadataWriterInput): Promise<void> {
@ -45,7 +45,7 @@ export class StorageDescriptionAdvertiser extends MetadataWriter {
this.logger.error(`Unable to find storage root: ${createErrorMessage(error)}`); this.logger.error(`Unable to find storage root: ${createErrorMessage(error)}`);
return; return;
} }
const storageDescription = joinUrl(storageRoot.path, this.suffix); const storageDescription = joinUrl(storageRoot.path, this.path);
addHeader(response, 'Link', `<${storageDescription}>; rel="${SOLID.storageDescription}"`); addHeader(response, 'Link', `<${storageDescription}>; rel="${SOLID.storageDescription}"`);
} }

View File

@ -317,6 +317,15 @@ export * from './server/notifications/generate/StateNotificationGenerator';
export * from './server/notifications/serialize/ConvertingNotificationSerializer'; export * from './server/notifications/serialize/ConvertingNotificationSerializer';
export * from './server/notifications/serialize/JsonLdNotificationSerializer'; export * from './server/notifications/serialize/JsonLdNotificationSerializer';
export * from './server/notifications/serialize/NotificationSerializer'; 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 // Server/Notifications
export * from './server/notifications/ActivityEmitter'; export * from './server/notifications/ActivityEmitter';
export * from './server/notifications/BaseStateHandler'; export * from './server/notifications/BaseStateHandler';

View File

@ -9,6 +9,9 @@ import namedNode = DataFactory.namedNode;
/** /**
* Adds a fixed set of triples to the storage description resource, * Adds a fixed set of triples to the storage description resource,
* with the resource identifier as subject. * 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 { export class StaticStorageDescriber extends StorageDescriber {
private readonly terms: ReadonlyMap<NamedNode, Quad_Object[]>; private readonly terms: ReadonlyMap<NamedNode, Quad_Object[]>;

View File

@ -14,20 +14,20 @@ import type { StorageDescriber } from './StorageDescriber';
/** /**
* Generates the response for GET requests targeting a storage description resource. * Generates the response for GET requests targeting a storage description resource.
* The suffix needs to match the suffix used to generate storage description resources * The input path needs to match the relative path used to generate storage description resources
* and will be used to verify the container it is linked to is an actual storage. * and will be used to verify if the container it is linked to is an actual storage.
*/ */
export class StorageDescriptionHandler extends OperationHttpHandler { export class StorageDescriptionHandler extends OperationHttpHandler {
private readonly store: ResourceStore; private readonly store: ResourceStore;
private readonly suffix: string; private readonly path: string;
private readonly converter: RepresentationConverter; private readonly converter: RepresentationConverter;
private readonly describer: StorageDescriber; private readonly describer: StorageDescriber;
public constructor(store: ResourceStore, suffix: string, converter: RepresentationConverter, public constructor(store: ResourceStore, path: string, converter: RepresentationConverter,
describer: StorageDescriber) { describer: StorageDescriber) {
super(); super();
this.store = store; this.store = store;
this.suffix = suffix; this.path = path;
this.converter = converter; this.converter = converter;
this.describer = describer; this.describer = describer;
} }
@ -36,7 +36,7 @@ export class StorageDescriptionHandler extends OperationHttpHandler {
if (method !== 'GET') { if (method !== 'GET') {
throw new MethodNotAllowedHttpError([ method ], `Only GET requests can target the storage description.`); 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, {}); const representation = await this.store.getRepresentation(container, {});
representation.data.destroy(); representation.data.destroy();
if (!representation.metadata.has(RDF.terms.type, PIM.terms.Storage)) { if (!representation.metadata.has(RDF.terms.type, PIM.terms.Storage)) {

View File

@ -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'; 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. * as defined in https://solidproject.org/TR/notifications-protocol#notification-data-model.
*/ */
export interface Notification { export interface Notification {

View File

@ -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<string, WebSocket>;
public constructor(socketMap: SetMultiMap<string, WebSocket>) {
super();
this.socketMap = socketMap;
}
public async handle({ info, representation }: NotificationEmitterInput): Promise<void> {
// 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();
}
}
}

View File

@ -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<WebSocket2021HandlerInput> {}

View File

@ -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<void> {
// 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 });
}
}

View File

@ -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<string, WebSocket>;
public constructor(storage: SubscriptionStorage, socketMap: SetMultiMap<string, WebSocket>, 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<void> {
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<void> {
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');
}
}

View File

@ -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<string, WebSocket> implements SingleThreaded {}

View File

@ -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<typeof schema> {
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<AccessMap> {
return new IdentifierSetMultiMap<AccessMode>([[{ path: subscription.topic }, AccessMode.read ]]);
}
public async subscribe(subscription: Subscription): Promise<SubscriptionResponse> {
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 };
}
}

View File

@ -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<void> => removeFolder(rootFilePath),
}],
];
// Send the subscribe request and check the response
async function subscribe(subscriptionUrl: string, topic: string, features: Record<string, unknown> = {}):
Promise<string> {
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<void> => {
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<string, any>;
({ app, store } = instances);
await app.start();
});
afterAll(async(): Promise<void> => {
await teardown();
await app.stop();
});
it('links to the storage description.', async(): Promise<void> => {
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<void> => {
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<void> => {
webSocketUrl = await subscribe(subscriptionUrl, topic);
});
it('emits Created events.', async(): Promise<void> => {
const socket = new WebSocket(webSocketUrl);
const notificationPromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));
await new Promise<void>((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<void> => {
const socket = new WebSocket(webSocketUrl);
const notificationPromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));
await new Promise<void>((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<void> => {
const socket = new WebSocket(webSocketUrl);
const notificationPromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));
await new Promise<void>((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<void> => {
const restricted = joinUrl(baseUrl, '/restricted');
// Only allow our WebID to read
const restrictedAcl = `@prefix acl: <http://www.w3.org/ns/auth/acl#>.
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
<#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<void> => {
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<Buffer>((resolve): any => socket.on('message', resolve));
await new Promise<void>((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<void> => {
const source = await subscribe(subscriptionUrl, topic, { expiration: 1 });
const socket = new WebSocket(source);
const messagePromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));
await new Promise<void>((resolve): any => socket.on('close', resolve));
const message = (await messagePromise).toString();
expect(message).toBe('Subscription has expired');
});
});

View File

@ -4,9 +4,9 @@
"css:config/app/main/default.json", "css:config/app/main/default.json",
"css:config/app/init/default.json", "css:config/app/init/default.json",
"css:config/app/setup/disabled.json", "css:config/app/setup/disabled.json",
"css:config/http/handler/simple.json", "css:config/http/handler/simple.json",
"css:config/http/middleware/default.json", "css:config/http/middleware/default.json",
"css:config/http/notifications/disabled.json",
"css:config/http/server-factory/http.json", "css:config/http/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -6,7 +6,7 @@
"css:config/app/setup/disabled.json", "css:config/app/setup/disabled.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -6,7 +6,7 @@
"css:config/app/setup/disabled.json", "css:config/app/setup/disabled.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -6,7 +6,7 @@
"css:config/app/setup/disabled.json", "css:config/app/setup/disabled.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/restricted.json", "css:config/identity/access/restricted.json",

View File

@ -6,7 +6,7 @@
"css:config/app/setup/disabled.json", "css:config/app/setup/disabled.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -6,7 +6,7 @@
"css:config/app/setup/disabled.json", "css:config/app/setup/disabled.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -6,7 +6,7 @@
"css:config/app/setup/disabled.json", "css:config/app/setup/disabled.json",
"css:config/http/handler/simple.json", "css:config/http/handler/simple.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -6,7 +6,7 @@
"css:config/app/setup/disabled.json", "css:config/app/setup/disabled.json",
"css:config/http/handler/simple.json", "css:config/http/handler/simple.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",

View File

@ -6,7 +6,7 @@
"css:config/app/setup/required.json", "css:config/app/setup/required.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/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/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -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" }
}
]
}
]
}

View File

@ -15,7 +15,7 @@ describe('A StorageDescriptionAdvertiser', (): void => {
let response: jest.Mocked<HttpResponse>; let response: jest.Mocked<HttpResponse>;
let input: MetadataWriterInput; let input: MetadataWriterInput;
const baseUrl = 'http://example.com/'; const baseUrl = 'http://example.com/';
const suffix = '.well-known/solid'; const path = '.well-known/solid';
let targetExtractor: jest.Mocked<TargetExtractor>; let targetExtractor: jest.Mocked<TargetExtractor>;
const identifierStrategy = new SingleRootIdentifierStrategy(baseUrl); const identifierStrategy = new SingleRootIdentifierStrategy(baseUrl);
let store: jest.Mocked<ResourceStore>; let store: jest.Mocked<ResourceStore>;
@ -41,7 +41,7 @@ describe('A StorageDescriptionAdvertiser', (): void => {
getRepresentation: jest.fn().mockResolvedValue(new BasicRepresentation('', { [RDF.type]: PIM.terms.Storage })), getRepresentation: jest.fn().mockResolvedValue(new BasicRepresentation('', { [RDF.type]: PIM.terms.Storage })),
} as any; } 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<void> => { it('adds a storage description link header.', async(): Promise<void> => {

View File

@ -15,7 +15,7 @@ import quad = DataFactory.quad;
import namedNode = DataFactory.namedNode; import namedNode = DataFactory.namedNode;
describe('A StorageDescriptionHandler', (): void => { describe('A StorageDescriptionHandler', (): void => {
const suffix = '.well-known/solid'; const path = '.well-known/solid';
const request: HttpRequest = {} as any; const request: HttpRequest = {} as any;
const response: HttpResponse = {} as any; const response: HttpResponse = {} as any;
let operation: Operation; let operation: Operation;
@ -28,7 +28,7 @@ describe('A StorageDescriptionHandler', (): void => {
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
operation = { operation = {
method: 'GET', method: 'GET',
target: { path: `http://example.com/${suffix}` }, target: { path: `http://example.com/${path}` },
body: new BasicRepresentation(), body: new BasicRepresentation(),
preferences: {}, preferences: {},
}; };
@ -50,7 +50,7 @@ describe('A StorageDescriptionHandler', (): void => {
[ quad(namedNode(target.path), RDF.terms.type, PIM.terms.Storage) ]), [ quad(namedNode(target.path), RDF.terms.type, PIM.terms.Storage) ]),
} as any; } as any;
handler = new StorageDescriptionHandler(store, suffix, converter, describer); handler = new StorageDescriptionHandler(store, path, converter, describer);
}); });
it('only handles GET requests.', async(): Promise<void> => { it('only handles GET requests.', async(): Promise<void> => {

View File

@ -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<WebSocket>;
let socketMap: SetMultiMap<string, WebSocket>;
let emitter: WebSocket2021Emitter;
beforeEach(async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
const webSocket2: jest.Mocked<WebSocket> = 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<void> => {
const webSocket2: jest.Mocked<WebSocket> = 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);
});
});

View File

@ -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<SubscriptionStorage>;
let handler: jest.Mocked<WebSocket2021Handler>;
const route = new AbsolutePathInteractionRoute('http://example.com/foo');
let listener: WebSocket2021Listener;
beforeEach(async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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 });
});
});

View File

@ -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<WebSocket>;
let storage: jest.Mocked<SubscriptionStorage>;
let socketMap: SetMultiMap<string, WebSocket>;
let storer: WebSocket2021Storer;
beforeEach(async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
jest.useFakeTimers();
// Need to create class after fake timers have been enabled
storer = new WebSocket2021Storer(storage, socketMap);
const webSocket2: jest.Mocked<WebSocket> = new EventEmitter() as any;
webSocket2.close = jest.fn();
const webSocketOther: jest.Mocked<WebSocket> = 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();
});
});

View File

@ -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<SubscriptionStorage>;
const route = new AbsolutePathInteractionRoute('http://example.com/foo');
let subscriptionType: WebSocketSubscription2021;
beforeEach(async(): Promise<void> => {
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<void> => {
expect(subscriptionType.type).toBe('WebSocketSubscription2021');
});
it('correctly parses subscriptions.', async(): Promise<void> => {
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<void> => {
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<void> => {
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),
});
});
});

View File

@ -29,6 +29,7 @@ const portNames = [
'SetupMemory', 'SetupMemory',
'SparqlStorage', 'SparqlStorage',
'Subdomains', 'Subdomains',
'WebSocketSubscription2021',
// Unit // Unit
'BaseServerFactory', 'BaseServerFactory',