Merge branch 'main' into versions/next-major

This commit is contained in:
Joachim Van Herwegen 2023-07-25 09:43:15 +02:00
commit 4f17f2baac
39 changed files with 801 additions and 242 deletions

View File

@ -14,7 +14,7 @@ updates:
interval: "daily"
time: "03:35"
timezone: "Europe/Brussels"
target-branch: "versions/6.0.0"
target-branch: "versions/next-major"
ignore:
# Ignore minor and patch version updates
- dependency-name: "*"

View File

@ -42,7 +42,7 @@ jobs:
with:
node-version: 16.x
- name: Check out the project
uses: actions/checkout@v3.5.2
uses: actions/checkout@v3.5.3
with:
ref: ${{ inputs.branch || github.ref }}
- name: Install dependencies and run build scripts

View File

@ -21,7 +21,7 @@ jobs:
tags: ${{ steps.meta-main.outputs.tags || steps.meta-version.outputs.tags }}
steps:
- name: Checkout
uses: actions/checkout@v3.5.2
uses: actions/checkout@v3.5.3
- if: startsWith(github.ref, 'refs/tags/v') || (github.ref == 'refs/heads/main')
name: Docker meta edge and version tag
id: meta-main
@ -55,7 +55,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3.5.2
uses: actions/checkout@v3.5.3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx

View File

@ -21,7 +21,7 @@ jobs:
outputs:
major: ${{ steps.tagged_version.outputs.major || steps.current_version.outputs.major }}
steps:
- uses: actions/checkout@v3.5.2
- uses: actions/checkout@v3.5.3
- uses: actions/setup-node@v3
with:
node-version: '16.x'
@ -43,7 +43,7 @@ jobs:
runs-on: ubuntu-latest
needs: mkdocs-prep
steps:
- uses: actions/checkout@v3.5.2
- uses: actions/checkout@v3.5.3
- uses: actions/setup-python@v4
with:
python-version: 3.x
@ -63,7 +63,7 @@ jobs:
needs: [mkdocs-prep, mkdocs]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3.5.2
- uses: actions/checkout@v3.5.3
- uses: actions/setup-node@v3
with:
node-version: '16.x'

View File

@ -7,7 +7,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3.5.2
- uses: actions/checkout@v3.5.3
- uses: actions/setup-node@v3
with:
node-version: '16.x'
@ -38,7 +38,7 @@ jobs:
- name: Ensure line endings are consistent
run: git config --global core.autocrlf input
- name: Check out repository
uses: actions/checkout@v3.5.2
uses: actions/checkout@v3.5.3
- name: Install dependencies and run build scripts
run: npm ci
- name: Type-check tests
@ -81,7 +81,7 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- name: Check out repository
uses: actions/checkout@v3.5.2
uses: actions/checkout@v3.5.3
- name: Install dependencies and run build scripts
run: npm ci
- name: Run integration tests
@ -105,7 +105,7 @@ jobs:
- name: Ensure line endings are consistent
run: git config --global core.autocrlf input
- name: Check out repository
uses: actions/checkout@v3.5.2
uses: actions/checkout@v3.5.3
- name: Install dependencies and run build scripts
run: npm ci
- name: Run integration tests
@ -127,7 +127,7 @@ jobs:
with:
node-version: '16.x'
- name: Check out repository
uses: actions/checkout@v3.5.2
uses: actions/checkout@v3.5.3
- name: Install dependencies and run build scripts
run: npm ci
- name: Run deploy tests

View File

@ -12,7 +12,7 @@ jobs:
matrix:
branch:
- 'main'
- 'versions/6.0.0'
- 'versions/next-major'
uses: ./.github/workflows/cth-test.yml
with:
branch: ${{ matrix.branch }}

View File

@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
## [6.0.1](https://github.com/CommunitySolidServer/CommunitySolidServer/compare/v6.0.0...v6.0.1) (2023-06-15)
### Fixes
* Use correct type for Webhook notifications ([c0a881b](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/c0a881b9809d3a551c4cdf63bbd89ce57f3fff8d))
* Make root storage subject of storage description ([9584ab7](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/9584ab7549ecf7ab20fe1e6db28f3c900d9a5392))
* Prevent illegal file paths from being generated ([fdee4b3](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/fdee4b334fa456746e9d2097284321a6c1fa2362))
## [6.0.0](https://github.com/CommunitySolidServer/CommunitySolidServer/compare/v6.0.0-alpha.0...v6.0.0) (2023-05-02)
### Features

View File

@ -1,3 +1,9 @@
# Code of conduct
# Code of Conduct
We follow and adhere to the Solid [Code of Conduct](https://github.com/solid/process/blob/main/code-of-conduct.md).
For our Code of Conduct, we follow and adhere to the Solid [Code of Conduct](https://github.com/solid/process/blob/main/code-of-conduct.md),
but with a different Committee, which should be contacted in the case of violations.
The Committee consists of the following people:
* Joachim Van Herwegen <joachim.vanherwegen@ugent.be>
* Ruben Verborgh <ruben.verborgh@ugent.be>

View File

@ -12,6 +12,7 @@
"handlers": [
{ "@id": "urn:solid-server:default:Middleware" },
{
"@id": "urn:solid-server:default:BaseHttpHandler",
"@type": "WaterfallHandler",
"handlers": [
{ "@id": "urn:solid-server:default:StaticAssetHandler" },

View File

@ -11,6 +11,7 @@
"handlers": [
{ "@id": "urn:solid-server:default:Middleware" },
{
"@id": "urn:solid-server:default:BaseHttpHandler",
"@type": "WaterfallHandler",
"handlers": [
{ "@id": "urn:solid-server:default:StaticAssetHandler" },

View File

@ -2,10 +2,10 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles the generation and serialization of notifications for WebHookChannel2023.",
"comment": "Handles the generation and serialization of notifications for WebhookChannel2023.",
"@id": "urn:solid-server:default:WebHookNotificationHandler",
"@type": "TypedNotificationHandler",
"type": "http://www.w3.org/ns/solid/notifications#WebHookChannel2023",
"type": "http://www.w3.org/ns/solid/notifications#WebhookChannel2023",
"source": {
"@type": "ComposedNotificationHandler",
"generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" },
@ -14,7 +14,7 @@
}
},
{
"comment": "Emits serialized notifications through HTTP requests to the WebHook.",
"comment": "Emits serialized notifications through HTTP requests to the Webhook.",
"@id": "urn:solid-server:default:WebHookEmitter",
"@type": "WebHookEmitter",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },

View File

@ -5,7 +5,7 @@
"@id": "urn:solid-server:default:WebHookRoute",
"@type": "RelativePathInteractionRoute",
"base": { "@id": "urn:solid-server:default:NotificationRoute" },
"relativePath": "/WebHookChannel2023/"
"relativePath": "/WebhookChannel2023/"
},
{
"@id": "urn:solid-server:default:WebHookWebIdRoute",
@ -15,11 +15,11 @@
},
{
"comment": "Handles the WebHookChannel2023 WebID.",
"comment": "Handles the WebhookChannel2023 WebID.",
"@id": "urn:solid-server:default:WebHookWebId",
"@type": "OperationRouterHandler",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"allowedPathNames": [ "/WebHookChannel2023/webId$" ],
"allowedPathNames": [ "/WebhookChannel2023/webId$" ],
"handler": {
"@type": "WebHookWebId",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }

View File

@ -2,12 +2,12 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles the subscriptions targeting a WebHookChannel2023.",
"comment": "Handles the subscriptions targeting a WebhookChannel2023.",
"@id": "urn:solid-server:default:WebHookRouter",
"@type": "OperationRouterHandler",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"allowedMethods": [ "HEAD", "GET", "POST" ],
"allowedPathNames": [ "/WebHookChannel2023/$" ],
"allowedPathNames": [ "/WebhookChannel2023/$" ],
"handler": {
"@id": "urn:solid-server:default:WebHookSubscriber",
"@type": "NotificationSubscriber",
@ -20,7 +20,7 @@
}
},
{
"comment": "Contains all the metadata relevant for a WebHookChannel2023.",
"comment": "Contains all the metadata relevant for a WebhookChannel2023.",
"@id": "urn:solid-server:default:WebHookChannel2023Type",
"@type": "WebhookChannel2023Type",
"route": { "@id": "urn:solid-server:default:WebHookRoute" },

View File

@ -38,6 +38,7 @@
"comment": "A server that stores its resources on disk while enforcing quota."
},
{
"comment": "Sets the maximum size of a single pod to 7KB.",
"@id": "urn:solid-server:default:QuotaStrategy",
"@type": "PodQuotaStrategy",
"limit_amount": 7000,

View File

@ -2,13 +2,17 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Converts many RDF serialization to Quad objects.",
"comment": "Converts many RDF serialization to Quad objects. Caching notification related contexts to prevent too many requests.",
"@id": "urn:solid-server:default:RdfToQuadConverter",
"@type": "RdfToQuadConverter",
"contexts": [
{
"RdfToQuadConverter:_contexts_key": "https://www.w3.org/ns/solid/notification/v1",
"RdfToQuadConverter:_contexts_value": "@css:templates/contexts/notification.jsonld"
},
{
"RdfToQuadConverter:_contexts_key": "https://www.w3.org/ns/activitystreams",
"RdfToQuadConverter:_contexts_value": "@css:templates/contexts/activitystreams.jsonld"
}
]
}

View File

@ -1,10 +1,10 @@
# Releasing a new version
# Releasing a new major version
This is only relevant if you are a developer with push access responsible for doing a new release.
Steps to follow:
* Merge `main` into `versions/x.0.0`.
* Merge `main` into `versions/next-major`.
* Verify if there are issues when upgrading an existing installation to the new version.
* Can the data still be accessed?
* Does authentication still work?
@ -15,7 +15,7 @@ Steps to follow:
* Automatically updates Components.js references to the new version.
Committed with `chore(release): Update configs to vx.0.0`.
* Updates the `package.json`, and generates the new entries in `CHANGELOG.md`.
Commited with `chore(release): Release version vx.0.0 of the npm package`
Commits with `chore(release): Release version vx.0.0 of the npm package`
* Optionally run `npx commit-and-tag-version -r major --dry-run` to preview the commands that will be run
and the changes to `CHANGELOG.md`.
* The `postrelease` script will now prompt you to manually edit the `CHANGELOG.md`.
@ -24,19 +24,25 @@ Steps to follow:
Documentation can be removed.
* Press any key in your terminal when your changes are ready.
* The `postrelease` script will amend the release commit, create an annotated tag and push changes to origin.
* Merge `versions/x.0.0` into `main` and push.
* Merge `versions/next-major` into `main` and push.
* Do a GitHub release.
* `npm publish`
* Check if there is a `next` tag that needs to be replaced.
* `npm dist-tag add @solid/community-server@x.0.0 next`
* Rename the `versions/x.0.0` branch to the next version.
* Update `.github/workflows/schedule.yml` and `.github/dependabot.yml` to point at the new branch.
* Potentially upgrade dependent repositories:
* Recipes at <https://github.com/CommunitySolidServer/recipes/>
* Tutorials at <https://github.com/CommunitySolidServer/tutorials/>
* Generator at <https://github.com/CommunitySolidServer/configuration-generator/>
* Hello world component at <https://github.com/CommunitySolidServer/hello-world-component/>
Changes when doing a pre-release of a major version:
## Changes when doing a pre-release
* Version with `npm run release -- -r major --prerelease alpha`
* Do not merge `versions/x.0.0` into `main`.
* Do not merge `versions/next-major` into `main`.
* Publish with `npm publish --tag next`.
* Do not update the branch or anything related.
## Changes when doing a minor release
* Version with `npm run release -- -r minor`
* Do not merge `versions/next-major` into `main`.

View File

@ -27,7 +27,7 @@ Doing a GET to `http://localhost:3000/.well-known/solid` then gives the followin
<http://localhost:3000/.well-known/solid>
a <http://www.w3.org/ns/pim/space#Storage> ;
notify:subscription <http://localhost:3000/.notifications/WebSocketChannel2023/> ,
<http://localhost:3000/.notifications/WebHookChannel2023/> .
<http://localhost:3000/.notifications/WebhookChannel2023/> .
<http://localhost:3000/.notifications/WebSocketChannel2023/>
notify:channelType notify:WebSocketChannel2023 ;
notify:feature notify:accept ,
@ -35,8 +35,8 @@ Doing a GET to `http://localhost:3000/.well-known/solid` then gives the followin
notify:rate ,
notify:startAt ,
notify:state .
<http://localhost:3000/.notifications/WebSocketChannel2023/>
notify:channelType notify:WebHookChannel2023;
<http://localhost:3000/.notifications/WebhookChannel2023/>
notify:channelType notify:WebhookChannel2023;
notify:feature notify:accept ,
notify:endAt ,
notify:rate ,
@ -61,7 +61,7 @@ Requests without `Read` permission will be rejected.
There are currently up to two supported ways to get notifications in CSS, depending on your configuration:
the notification channel types [`WebSocketChannel2023`](https://solid.github.io/notifications/websocket-channel-2023);
and [`WebHookChannel2023`](https://solid.github.io/notifications/webhook-channel-2023).
and [`WebhookChannel2023`](https://solid.github.io/notifications/webhook-channel-2023).
### WebSockets
@ -98,30 +98,30 @@ const ws = new WebSocket(receiveFrom);
ws.on('message', (notification) => console.log(notification));
```
### WebHooks
### Webhooks
Similar to the WebSocket subscription, below is sample JSON-LD
that would be sent to `http://localhost:3000/.notifications/WebHookChannel2023/`:
that would be sent to `http://localhost:3000/.notifications/WebhookChannel2023/`:
```json
{
"@context": [ "https://www.w3.org/ns/solid/notification/v1" ],
"type": "http://www.w3.org/ns/solid/notifications#WebHookChannel2023",
"type": "http://www.w3.org/ns/solid/notifications#WebhookChannel2023",
"topic": "http://localhost:3000/foo",
"sendTo": "https://example.com/webhook"
}
```
Note that this document has an additional `sendTo` field.
This is the WebHook URL of your server, the URL to which you want the notifications to be sent.
This is the Webhook URL of your server, the URL to which you want the notifications to be sent.
The response would then be something like this:
```json
{
"@context": [ "https://www.w3.org/ns/solid/notification/v1" ],
"id": "http://localhost:3000/.notifications/WebHookChannel2023/eeaf2c17-699a-4e53-8355-e91d13807e5f",
"type": "http://www.w3.org/ns/solid/notifications#WebHookChannel2023",
"id": "http://localhost:3000/.notifications/WebhookChannel2023/eeaf2c17-699a-4e53-8355-e91d13807e5f",
"type": "http://www.w3.org/ns/solid/notifications#WebhookChannel2023",
"topic": "http://localhost:3000/foo",
"sendTo": "https://example.com/webhook"
}

View File

@ -39,7 +39,8 @@ module.exports = {
'js',
],
testEnvironment: 'node',
setupFilesAfterEnv: [ 'jest-rdf', '<rootDir>/test/util/SetupTests.ts' ],
globalSetup: '<rootDir>/test/util/SetupTests.ts',
setupFilesAfterEnv: [ 'jest-rdf' ],
collectCoverage: false,
// See https://github.com/matthieubosquet/ts-dpop/issues/13
moduleNameMapper: {

16
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@solid/community-server",
"version": "6.0.0",
"version": "6.0.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@solid/community-server",
"version": "6.0.0",
"version": "6.0.1",
"license": "MIT",
"dependencies": {
"@comunica/context-entries": "^2.6.8",
@ -15179,9 +15179,9 @@
}
},
"node_modules/word-wrap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz",
"integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
@ -27505,9 +27505,9 @@
}
},
"word-wrap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz",
"integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==",
"dev": true
},
"wordwrap": {

View File

@ -1,6 +1,6 @@
{
"name": "@solid/community-server",
"version": "6.0.0",
"version": "6.0.1",
"description": "Community Solid Server: an open and modular implementation of the Solid specifications",
"keywords": [
"solid",

View File

@ -50,10 +50,14 @@ export class AllowAcceptHeaderWriter extends MetadataWriter {
// POST is only allowed on containers.
// Metadata only has the resource URI in case it has resource metadata.
if (this.isPostAllowed(metadata)) {
if (!this.isPostAllowed(metadata)) {
allowedMethods.delete('POST');
}
if (!this.isPutAllowed(metadata)) {
allowedMethods.delete('PUT');
}
if (!this.isDeleteAllowed(metadata)) {
allowedMethods.delete('DELETE');
}
@ -76,7 +80,14 @@ export class AllowAcceptHeaderWriter extends MetadataWriter {
* otherwise it is just a blank node.
*/
private isPostAllowed(metadata: RepresentationMetadata): boolean {
return metadata.has(RDF.terms.type, LDP.terms.Resource) && !isContainerPath(metadata.identifier.value);
return !metadata.has(RDF.terms.type, LDP.terms.Resource) || isContainerPath(metadata.identifier.value);
}
/**
* PUT is not allowed on existing containers.
*/
private isPutAllowed(metadata: RepresentationMetadata): boolean {
return !metadata.has(RDF.terms.type, LDP.terms.Resource) || !isContainerPath(metadata.identifier.value);
}
/**

View File

@ -1,6 +1,7 @@
import { OkResponseDescription } from '../../http/output/response/OkResponseDescription';
import type { ResponseDescription } from '../../http/output/response/ResponseDescription';
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import type { ResourceStore } from '../../storage/ResourceStore';
import { INTERNAL_QUADS } from '../../util/ContentTypes';
import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError';
@ -32,7 +33,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.path.length)) };
const container = this.getStorageIdentifier(target);
const representation = await this.store.getRepresentation(container, {});
representation.data.destroy();
if (!representation.metadata.has(RDF.terms.type, PIM.terms.Storage)) {
@ -43,10 +44,17 @@ export class StorageDescriptionHandler extends OperationHttpHandler {
}
public async handle({ operation: { target }}: OperationHttpHandlerInput): Promise<ResponseDescription> {
const quads = await this.describer.handle(target);
const quads = await this.describer.handle(this.getStorageIdentifier(target));
const representation = new BasicRepresentation(quads, INTERNAL_QUADS);
return new OkResponseDescription(representation.metadata, representation.data);
}
/**
* Determine the identifier of the root storage based on the identifier of the root storage description resource.
*/
protected getStorageIdentifier(descriptionIdentifier: ResourceIdentifier): ResourceIdentifier {
return { path: ensureTrailingSlash(descriptionIdentifier.path.slice(0, -this.path.length)) };
}
}

View File

@ -14,7 +14,7 @@ export interface WebhookChannel2023 extends NotificationChannel {
/**
* The "WebHookChannel2023" type.
*/
type: typeof NOTIFY.WebHookChannel2023;
type: typeof NOTIFY.WebhookChannel2023;
/**
* Where the notifications have to be sent.
*/
@ -22,7 +22,7 @@ export interface WebhookChannel2023 extends NotificationChannel {
}
export function isWebHook2023Channel(channel: NotificationChannel): channel is WebhookChannel2023 {
return channel.type === NOTIFY.WebHookChannel2023;
return channel.type === NOTIFY.WebhookChannel2023;
}
/**
@ -47,7 +47,7 @@ export class WebhookChannel2023Type extends BaseChannelType {
*/
public constructor(route: InteractionRoute, webIdRoute: InteractionRoute, stateHandler: StateHandler,
features?: string[]) {
super(NOTIFY.terms.WebHookChannel2023,
super(NOTIFY.terms.WebhookChannel2023,
route,
features,
[{ path: NOTIFY.sendTo, minCount: 1, maxCount: 1 }]);
@ -62,7 +62,7 @@ export class WebhookChannel2023Type extends BaseChannelType {
return {
...channel,
type: NOTIFY.WebHookChannel2023,
type: NOTIFY.WebhookChannel2023,
sendTo: sendTo.value,
};
}

View File

@ -140,6 +140,9 @@ const mediaRange = new RegExp(`${tchar.source}+/${tchar.source}+`, 'u');
* Replaces all double quoted strings in the input string with `"0"`, `"1"`, etc.
* @param input - The Accept header string.
*
* @throws {@link BadRequestHttpError}
* Thrown if invalid characters are detected in a quoted string.
*
* @returns The transformed string and a map with keys `"0"`, etc. and values the original string that was there.
*/
export function transformQuotedStrings(input: string): { result: string; replacements: Record<string, string> } {
@ -163,6 +166,8 @@ export function transformQuotedStrings(input: string): { result: string; replace
* Splits the input string on commas, trims all parts and filters out empty ones.
*
* @param input - Input header string.
*
* @returns An array of trimmed strings.
*/
export function splitAndClean(input: string): string[] {
return input.split(',')
@ -175,44 +180,67 @@ export function splitAndClean(input: string): string[] {
*
* @param qvalue - Input qvalue string (so "q=....").
*
* @throws {@link BadRequestHttpError}
* Thrown on invalid syntax.
* @returns true if q value is valid, false otherwise.
*/
function testQValue(qvalue: string): void {
if (!/^(?:(?:0(?:\.\d{0,3})?)|(?:1(?:\.0{0,3})?))$/u.test(qvalue)) {
logger.warn(`Invalid q value: ${qvalue}`);
throw new BadRequestHttpError(
`Invalid q value: ${qvalue} does not match ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3("0") ] ).`,
);
function isValidQValue(qvalue: string): boolean {
return /^(?:(?:0(?:\.\d{0,3})?)|(?:1(?:\.0{0,3})?))$/u.test(qvalue);
}
/**
* Converts a qvalue to a number.
* Returns 1 if the value is not a valid number or 1 if it is more than 1.
* Returns 0 if the value is negative.
* Otherwise, the parsed value is returned.
*
* @param qvalue - Value to convert.
*/
function parseQValue(qvalue: string): number {
const result = Number(qvalue);
if (Number.isNaN(result) || result >= 1) {
return 1;
}
if (result < 0) {
return 0;
}
return result;
}
/**
* Logs a warning to indicate there was an invalid value.
* Throws a {@link BadRequestHttpError} in case `strict` is `true`.
*
* @param message - Message to log and potentially put in the error.
* @param strict - `true` if an error needs to be thrown.
*/
function handleInvalidValue(message: string, strict: boolean): void | never {
logger.warn(message);
if (strict) {
throw new BadRequestHttpError(message);
}
}
/**
* Parses a list of split parameters and checks their validity.
* Parses a list of split parameters and checks their validity. Parameters with invalid
* syntax are ignored and not returned.
*
* @param parameters - A list of split parameters (token [ "=" ( token / quoted-string ) ])
* @param replacements - The double quoted strings that need to be replaced.
*
*
* @throws {@link BadRequestHttpError}
* Thrown on invalid parameter syntax.
* @param strict - Determines if invalid values throw errors (`true`) or log warnings (`false`). Defaults to `false`.
*
* @returns An array of name/value objects corresponding to the parameters.
*/
export function parseParameters(parameters: string[], replacements: Record<string, string>):
export function parseParameters(parameters: string[], replacements: Record<string, string>, strict = false):
{ name: string; value: string }[] {
return parameters.map((param): { name: string; value: string } => {
return parameters.reduce<{ name: string; value: string }[]>((acc, param): { name: string; value: string }[] => {
const [ name, rawValue ] = param.split('=').map((str): string => str.trim());
// Test replaced string for easier check
// parameter = token "=" ( token / quoted-string )
// second part is optional for certain parameters
if (!(token.test(name) && (!rawValue || /^"\d+"$/u.test(rawValue) || token.test(rawValue)))) {
logger.warn(`Invalid parameter value: ${name}=${replacements[rawValue] || rawValue}`);
throw new BadRequestHttpError(
`Invalid parameter value: ${name}=${replacements[rawValue] || rawValue} ` +
`does not match (token ( "=" ( token / quoted-string ))?). `,
);
handleInvalidValue(`Invalid parameter value: ${name}=${replacements[rawValue] || rawValue} ` +
`does not match (token ( "=" ( token / quoted-string ))?). `, strict);
return acc;
}
let value = rawValue;
@ -220,8 +248,9 @@ export function parseParameters(parameters: string[], replacements: Record<strin
value = replacements[rawValue];
}
return { name, value };
});
acc.push({ name, value });
return acc;
}, []);
}
/**
@ -229,24 +258,24 @@ export function parseParameters(parameters: string[], replacements: Record<strin
* For every parameter value that is a double quoted string,
* we check if it is a key in the replacements map.
* If yes the value from the map gets inserted instead.
* Invalid q values and parameter values are ignored and not returned.
*
* @param part - A string corresponding to a media range and its corresponding parameters.
* @param replacements - The double quoted strings that need to be replaced.
* @param strict - Determines if invalid values throw errors (`true`) or log warnings (`false`). Defaults to `false`.
*
* @throws {@link BadRequestHttpError}
* Thrown on invalid type, qvalue or parameter syntax.
*
* @returns {@link Accept} object corresponding to the header string.
* @returns {@link Accept | undefined} object corresponding to the header string, or
* undefined if an invalid type or sub-type is detected.
*/
function parseAcceptPart(part: string, replacements: Record<string, string>): Accept {
function parseAcceptPart(part: string, replacements: Record<string, string>, strict: boolean): Accept | undefined {
const [ range, ...parameters ] = part.split(';').map((param): string => param.trim());
// No reason to test differently for * since we don't check if the type exists
if (!mediaRange.test(range)) {
logger.warn(`Invalid Accept range: ${range}`);
throw new BadRequestHttpError(
`Invalid Accept range: ${range} does not match ( "*/*" / ( token "/" "*" ) / ( token "/" token ) )`,
handleInvalidValue(
`Invalid Accept range: ${range} does not match ( "*/*" / ( token "/" "*" ) / ( token "/" token ) )`, strict,
);
return;
}
let weight = 1;
@ -258,13 +287,16 @@ function parseAcceptPart(part: string, replacements: Record<string, string>): Ac
if (name === 'q') {
// Extension parameters appear after the q value
map = extensionParams;
testQValue(value);
weight = Number.parseFloat(value);
if (!isValidQValue(value)) {
handleInvalidValue(`Invalid q value for range ${range}: ${value
} does not match ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3("0") ] ).`, strict);
}
weight = parseQValue(value);
} else {
if (!value && map !== extensionParams) {
logger.warn(`Invalid Accept parameter ${name}`);
throw new BadRequestHttpError(`Invalid Accept parameter ${name}: ` +
`Accept parameter values are not optional when preceding the q value`);
handleInvalidValue(`Invalid Accept parameter ${name}: ` +
`Accept parameter values are not optional when preceding the q value`, strict);
return;
}
map[name] = value || '';
}
@ -282,14 +314,13 @@ function parseAcceptPart(part: string, replacements: Record<string, string>): Ac
/**
* Parses an Accept-* header where each part is only a value and a weight, so roughly /.*(q=.*)?/ separated by commas.
* The returned weights default to 1 if no q value is found or the q value is invalid.
* @param input - Input header string.
*
* @throws {@link BadRequestHttpError}
* Thrown on invalid qvalue syntax.
* @param strict - Determines if invalid values throw errors (`true`) or log warnings (`false`). Defaults to `false`.
*
* @returns An array of ranges and weights.
*/
function parseNoParameters(input: string): AcceptHeader[] {
function parseNoParameters(input: string, strict = false): AcceptHeader[] {
const parts = splitAndClean(input);
return parts.map((part): AcceptHeader => {
@ -297,12 +328,15 @@ function parseNoParameters(input: string): AcceptHeader[] {
const result = { range, weight: 1 };
if (qvalue) {
if (!qvalue.startsWith('q=')) {
logger.warn(`Only q parameters are allowed in ${input}`);
throw new BadRequestHttpError(`Only q parameters are allowed in ${input}`);
handleInvalidValue(`Only q parameters are allowed in ${input}`, strict);
return result;
}
const val = qvalue.slice(2);
testQValue(val);
result.weight = Number.parseFloat(val);
if (!isValidQValue(val)) {
handleInvalidValue(`Invalid q value for range ${range}: ${val
} does not match ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3("0") ] ).`, strict);
}
result.weight = parseQValue(val);
}
return result;
}).sort((left, right): number => right.weight - left.weight);
@ -314,17 +348,25 @@ function parseNoParameters(input: string): AcceptHeader[] {
* Parses an Accept header string.
*
* @param input - The Accept header string.
* @param strict - Determines if invalid values throw errors (`true`) or log warnings (`false`). Defaults to `false`.
*
* @throws {@link BadRequestHttpError}
* Thrown on invalid header syntax.
*
* @returns An array of {@link Accept} objects, sorted by weight.
* @returns An array of {@link Accept} objects, sorted by weight. Accept parts
* with invalid syntax are ignored and removed from the returned array.
*/
export function parseAccept(input: string): Accept[] {
export function parseAccept(input: string, strict = false): Accept[] {
// Quoted strings could prevent split from having correct results
const { result, replacements } = transformQuotedStrings(input);
return splitAndClean(result)
.map((part): Accept => parseAcceptPart(part, replacements))
.reduce<Accept[]>((acc, part): Accept[] => {
const partOrUndef = parseAcceptPart(part, replacements, strict);
if (partOrUndef !== undefined) {
acc.push(partOrUndef);
}
return acc;
}, [])
.sort((left, right): number => right.weight - left.weight);
}
@ -332,70 +374,65 @@ export function parseAccept(input: string): Accept[] {
* Parses an Accept-Charset header string.
*
* @param input - The Accept-Charset header string.
* @param strict - Determines if invalid values throw errors (`true`) or log warnings (`false`). Defaults to `false`.
*
* @throws {@link BadRequestHttpError}
* Thrown on invalid header syntax.
*
* @returns An array of {@link AcceptCharset} objects, sorted by weight.
* @returns An array of {@link AcceptCharset} objects, sorted by weight. Invalid ranges
* are ignored and not returned.
*/
export function parseAcceptCharset(input: string): AcceptCharset[] {
export function parseAcceptCharset(input: string, strict = false): AcceptCharset[] {
const results = parseNoParameters(input);
results.forEach((result): void => {
return results.filter((result): boolean => {
if (!token.test(result.range)) {
logger.warn(`Invalid Accept-Charset range: ${result.range}`);
throw new BadRequestHttpError(
`Invalid Accept-Charset range: ${result.range} does not match (content-coding / "identity" / "*")`,
handleInvalidValue(
`Invalid Accept-Charset range: ${result.range} does not match (content-coding / "identity" / "*")`, strict,
);
return false;
}
return true;
});
return results;
}
/**
* Parses an Accept-Encoding header string.
*
* @param input - The Accept-Encoding header string.
* @param strict - Determines if invalid values throw errors (`true`) or log warnings (`false`). Defaults to `false`.
*
* @throws {@link BadRequestHttpError}
* Thrown on invalid header syntax.
*
* @returns An array of {@link AcceptEncoding} objects, sorted by weight.
* @returns An array of {@link AcceptEncoding} objects, sorted by weight. Invalid ranges
* are ignored and not returned.
*/
export function parseAcceptEncoding(input: string): AcceptEncoding[] {
export function parseAcceptEncoding(input: string, strict = false): AcceptEncoding[] {
const results = parseNoParameters(input);
results.forEach((result): void => {
return results.filter((result): boolean => {
if (!token.test(result.range)) {
logger.warn(`Invalid Accept-Encoding range: ${result.range}`);
throw new BadRequestHttpError(`Invalid Accept-Encoding range: ${result.range} does not match (charset / "*")`);
handleInvalidValue(`Invalid Accept-Encoding range: ${result.range} does not match (charset / "*")`, strict);
return false;
}
return true;
});
return results;
}
/**
* Parses an Accept-Language header string.
*
* @param input - The Accept-Language header string.
* @param strict - Determines if invalid values throw errors (`true`) or log warnings (`false`). Defaults to `false`.
*
* @throws {@link BadRequestHttpError}
* Thrown on invalid header syntax.
*
* @returns An array of {@link AcceptLanguage} objects, sorted by weight.
* @returns An array of {@link AcceptLanguage} objects, sorted by weight. Invalid ranges
* are ignored and not returned.
*/
export function parseAcceptLanguage(input: string): AcceptLanguage[] {
export function parseAcceptLanguage(input: string, strict = false): AcceptLanguage[] {
const results = parseNoParameters(input);
results.forEach((result): void => {
return results.filter((result): boolean => {
// (1*8ALPHA *("-" 1*8alphanum)) / "*"
if (result.range !== '*' && !/^[a-zA-Z]{1,8}(?:-[a-zA-Z0-9]{1,8})*$/u.test(result.range)) {
logger.warn(
`Invalid Accept-Language range: ${result.range}`,
);
throw new BadRequestHttpError(
`Invalid Accept-Language range: ${result.range} does not match ((1*8ALPHA *("-" 1*8alphanum)) / "*")`,
handleInvalidValue(
`Invalid Accept-Language range: ${result.range} does not match ((1*8ALPHA *("-" 1*8alphanum)) / "*")`, strict,
);
return false;
}
return true;
});
return results;
}
// eslint-disable-next-line max-len
@ -405,24 +442,21 @@ const rfc1123Date = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|
* Parses an Accept-DateTime header string.
*
* @param input - The Accept-DateTime header string.
* @param strict - Determines if invalid values throw errors (`true`) or log warnings (`false`). Defaults to `false`.
*
* @returns An array with a single {@link AcceptDatetime} object.
* @returns An array with a single {@link AcceptDatetime} object,
* or an empty array if a range in an invalid format is detected.
*/
export function parseAcceptDateTime(input: string): AcceptDatetime[] {
const results: AcceptDatetime[] = [];
export function parseAcceptDateTime(input: string, strict = false): AcceptDatetime[] {
const range = input.trim();
if (range) {
if (!rfc1123Date.test(range)) {
logger.warn(
`Invalid Accept-DateTime range: ${range}`,
);
throw new BadRequestHttpError(
`Invalid Accept-DateTime range: ${range} does not match the RFC1123 format`,
);
}
results.push({ range, weight: 1 });
if (!range) {
return [];
}
return results;
if (!rfc1123Date.test(range)) {
handleInvalidValue(`Invalid Accept-DateTime range: ${range} does not match the RFC1123 format`, strict);
return [];
}
return [{ range, weight: 1 }];
}
/**

View File

@ -155,16 +155,31 @@ export function toCanonicalUriPath(path: string): string {
encodeURIComponent(decodeURIComponent(part)));
}
// Characters not allowed in a Windows file path
const forbiddenSymbols = {
'<': '%3C',
'>': '%3E',
':': '%3A',
'"': '%22',
'|': '%7C',
'?': '%3F',
// `*` does not get converted by `encodeUriComponent`
'*': '%2A',
} as const;
const forbiddenRegex = new RegExp(`[${Object.keys(forbiddenSymbols).join('')}]`, 'ug');
/**
* This function is used when converting a URI to a file path. Decodes all components of a URI path,
* with the exception of encoded slash characters, as this would lead to unexpected file locations
* being targeted (resulting in erroneous behaviour of the file based backend).
* Characters that would result in an illegal file path remain percent encoded.
*
* @param path - The path to decode the URI path components of.
* @returns A decoded copy of the provided URI path (ignoring encoded slash characters).
*/
export function decodeUriPathComponents(path: string): string {
return transformPathComponents(path, decodeURIComponent);
return transformPathComponents(path, (part): string => decodeURIComponent(part)
// The characters replaced below result in illegal Windows file paths so need to be encoded
.replace(forbiddenRegex, (val): string => forbiddenSymbols[val as keyof typeof forbiddenSymbols]));
}
/**

View File

@ -210,7 +210,7 @@ export const NOTIFY = createVocabulary('http://www.w3.org/ns/solid/notifications
'topic',
'webhookAuth',
'WebHookChannel2023',
'WebhookChannel2023',
'WebSocketChannel2023',
);

View File

@ -0,0 +1,379 @@
{
"@context": {
"@vocab": "_:",
"xsd": "http://www.w3.org/2001/XMLSchema#",
"as": "https://www.w3.org/ns/activitystreams#",
"ldp": "http://www.w3.org/ns/ldp#",
"vcard": "http://www.w3.org/2006/vcard/ns#",
"id": "@id",
"type": "@type",
"Accept": "as:Accept",
"Activity": "as:Activity",
"IntransitiveActivity": "as:IntransitiveActivity",
"Add": "as:Add",
"Announce": "as:Announce",
"Application": "as:Application",
"Arrive": "as:Arrive",
"Article": "as:Article",
"Audio": "as:Audio",
"Block": "as:Block",
"Collection": "as:Collection",
"CollectionPage": "as:CollectionPage",
"Relationship": "as:Relationship",
"Create": "as:Create",
"Delete": "as:Delete",
"Dislike": "as:Dislike",
"Document": "as:Document",
"Event": "as:Event",
"Follow": "as:Follow",
"Flag": "as:Flag",
"Group": "as:Group",
"Ignore": "as:Ignore",
"Image": "as:Image",
"Invite": "as:Invite",
"Join": "as:Join",
"Leave": "as:Leave",
"Like": "as:Like",
"Link": "as:Link",
"Mention": "as:Mention",
"Note": "as:Note",
"Object": "as:Object",
"Offer": "as:Offer",
"OrderedCollection": "as:OrderedCollection",
"OrderedCollectionPage": "as:OrderedCollectionPage",
"Organization": "as:Organization",
"Page": "as:Page",
"Person": "as:Person",
"Place": "as:Place",
"Profile": "as:Profile",
"Question": "as:Question",
"Reject": "as:Reject",
"Remove": "as:Remove",
"Service": "as:Service",
"TentativeAccept": "as:TentativeAccept",
"TentativeReject": "as:TentativeReject",
"Tombstone": "as:Tombstone",
"Undo": "as:Undo",
"Update": "as:Update",
"Video": "as:Video",
"View": "as:View",
"Listen": "as:Listen",
"Read": "as:Read",
"Move": "as:Move",
"Travel": "as:Travel",
"IsFollowing": "as:IsFollowing",
"IsFollowedBy": "as:IsFollowedBy",
"IsContact": "as:IsContact",
"IsMember": "as:IsMember",
"subject": {
"@id": "as:subject",
"@type": "@id"
},
"relationship": {
"@id": "as:relationship",
"@type": "@id"
},
"actor": {
"@id": "as:actor",
"@type": "@id"
},
"attributedTo": {
"@id": "as:attributedTo",
"@type": "@id"
},
"attachment": {
"@id": "as:attachment",
"@type": "@id"
},
"bcc": {
"@id": "as:bcc",
"@type": "@id"
},
"bto": {
"@id": "as:bto",
"@type": "@id"
},
"cc": {
"@id": "as:cc",
"@type": "@id"
},
"context": {
"@id": "as:context",
"@type": "@id"
},
"current": {
"@id": "as:current",
"@type": "@id"
},
"first": {
"@id": "as:first",
"@type": "@id"
},
"generator": {
"@id": "as:generator",
"@type": "@id"
},
"icon": {
"@id": "as:icon",
"@type": "@id"
},
"image": {
"@id": "as:image",
"@type": "@id"
},
"inReplyTo": {
"@id": "as:inReplyTo",
"@type": "@id"
},
"items": {
"@id": "as:items",
"@type": "@id"
},
"instrument": {
"@id": "as:instrument",
"@type": "@id"
},
"orderedItems": {
"@id": "as:items",
"@type": "@id",
"@container": "@list"
},
"last": {
"@id": "as:last",
"@type": "@id"
},
"location": {
"@id": "as:location",
"@type": "@id"
},
"next": {
"@id": "as:next",
"@type": "@id"
},
"object": {
"@id": "as:object",
"@type": "@id"
},
"oneOf": {
"@id": "as:oneOf",
"@type": "@id"
},
"anyOf": {
"@id": "as:anyOf",
"@type": "@id"
},
"closed": {
"@id": "as:closed",
"@type": "xsd:dateTime"
},
"origin": {
"@id": "as:origin",
"@type": "@id"
},
"accuracy": {
"@id": "as:accuracy",
"@type": "xsd:float"
},
"prev": {
"@id": "as:prev",
"@type": "@id"
},
"preview": {
"@id": "as:preview",
"@type": "@id"
},
"replies": {
"@id": "as:replies",
"@type": "@id"
},
"result": {
"@id": "as:result",
"@type": "@id"
},
"audience": {
"@id": "as:audience",
"@type": "@id"
},
"partOf": {
"@id": "as:partOf",
"@type": "@id"
},
"tag": {
"@id": "as:tag",
"@type": "@id"
},
"target": {
"@id": "as:target",
"@type": "@id"
},
"to": {
"@id": "as:to",
"@type": "@id"
},
"url": {
"@id": "as:url",
"@type": "@id"
},
"altitude": {
"@id": "as:altitude",
"@type": "xsd:float"
},
"content": "as:content",
"contentMap": {
"@id": "as:content",
"@container": "@language"
},
"name": "as:name",
"nameMap": {
"@id": "as:name",
"@container": "@language"
},
"duration": {
"@id": "as:duration",
"@type": "xsd:duration"
},
"endTime": {
"@id": "as:endTime",
"@type": "xsd:dateTime"
},
"height": {
"@id": "as:height",
"@type": "xsd:nonNegativeInteger"
},
"href": {
"@id": "as:href",
"@type": "@id"
},
"hreflang": "as:hreflang",
"latitude": {
"@id": "as:latitude",
"@type": "xsd:float"
},
"longitude": {
"@id": "as:longitude",
"@type": "xsd:float"
},
"mediaType": "as:mediaType",
"published": {
"@id": "as:published",
"@type": "xsd:dateTime"
},
"radius": {
"@id": "as:radius",
"@type": "xsd:float"
},
"rel": "as:rel",
"startIndex": {
"@id": "as:startIndex",
"@type": "xsd:nonNegativeInteger"
},
"startTime": {
"@id": "as:startTime",
"@type": "xsd:dateTime"
},
"summary": "as:summary",
"summaryMap": {
"@id": "as:summary",
"@container": "@language"
},
"totalItems": {
"@id": "as:totalItems",
"@type": "xsd:nonNegativeInteger"
},
"units": "as:units",
"updated": {
"@id": "as:updated",
"@type": "xsd:dateTime"
},
"width": {
"@id": "as:width",
"@type": "xsd:nonNegativeInteger"
},
"describes": {
"@id": "as:describes",
"@type": "@id"
},
"formerType": {
"@id": "as:formerType",
"@type": "@id"
},
"deleted": {
"@id": "as:deleted",
"@type": "xsd:dateTime"
},
"inbox": {
"@id": "ldp:inbox",
"@type": "@id"
},
"outbox": {
"@id": "as:outbox",
"@type": "@id"
},
"following": {
"@id": "as:following",
"@type": "@id"
},
"followers": {
"@id": "as:followers",
"@type": "@id"
},
"streams": {
"@id": "as:streams",
"@type": "@id"
},
"preferredUsername": "as:preferredUsername",
"endpoints": {
"@id": "as:endpoints",
"@type": "@id"
},
"uploadMedia": {
"@id": "as:uploadMedia",
"@type": "@id"
},
"proxyUrl": {
"@id": "as:proxyUrl",
"@type": "@id"
},
"liked": {
"@id": "as:liked",
"@type": "@id"
},
"oauthAuthorizationEndpoint": {
"@id": "as:oauthAuthorizationEndpoint",
"@type": "@id"
},
"oauthTokenEndpoint": {
"@id": "as:oauthTokenEndpoint",
"@type": "@id"
},
"provideClientKey": {
"@id": "as:provideClientKey",
"@type": "@id"
},
"signClientKey": {
"@id": "as:signClientKey",
"@type": "@id"
},
"sharedInbox": {
"@id": "as:sharedInbox",
"@type": "@id"
},
"Public": {
"@id": "as:Public",
"@type": "@id"
},
"source": "as:source",
"likes": {
"@id": "as:likes",
"@type": "@id"
},
"shares": {
"@id": "as:shares",
"@type": "@id"
},
"alsoKnownAs": {
"@id": "as:alsoKnownAs",
"@type": "@id"
}
}
}

View File

@ -25,7 +25,7 @@ else
fi
printf " - %s\n" "${CONFIG_ARRAY[@]}"
mkdir -p test/tmp/data
mkdir -p test/tmp/data
echo "$TEST_NAME - Building and installing package"
npm pack --loglevel warn
npm install -g solid-community-server-*.tgz --loglevel warn
@ -72,7 +72,7 @@ run_server_with_config () {
cat test/tmp/"$CONFIG_NAME"
else
echo "$TEST_NAME($CONFIG_NAME) - Attempting HTTP access to the server"
if curl -sfkI -X GET --retry 15 --retry-connrefused --retry-delay 1 $CSS_BASE_URL > test/tmp/"$CONFIG_NAME"-curl; then
if curl -sfkI -X GET --retry 15 --retry-connrefused --retry-delay 5 $CSS_BASE_URL > test/tmp/"$CONFIG_NAME"-curl; then
echo "$TEST_NAME($CONFIG_NAME) - SUCCESS: server reached"
FAILURE=0
else

View File

@ -5,7 +5,6 @@ import { createRemoteJWKSet, jwtVerify } from 'jose';
import type { NamedNode } from 'n3';
import { DataFactory, Parser, Store } from 'n3';
import type { App } from '../../src/init/App';
import { matchesAuthorizationScheme } from '../../src/util/HeaderUtil';
import { joinUrl, trimTrailingSlashes } from '../../src/util/PathUtil';
import { readJsonStream } from '../../src/util/StreamUtil';
@ -21,14 +20,13 @@ import {
removeFolder,
} from './Config';
import quad = DataFactory.quad;
import namedNode = DataFactory.namedNode;
const port = getPort('WebHookChannel2023');
const baseUrl = `http://localhost:${port}/`;
const clientPort = getPort('WebHookChannel2023-client');
const target = `http://localhost:${clientPort}/`;
const webId = 'http://example.com/card/#me';
const notificationType = NOTIFY.WebHookChannel2023;
const notificationType = NOTIFY.WebhookChannel2023;
const rootFilePath = getTestFolder('WebHookChannel2023');
const stores: [string, any][] = [
@ -37,8 +35,7 @@ const stores: [string, any][] = [
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' ],
configs: [ 'storage/backend/file.json', 'util/resource-locker/file.json' ],
teardown: async(): Promise<void> => removeFolder(rootFilePath),
}],
];
@ -97,9 +94,9 @@ describe.each(stores)('A server supporting WebHookChannel2023 using %s', (name,
const quads = new Store(new Parser().parse(await response.text()));
// Find the notification channel for websockets
const subscriptions = quads.getObjects(storageDescriptionUrl, NOTIFY.terms.subscription, null);
const subscriptions = quads.getObjects(null, NOTIFY.terms.subscription, null);
const webhookSubscriptions = subscriptions.filter((channel): boolean => quads.has(
quad(channel as NamedNode, NOTIFY.terms.channelType, namedNode(`${NOTIFY.namespace}WebHookChannel2023`)),
quad(channel as NamedNode, NOTIFY.terms.channelType, NOTIFY.terms.WebhookChannel2023),
));
expect(webhookSubscriptions).toHaveLength(1);
subscriptionUrl = webhookSubscriptions[0].value;

View File

@ -30,8 +30,7 @@ const stores: [string, any][] = [
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' ],
configs: [ 'storage/backend/file.json', 'util/resource-locker/file.json' ],
teardown: async(): Promise<void> => removeFolder(rootFilePath),
}],
];
@ -86,9 +85,9 @@ describe.each(stores)('A server supporting WebSocketChannel2023 using %s', (name
const quads = new Store(new Parser().parse(await response.text()));
// Find the notification channel for websockets
const subscriptions = quads.getObjects(storageDescriptionUrl, NOTIFY.terms.subscription, null);
const subscriptions = quads.getObjects(null, NOTIFY.terms.subscription, null);
const websocketSubscriptions = subscriptions.filter((channel): boolean => quads.has(
quad(channel as NamedNode, NOTIFY.terms.channelType, namedNode(`${NOTIFY.namespace}WebSocketChannel2023`)),
quad(channel as NamedNode, NOTIFY.terms.channelType, NOTIFY.terms.WebSocketChannel2023),
));
expect(websocketSubscriptions).toHaveLength(1);
subscriptionUrl = websocketSubscriptions[0].value;

View File

@ -49,36 +49,33 @@ describe('An AllowAcceptHeaderWriter', (): void => {
expect(headers['accept-post']).toBeUndefined();
});
it('returns all methods for an empty container.', async(): Promise<void> => {
it('returns all methods except PUT for an empty container.', async(): Promise<void> => {
await expect(writer.handleSafe({ response, metadata: emptyContainer })).resolves.toBeUndefined();
const headers = response.getHeaders();
expect(typeof headers.allow).toBe('string');
expect(new Set((headers.allow as string).split(', ')))
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'PUT', 'POST', 'PATCH', 'DELETE' ]));
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'POST', 'PATCH', 'DELETE' ]));
expect(headers['accept-patch']).toBe('text/n3, application/sparql-update');
expect(headers['accept-put']).toBe('*/*');
expect(headers['accept-post']).toBe('*/*');
});
it('returns all methods except DELETE for a non-empty container.', async(): Promise<void> => {
it('returns all methods except PUT/DELETE for a non-empty container.', async(): Promise<void> => {
await expect(writer.handleSafe({ response, metadata: fullContainer })).resolves.toBeUndefined();
const headers = response.getHeaders();
expect(typeof headers.allow).toBe('string');
expect(new Set((headers.allow as string).split(', ')))
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'PUT', 'POST', 'PATCH' ]));
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'POST', 'PATCH' ]));
expect(headers['accept-patch']).toBe('text/n3, application/sparql-update');
expect(headers['accept-put']).toBe('*/*');
expect(headers['accept-post']).toBe('*/*');
});
it('returns all methods except DELETE for a storage container.', async(): Promise<void> => {
it('returns all methods except PUT/DELETE for a storage container.', async(): Promise<void> => {
await expect(writer.handleSafe({ response, metadata: storageContainer })).resolves.toBeUndefined();
const headers = response.getHeaders();
expect(typeof headers.allow).toBe('string');
expect(new Set((headers.allow as string).split(', ')))
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'PUT', 'POST', 'PATCH' ]));
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'POST', 'PATCH' ]));
expect(headers['accept-patch']).toBe('text/n3, application/sparql-update');
expect(headers['accept-put']).toBe('*/*');
expect(headers['accept-post']).toBe('*/*');
});

View File

@ -1,12 +1,24 @@
import { PassThrough } from 'stream';
import type { Logger } from 'winston';
import type * as Transport from 'winston-transport';
import { WinstonLogger } from '../../../src/logging/WinstonLogger';
import { WinstonLoggerFactory } from '../../../src/logging/WinstonLoggerFactory';
const now = new Date();
jest.useFakeTimers();
jest.setSystemTime(now);
describe('WinstonLoggerFactory', (): void => {
let factory: WinstonLoggerFactory;
let transport: jest.Mocked<Transport>;
beforeEach(async(): Promise<void> => {
factory = new WinstonLoggerFactory('debug');
// Create a dummy log transport
transport = new PassThrough({ objectMode: true }) as any;
transport.write = jest.fn();
transport.log = jest.fn();
});
it('creates WinstonLoggers.', async(): Promise<void> => {
@ -19,10 +31,6 @@ describe('WinstonLoggerFactory', (): void => {
});
it('allows WinstonLoggers to be invoked.', async(): Promise<void> => {
// Create a dummy log transport
const transport: any = new PassThrough({ objectMode: true });
transport.write = jest.fn();
transport.log = jest.fn();
(factory as any).createTransports = (): any => [ transport ];
// Create logger, and log
@ -30,15 +38,39 @@ describe('WinstonLoggerFactory', (): void => {
logger.log('debug', 'my message');
expect(transport.write).toHaveBeenCalledTimes(1);
// Need to check level like this as it has color tags
const { level } = transport.write.mock.calls[0][0];
expect(transport.write).toHaveBeenCalledWith({
label: 'MyLabel',
level: expect.stringContaining('debug'),
level,
message: 'my message',
timestamp: expect.any(String),
metadata: expect.any(Object),
timestamp: now.toISOString(),
metadata: {},
[Symbol.for('level')]: 'debug',
[Symbol.for('splat')]: [ undefined ],
[Symbol.for('message')]: expect.any(String),
[Symbol.for('message')]: `${now.toISOString()} [MyLabel] {W-???} ${level}: my message`,
});
});
it('allows extra metadata when logging to indicate the thread.', async(): Promise<void> => {
(factory as any).createTransports = (): any => [ transport ];
// Create logger, and log
const logger = factory.createLogger('MyLabel');
logger.log('debug', 'my message', { isPrimary: true, pid: 0 });
expect(transport.write).toHaveBeenCalledTimes(1);
// Need to check level like this as it has color tags
const { level } = transport.write.mock.calls[0][0];
expect(transport.write).toHaveBeenCalledWith(expect.objectContaining({
label: 'MyLabel',
level,
message: 'my message',
timestamp: now.toISOString(),
metadata: { isPrimary: true, pid: 0 },
[Symbol.for('level')]: 'debug',
[Symbol.for('splat')]: [ undefined ],
[Symbol.for('message')]: `${now.toISOString()} [MyLabel] {Primary} ${level}: my message`,
}));
});
});

View File

@ -83,8 +83,8 @@ describe('A StorageDescriptionHandler', (): void => {
expect(result.metadata?.contentType).toBe('internal/quads');
expect(result.data).toBeDefined();
const quads = await readableToQuads(result.data!);
expect(quads.countQuads(operation.target.path, RDF.terms.type, PIM.terms.Storage, null)).toBe(1);
expect(quads.countQuads('http://example.com/', RDF.terms.type, PIM.terms.Storage, null)).toBe(1);
expect(describer.handle).toHaveBeenCalledTimes(1);
expect(describer.handle).toHaveBeenLastCalledWith(operation.target);
expect(describer.handle).toHaveBeenLastCalledWith({ path: 'http://example.com/' });
});
});

View File

@ -43,14 +43,14 @@ describe('A WebhookChannel2023Type', (): void => {
beforeEach(async(): Promise<void> => {
data = new Store();
data.addQuad(quad(subject, RDF.terms.type, NOTIFY.terms.WebHookChannel2023));
data.addQuad(quad(subject, RDF.terms.type, NOTIFY.terms.WebhookChannel2023));
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode(topic)));
data.addQuad(quad(subject, NOTIFY.terms.sendTo, namedNode(sendTo)));
const id = 'http://example.com/webhooks/4c9b88c1-7502-4107-bb79-2a3a590c7aa3';
channel = {
id,
type: NOTIFY.WebHookChannel2023,
type: NOTIFY.WebhookChannel2023,
topic: 'https://storage.example/resource',
sendTo,
};
@ -79,7 +79,7 @@ describe('A WebhookChannel2023Type', (): void => {
CONTEXT_NOTIFICATION,
],
id: channel.id,
type: NOTIFY.WebHookChannel2023,
type: NOTIFY.WebhookChannel2023,
sendTo,
topic,
sender: 'http://example.com/webhooks/webid',

View File

@ -45,7 +45,7 @@ describe('A WebHookEmitter', (): void => {
const channel: WebhookChannel2023 = {
id: 'id',
topic: 'http://example.com/foo',
type: NOTIFY.WebHookChannel2023,
type: NOTIFY.WebhookChannel2023,
sendTo: 'http://example.org/somewhere-else',
};

View File

@ -53,32 +53,63 @@ describe('HeaderUtil', (): void => {
]);
});
it('rejects Accept Headers with invalid types.', async(): Promise<void> => {
expect((): any => parseAccept('*')).toThrow('Invalid Accept range:');
expect((): any => parseAccept('"bad"/text')).toThrow('Invalid Accept range:');
expect((): any => parseAccept('*/\\bad')).toThrow('Invalid Accept range:');
expect((): any => parseAccept('*/*')).not.toThrow('Invalid Accept range:');
it('ignores Accept Headers with invalid types.', async(): Promise<void> => {
expect(parseAccept('*')).toEqual([]);
expect(parseAccept('"bad"/text')).toEqual([]);
expect(parseAccept('*/\\bad')).toEqual([]);
expect(parseAccept('*/*')).toEqual([{
parameters: { extension: {}, mediaType: {}}, range: '*/*', weight: 1,
}]);
});
it('rejects Accept Headers with invalid q values.', async(): Promise<void> => {
expect((): any => parseAccept('a/b; q=text')).toThrow('Invalid q value:');
expect((): any => parseAccept('a/b; q=0.1234')).toThrow('Invalid q value:');
expect((): any => parseAccept('a/b; q=1.1')).toThrow('Invalid q value:');
expect((): any => parseAccept('a/b; q=1.000')).not.toThrow();
expect((): any => parseAccept('a/b; q=0.123')).not.toThrow();
it('ignores the weight of Accept Headers with q values it can not parse.', async(): Promise<void> => {
expect(parseAccept('a/b; q=text')).toEqual([{
range: 'a/b', weight: 1, parameters: { extension: {}, mediaType: {}},
}]);
// Invalid Q value but can be parsed
expect(parseAccept('a/b; q=0.1234')).toEqual([{
range: 'a/b', weight: 0.1234, parameters: { extension: {}, mediaType: {}},
}]);
expect(parseAccept('a/b; q=1.1')).toEqual([{
range: 'a/b', weight: 1, parameters: { extension: {}, mediaType: {}},
}]);
expect(parseAccept('a/b; q=1.000')).toEqual([{
range: 'a/b', weight: 1, parameters: { extension: {}, mediaType: {}},
}]);
expect(parseAccept('a/b; q=-5')).toEqual([{
range: 'a/b', weight: 0, parameters: { extension: {}, mediaType: {}},
}]);
expect(parseAccept('a/b; q=0.123')).toEqual([{
range: 'a/b', weight: 0.123, parameters: { extension: {}, mediaType: {}},
}]);
});
it('rejects Accept Headers with invalid parameters.', async(): Promise<void> => {
expect((): any => parseAccept('a/b; a')).toThrow('Invalid Accept parameter');
expect((): any => parseAccept('a/b; a=\\')).toThrow('Invalid parameter value');
expect((): any => parseAccept('a/b; q=1 ; a=\\')).toThrow('Invalid parameter value');
expect((): any => parseAccept('a/b; q=1 ; a')).not.toThrow('Invalid Accept parameter');
it('ignores Accept Headers with invalid parameters.', async(): Promise<void> => {
expect(parseAccept('a/b; a')).toEqual([{
range: 'a/b', weight: 1, parameters: { extension: {}, mediaType: {}},
}]);
expect(parseAccept('a/b; a=\\')).toEqual([{
range: 'a/b', weight: 1, parameters: { extension: {}, mediaType: {}},
}]);
expect(parseAccept('a/b; q=1 ; a=\\')).toEqual([{
range: 'a/b', weight: 1, parameters: { extension: {}, mediaType: {}},
}]);
expect(parseAccept('a/b; q=1 ; a')).toEqual([{
// eslint-disable-next-line id-length
range: 'a/b', weight: 1, parameters: { extension: { a: '' }, mediaType: {}},
}]);
});
it('rejects Accept Headers with quoted parameters.', async(): Promise<void> => {
expect((): any => parseAccept('a/b; a="\\""')).not.toThrow();
expect((): any => parseAccept('a/b; a="\\\u007F"')).toThrow('Invalid quoted string in header:');
});
it('rejects invalid values when strict mode is enabled.', async(): Promise<void> => {
expect((): any => parseAccept('"bad"/text', true)).toThrow(BadRequestHttpError);
expect((): any => parseAccept('a/b; q=text', true)).toThrow(BadRequestHttpError);
expect((): any => parseAccept('a/b; a', true)).toThrow(BadRequestHttpError);
});
});
describe('#parseCharset', (): void => {
@ -89,10 +120,14 @@ describe('HeaderUtil', (): void => {
]);
});
it('rejects invalid Accept-Charset Headers.', async(): Promise<void> => {
expect((): any => parseAcceptCharset('a/b')).toThrow('Invalid Accept-Charset range:');
expect((): any => parseAcceptCharset('a; q=text')).toThrow('Invalid q value:');
expect((): any => parseAcceptCharset('a; c=d')).toThrow('Only q parameters are allowed');
it('ignores invalid Accept-Charset Headers.', async(): Promise<void> => {
expect(parseAcceptCharset('a/b')).toEqual([]);
expect(parseAcceptCharset('a; q=text')).toEqual([{ range: 'a', weight: 1 }]);
expect(parseAcceptCharset('a; c=d')).toEqual([{ range: 'a', weight: 1 }]);
});
it('rejects invalid values when strict mode is enabled.', async(): Promise<void> => {
expect((): any => parseAcceptCharset('a/b', true)).toThrow(BadRequestHttpError);
});
});
@ -109,10 +144,14 @@ describe('HeaderUtil', (): void => {
]);
});
it('rejects invalid Accept-Encoding Headers.', async(): Promise<void> => {
expect((): any => parseAcceptEncoding('a/b')).toThrow('Invalid Accept-Encoding range:');
expect((): any => parseAcceptEncoding('a; q=text')).toThrow('Invalid q value:');
expect((): any => parseAcceptCharset('a; c=d')).toThrow('Only q parameters are allowed');
it('ignores invalid Accept-Encoding Headers.', async(): Promise<void> => {
expect(parseAcceptEncoding('a/b')).toEqual([]);
expect(parseAcceptEncoding('a; q=text')).toEqual([{ range: 'a', weight: 1 }]);
expect(parseAcceptCharset('a; c=d')).toEqual([{ range: 'a', weight: 1 }]);
});
it('rejects invalid values when strict mode is enabled.', async(): Promise<void> => {
expect((): any => parseAcceptEncoding('a/b', true)).toThrow(BadRequestHttpError);
});
});
@ -125,16 +164,20 @@ describe('HeaderUtil', (): void => {
]);
});
it('rejects invalid Accept-Language Headers.', async(): Promise<void> => {
expect((): any => parseAcceptLanguage('a/b')).toThrow('Invalid Accept-Language range:');
expect((): any => parseAcceptLanguage('05-a')).toThrow('Invalid Accept-Language range:');
expect((): any => parseAcceptLanguage('a--05')).toThrow('Invalid Accept-Language range:');
expect((): any => parseAcceptLanguage('a-"a"')).toThrow('Invalid Accept-Language range:');
expect((): any => parseAcceptLanguage('a-05')).not.toThrow('Invalid Accept-Language range:');
expect((): any => parseAcceptLanguage('a-b-c-d')).not.toThrow('Invalid Accept-Language range:');
it('ignores invalid Accept-Language Headers.', async(): Promise<void> => {
expect(parseAcceptLanguage('a/b')).toEqual([]);
expect(parseAcceptLanguage('05-a')).toEqual([]);
expect(parseAcceptLanguage('a--05')).toEqual([]);
expect(parseAcceptLanguage('a-"a"')).toEqual([]);
expect(parseAcceptLanguage('a-05')).toEqual([{ range: 'a-05', weight: 1 }]);
expect(parseAcceptLanguage('a-b-c-d')).toEqual([{ range: 'a-b-c-d', weight: 1 }]);
expect((): any => parseAcceptLanguage('a; q=text')).toThrow('Invalid q value:');
expect((): any => parseAcceptCharset('a; c=d')).toThrow('Only q parameters are allowed');
expect(parseAcceptLanguage('a; q=text')).toEqual([{ range: 'a', weight: 1 }]);
expect(parseAcceptCharset('a; c=d')).toEqual([{ range: 'a', weight: 1 }]);
});
it('rejects invalid values when strict mode is enabled.', async(): Promise<void> => {
expect((): any => parseAcceptLanguage('a/b', true)).toThrow(BadRequestHttpError);
});
});
@ -150,9 +193,13 @@ describe('HeaderUtil', (): void => {
expect(parseAcceptDateTime(' ')).toEqual([]);
});
it('rejects invalid Accept-DateTime Headers.', async(): Promise<void> => {
expect((): any => parseAcceptDateTime('a/b')).toThrow('Invalid Accept-DateTime range:');
expect((): any => parseAcceptDateTime('30 May 2007')).toThrow('Invalid Accept-DateTime range:');
it('ignores invalid Accept-DateTime Headers.', async(): Promise<void> => {
expect(parseAcceptDateTime('a/b')).toEqual([]);
expect(parseAcceptDateTime('30 May 2007')).toEqual([]);
});
it('rejects invalid values when strict mode is enabled.', async(): Promise<void> => {
expect((): any => parseAcceptLanguage('a/b', true)).toThrow(BadRequestHttpError);
});
});

View File

@ -96,7 +96,7 @@ describe('PathUtil', (): void => {
describe('#toCanonicalUriPath', (): void => {
it('encodes only the necessary parts.', (): void => {
expect(toCanonicalUriPath('/a%20path&/name')).toBe('/a%20path%26/name');
expect(toCanonicalUriPath('/a%20path&*/name')).toBe('/a%20path%26*/name');
});
it('leaves the query string untouched.', (): void => {
@ -138,6 +138,11 @@ describe('PathUtil', (): void => {
expect(decodeUriPathComponents('/a%25252Fb')).toBe('/a%25252Fb');
expect(decodeUriPathComponents('/a%2525252Fb')).toBe('/a%2525252Fb');
});
it('ensures illegal path characters are encoded.', async(): Promise<void> => {
expect(decodeUriPathComponents('/a<path%3F%3E/*:name?abc=def&xyz'))
.toBe('/a%3Cpath%3F%3E/%2A%3Aname?abc=def&xyz');
});
});
describe('#encodeUriPathComponents', (): void => {

View File

@ -2,7 +2,7 @@ import { fetch } from 'cross-fetch';
/**
* Subscribes to a notification channel.
* @param type - The type of the notification channel, e.g., "NOTIFY.WebHookChannel2023".
* @param type - The type of the notification channel, e.g., "NOTIFY.WebhookChannel2023".
* @param webId - The WebID to spoof in the authorization header. This assumes the config uses the debug auth import.
* @param subscriptionUrl - The subscription URL to which the request needs to be sent.
* @param topic - The topic to subscribe to.

View File

@ -1,17 +1,24 @@
import { setGlobalLoggerFactory } from '../../src/logging/LogUtil';
import { WinstonLoggerFactory } from '../../src/logging/WinstonLoggerFactory';
import { getTestFolder, removeFolder } from '../integration/Config';
// Jest global setup requires a single function to be exported
export default async function(): Promise<void> {
// Set the main logger
const level = process.env.LOGLEVEL ?? 'off';
const loggerFactory = new WinstonLoggerFactory(level);
setGlobalLoggerFactory(loggerFactory);
const level = process.env.LOGLEVEL ?? 'off';
const loggerFactory = new WinstonLoggerFactory(level);
setGlobalLoggerFactory(loggerFactory);
// Also set the logger factory of transpiled JS modules
// (which are instantiated by Components.js)
try {
// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
const dist = require('../../dist/logging/LogUtil');
dist.setGlobalLoggerFactory(loggerFactory);
} catch {
// Ignore
// Also set the logger factory of transpiled JS modules
// (which are instantiated by Components.js)
try {
// eslint-disable-next-line global-require,@typescript-eslint/no-var-requires,@typescript-eslint/no-require-imports
const dist = require('../../dist/logging/LogUtil');
dist.setGlobalLoggerFactory(loggerFactory);
} catch {
// Ignore
}
// Clean up the test folder to prevent issues with remaining files from previous tests
await removeFolder(getTestFolder(''));
}