diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f22b190e..9ca6bb74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,47 @@ # Changelog All notable changes to this project will be documented in this file. + +## [v3.0.0](https://github.com/solid/community-server/compare/v2.0.1...v3.0.0) - 2022-02-23 + +### Added +* [feat: Determine Typed Converter output based on input type](https://github.com/solid/community-server/commit/fa94c7d4bb0d67b0cde264f9515260293b3b904a) +* [feat: Add ContentTypeReplacer to conversion chain](https://github.com/solid/community-server/commit/fdd42bb7b3efda8bfac535ef4ff07f45ea4a524a) +* [feat: Add "no conversion" as possible path in ChainedConverter](https://github.com/solid/community-server/commit/d52aa94e535768c183589179462af95814b51094) +* [feat: Support redirection through errors](https://github.com/solid/community-server/commit/7163a0317b80535ba85e636495cb48b61bb6e6f3) +* [feat: Move redirect support from IDP handler to specific handlers](https://github.com/solid/community-server/commit/4241c5348df880646ac39d34d0f733a0743fcb24) +* [feat: Create VoidLocker to disable locking resources](https://github.com/solid/community-server/commit/9a1f324685216bd6346fb19e626dcca5145053df) +* [chore: Build and push official docker image in CI](https://github.com/solid/community-server/commit/65d1eeb0a2f3ab253efca50d98d6a14c3fa3103c) +* [feat: Add support for quota limits](https://github.com/solid/community-server/commit/0cb4d7b16114ce9d0d4c5ae0766b4e4e944af9cf) +* [feat: Add support for N3 Patch](https://github.com/solid/community-server/commit/a9941ebe7880cc9bb136786d721c1ba76bda888a) +* [feat: Allow for custom CLI and variable options](https://github.com/solid/community-server/commit/c216efd62fcc05aa1db5a0046c3dbc512e7f2d62) +* [feat: Send reset password recordId as query parameter](https://github.com/solid/community-server/commit/8f8e8e6df4a4a5d8759c95c2a07e457050830ed6) +* [feat: Split up IDP HTML, routing, and handler behaviour](https://github.com/solid/community-server/commit/bc0eeb1012e15e9e9ee0f9085be209f6a9229ccd) +* [feat: Update IDP templates to work with new API format](https://github.com/solid/community-server/commit/a684b2ead7365b9409d7f2f4cfa6755e8b951958) +* [feat: Simplify setup to be more in line with IDP behaviour](https://github.com/solid/community-server/commit/95777914729890debe0d4815c084029864afaf23) +* [feat: Return client information from consent handler](https://github.com/solid/community-server/commit/e604c0c2e427f7cf426cda6e3a52c2d72b997057) +* [feat: Warn users when they change the base URL](https://github.com/solid/community-server/commit/62e22100238f1b9dfb13b9f350fccf12184f728b) +* [feat: Store the server version on start](https://github.com/solid/community-server/commit/2dc20fe3bc63da1d0a39720410da07f316b253ac) + +### Changed +* [refactor: Create BaseTypedRepresentationConverter](https://github.com/solid/community-server/commit/27306d6e3f6f3dda09914e078151a8d07e111869) +* [feat: Update IDP parameters to latest Solid-OIDC version](https://github.com/solid/community-server/commit/fc60b5c161853845d1f3e6405e1182948cca421b) +* [feat: Move OIDC library behaviour to separate path](https://github.com/solid/community-server/commit/520e4fe42fe14ec80ef0718c7f1214620fdae218) +* [fix: Update OIDC provider dependency to v7](https://github.com/solid/community-server/commit/c9ed90aeebaabca957ae1980738f732e5472ee9d) + +### Fixed +* [fix: Prefer all inputs equally when generating quads](https://github.com/solid/community-server/commit/c6544fac1db432d1e0ce323bf439c48a7ed5dc52) +* [fix: Handle JSON preferences correctly in dynamic converter](https://github.com/solid/community-server/commit/4d319d2564e953514c94cbadf93e28fefc501e86) +* [fix: Make UnionCredentialsExtractor tolerate failures.](https://github.com/solid/community-server/commit/c13456c2259538e502a59ce73a226bab2c99c395) +* [fix: Accept lowercase Authorization tokens.](https://github.com/solid/community-server/commit/9c52011addde6cbdfd22efeb9485841c640363be) +* [feat: Return correct status codes for invalid requests](https://github.com/solid/community-server/commit/1afed65368f98f4fda7bdd8f9fc5071f51d4dc5b) +* [fix: Split AccountStorage and ForgotPasswordStorage (expiring now)](https://github.com/solid/community-server/commit/d067165b68a824143ff65f289d8a1e5e53d15103) +* [fix: Add content-negotiation when fetching dataset from url](https://github.com/solid/community-server/commit/ce754c119fb87dc8a4f79c639e316bd04d40109b) +* [fix: Prevent login page from showing error before redirect](https://github.com/solid/community-server/commit/1ed45c8903e8750b818885cb6e48183e4c36f22a) +* [fix: Make IDP routes independent of handlers](https://github.com/solid/community-server/commit/1769b799df090a036f2d2925c06ba8d9f7130e6b) +* [fix: Improve OIDC error descriptions](https://github.com/solid/community-server/commit/e9e3c6df3c945e187ae351f15bfe1a6df75e47a9) + + ## [v2.0.1](https://github.com/solid/community-server/compare/v2.0.0...v2.0.1) - 2021-11-02 diff --git a/LICENSE.md b/LICENSE.md index b433cd27a..dfb243b82 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright © 2019–2021 Inrupt Inc. and imec +Copyright © 2019–2022 Inrupt Inc. and imec Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c1d69e7fb..bff3d1309 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,48 @@ # Community Solid Server release notes +## v3.0.0 +### New features +- The Identity Provider now uses the `webid` scope as required for Solid-OIDC. +- The `VoidLocker` can be used to disable locking for development/testing purposes. + This can be enabled by changing the `/config/util/resource-locker/` import to `debug-void.json` +- Added support for setting a quota on the server. See the `config/quota-file.json` config for an example. +- An official docker image is now built on each version tag and published at https://hub.docker.com/r/solidproject/community-server. +- Added support for N3 Patch. +- It is now possible to customize arguments to the `community-solid-server` command, + which enables passing custom variables to configurations and setting new default values. +- The AppRunner functions have changed to require Components.js variables. + This is important for anyone who starts the server from code. +- When logging in, a consent screen will now provide information about the client. + +### Configuration changes +You might need to make changes to your v2 configuration if you use a custom config. + +The `@context` needs to be updated to +`https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld`. + +The following changes pertain to the imports in the default configs: +- A new configuration option needs to be imported: + - `/app/variables/default/json` contains everything related to parsing CLI arguments + and assigning values to variables. + +The following changes are relevant for v2 custom configs that replaced certain features. +- Conversion has been simplified so most converters are part of the conversion chain: + - `/util/representation-conversion/default.json` +- The IDP settings have changed to support the latest Solid-OIDC draft. + - `/identity/handler/provider-factory/identity.json` +- Requests targeting the OIDC library now use a separate handler. + - `/http/handler/default.json` + - `/identity/handler/default.json` +- The architecture of IDP interaction handlers has completely changed to improve modularity + - `/identity/handler/interaction/*` + - `/identity/registration/*` + +### Interface changes +These changes are relevant if you wrote custom modules for the server that depend on existing interfaces. +- `TypedRepresentationConverter` function signatures changed + and base functionality moved to `BaseTypedRepresentationConverter`. +- Many changes to several components related to the IDP. This includes the HTML templates. + ## v2.0.0 ### New features - Pod owners always have Control access to resources stored in their Pod. diff --git a/bin/server.js b/bin/server.js index 32a79b07b..0eda47fe6 100755 --- a/bin/server.js +++ b/bin/server.js @@ -1,4 +1,3 @@ #!/usr/bin/env node -// eslint-disable-next-line @typescript-eslint/naming-convention const { AppRunner } = require('..'); -new AppRunner().runCli(process); +new AppRunner().runCliSync(process); diff --git a/config/app/README.md b/config/app/README.md index 1e7b2965a..5366a38a1 100644 --- a/config/app/README.md +++ b/config/app/README.md @@ -20,3 +20,11 @@ Handles the setup page the first time the server is started. * *optional*: Setup is available at `/setup` but the server can already be used. Everyone can access the setup page so make sure to complete that as soon as possible. * *required*: All requests will be redirected to the setup page until setup is completed. + +## Variables +Handles parsing CLI parameters and assigning values to Components.js variables. +Some parts of the configuration contains variables that can be set as arguments on the command-line. +That way, you don't have to edit the configuration files for small changes, +such as starting the server with a different hostname. +Here, you can customize the mapping from CLI arguments into values for those variables. +* *default*: Assigns CLI parameters for all variables defined in `/config/util/variables/default.json` diff --git a/config/app/init/base/init.json b/config/app/init/base/init.json index d794509f0..0036fdbe8 100644 --- a/config/app/init/base/init.json +++ b/config/app/init/base/init.json @@ -1,8 +1,10 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ + "files-scs:config/app/init/initializers/base-url.json", "files-scs:config/app/init/initializers/logger.json", - "files-scs:config/app/init/initializers/server.json" + "files-scs:config/app/init/initializers/server.json", + "files-scs:config/app/init/initializers/version.json" ], "@graph": [ { @@ -11,8 +13,10 @@ "@type": "SequenceHandler", "handlers": [ { "@id": "urn:solid-server:default:LoggerInitializer" }, + { "@id": "urn:solid-server:default:BaseUrlVerifier" }, { "@id": "urn:solid-server:default:ParallelInitializer" }, - { "@id": "urn:solid-server:default:ServerInitializer" } + { "@id": "urn:solid-server:default:ServerInitializer" }, + { "@id": "urn:solid-server:default:ModuleVersionVerifier" } ] } ] diff --git a/config/app/init/default.json b/config/app/init/default.json index 88037a1e2..2b117336b 100644 --- a/config/app/init/default.json +++ b/config/app/init/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/init/base/init.json" ], diff --git a/config/app/init/initialize-prefilled-root.json b/config/app/init/initialize-prefilled-root.json index 40e1d5d85..7eae44141 100644 --- a/config/app/init/initialize-prefilled-root.json +++ b/config/app/init/initialize-prefilled-root.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/init/base/init.json", "files-scs:config/app/init/initializers/prefilled-root.json" diff --git a/config/app/init/initialize-root.json b/config/app/init/initialize-root.json index fea2858a1..c72c66c3c 100644 --- a/config/app/init/initialize-root.json +++ b/config/app/init/initialize-root.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/init/base/init.json", "files-scs:config/app/init/initializers/root.json" diff --git a/config/app/init/initializers/base-url.json b/config/app/init/initializers/base-url.json new file mode 100644 index 000000000..d9c113551 --- /dev/null +++ b/config/app/init/initializers/base-url.json @@ -0,0 +1,13 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Logs a warning if the base URL changes.", + "@id": "urn:solid-server:default:BaseUrlVerifier", + "@type": "BaseUrlVerifier", + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "storageKey": "current-base-url", + "storage": { "@id": "urn:solid-server:default:SetupStorage" } + } + ] +} diff --git a/config/app/init/initializers/logger.json b/config/app/init/initializers/logger.json index c1829d165..d1cf5fab8 100644 --- a/config/app/init/initializers/logger.json +++ b/config/app/init/initializers/logger.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": [ diff --git a/config/app/init/initializers/prefilled-root.json b/config/app/init/initializers/prefilled-root.json index 0fea154a7..2f934a3c4 100644 --- a/config/app/init/initializers/prefilled-root.json +++ b/config/app/init/initializers/prefilled-root.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Makes sure the root container exists and contains the necessary resources.", diff --git a/config/app/init/initializers/root.json b/config/app/init/initializers/root.json index 314bd8125..0e0ceb912 100644 --- a/config/app/init/initializers/root.json +++ b/config/app/init/initializers/root.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Makes sure the root container exists and contains the necessary resources.", diff --git a/config/app/init/initializers/server.json b/config/app/init/initializers/server.json index ccaeffdc3..f227a7fb7 100644 --- a/config/app/init/initializers/server.json +++ b/config/app/init/initializers/server.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Creates the server that starts listening for requests.", diff --git a/config/app/init/initializers/version.json b/config/app/init/initializers/version.json new file mode 100644 index 000000000..42b1e4bfb --- /dev/null +++ b/config/app/init/initializers/version.json @@ -0,0 +1,12 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Logs a warning if the base URL changes.", + "@id": "urn:solid-server:default:ModuleVersionVerifier", + "@type": "ModuleVersionVerifier", + "storageKey": "current-server-version", + "storage": { "@id": "urn:solid-server:default:SetupStorage" } + } + ] +} diff --git a/config/app/main/default.json b/config/app/main/default.json index d5657700f..d723c4b1a 100644 --- a/config/app/main/default.json +++ b/config/app/main/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "This is the entry point to the application. It can be used to both start and stop the server.", diff --git a/config/app/setup/disabled.json b/config/app/setup/disabled.json index 39901fa04..4e02f171d 100644 --- a/config/app/setup/disabled.json +++ b/config/app/setup/disabled.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/init/initializers/root.json" ], diff --git a/config/app/setup/handlers/redirect.json b/config/app/setup/handlers/redirect.json index 548ae736a..12c0c584b 100644 --- a/config/app/setup/handlers/redirect.json +++ b/config/app/setup/handlers/redirect.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Redirects all request to the setup.", diff --git a/config/app/setup/handlers/setup.json b/config/app/setup/handlers/setup.json index fd0bbd140..4dcb0ffe6 100644 --- a/config/app/setup/handlers/setup.json +++ b/config/app/setup/handlers/setup.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/init/initializers/root.json" ], @@ -14,14 +14,31 @@ "args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }, "args_operationHandler": { "@type": "SetupHttpHandler", - "args_initializer": { "@id": "urn:solid-server:default:RootInitializer" }, - "args_registrationManager": { "@id": "urn:solid-server:default:SetupRegistrationManager" }, + "args_handler": { + "@type": "SetupHandler", + "args_initializer": { "@id": "urn:solid-server:default:RootInitializer" }, + "args_registrationManager": { "@id": "urn:solid-server:default:SetupRegistrationManager" } + }, "args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, "args_storageKey": "setupCompleted-2.0", "args_storage": { "@id": "urn:solid-server:default:SetupStorage" }, - "args_viewTemplate": "@css:templates/setup/index.html.ejs", - "args_responseTemplate": "@css:templates/setup/response.html.ejs", - "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" } + "args_templateEngine": { + "comment": "Renders the specific page and embeds it into the main HTML body.", + "@type": "ChainedTemplateEngine", + "renderedName": "htmlBody", + "engines": [ + { + "comment": "Renders the main setup template.", + "@type": "EjsTemplateEngine", + "template": "@css:templates/setup/index.html.ejs" + }, + { + "comment": "Will embed the result of the first engine into the main HTML template.", + "@type": "EjsTemplateEngine", + "template": "@css:templates/main.html.ejs" + } + ] + } } }, { diff --git a/config/app/setup/optional.json b/config/app/setup/optional.json index 921596deb..33cd597e5 100644 --- a/config/app/setup/optional.json +++ b/config/app/setup/optional.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/setup/handlers/setup.json" ], diff --git a/config/app/setup/required.json b/config/app/setup/required.json index 2bfc0b6aa..8056955c2 100644 --- a/config/app/setup/required.json +++ b/config/app/setup/required.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/setup/handlers/redirect.json", "files-scs:config/app/setup/handlers/setup.json" diff --git a/config/app/variables/cli/cli.json b/config/app/variables/cli/cli.json new file mode 100644 index 000000000..63ac5fa4f --- /dev/null +++ b/config/app/variables/cli/cli.json @@ -0,0 +1,67 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Extracts CLI arguments into a key/value object. Config and mainModulePath are only defined here so their description is returned.", + "@id": "urn:solid-server-app-setup:default:CliExtractor", + "@type": "YargsCliExtractor", + "parameters": { + "config": { + "alias": "c", + "requiresArg": true, + "type": "string", + "describe": "The configuration for the server. The default only stores data in memory; to persist to your filesystem, use @css:config/file.json." + }, + "mainModulePath": { + "alias": "m", + "requiresArg": true, + "type": "string", + "describe": "Path from where Components.js will start its lookup when initializing configurations." + }, + "loggingLevel": { + "alias": "l", + "requiresArg": true, + "type": "string", + "describe": "The detail level of logging; useful for debugging problems." + }, + "baseUrl": { + "alias": "b", + "requiresArg": true, + "type": "string", + "describe": "The public URL of your server." + }, + "port": { + "alias": "p", + "requiresArg": true, + "type": "number", + "describe": "The TCP port on which the server runs." + }, + "rootFilePath": { + "alias": "f", + "requiresArg": true, + "type": "string", + "describe": "Root folder of the server, when using a file-based configuration." + }, + "showStackTrace": { + "alias": "t", + "type": "boolean", + "describe": "Enables detailed logging on error pages." + }, + "sparqlEndpoint": { + "alias": "s", + "requiresArg": true, + "type": "string", + "describe": "URL of the SPARQL endpoint, when using a quadstore-based configuration." + }, + "podConfigJson": { + "requiresArg": true, + "type": "string", + "describe": "Path to the file that keeps track of dynamic Pod configurations." + } + }, + "options": { + "usage": "node ./bin/server.js [args]" + } + } + ] +} diff --git a/config/app/variables/default.json b/config/app/variables/default.json new file mode 100644 index 000000000..bf72b1083 --- /dev/null +++ b/config/app/variables/default.json @@ -0,0 +1,16 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "import": [ + "files-scs:config/app/variables/cli/cli.json", + "files-scs:config/app/variables/resolver/resolver.json" + ], + "@graph": [ + { + "comment": "Combines a CliExtractor and SettingsResolver to be used by the AppRunner.", + "@id": "urn:solid-server-app-setup:default:CliResolver", + "@type": "CliResolver", + "cliExtractor": { "@id": "urn:solid-server-app-setup:default:CliExtractor" }, + "settingsResolver": { "@id": "urn:solid-server-app-setup:default:SettingsResolver" } + } + ] +} diff --git a/config/app/variables/resolver/resolver.json b/config/app/variables/resolver/resolver.json new file mode 100644 index 000000000..9f5e2e749 --- /dev/null +++ b/config/app/variables/resolver/resolver.json @@ -0,0 +1,65 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Converts an input key/value object into an object mapping values to Components.js variables", + "@id": "urn:solid-server-app-setup:default:SettingsResolver", + "@type": "CombinedSettingsResolver", + "computers": [ + { + "CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:baseUrl", + "CombinedSettingsResolver:_computers_value": { + "@type": "BaseUrlExtractor" + } + }, + { + "CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:loggingLevel", + "CombinedSettingsResolver:_computers_value": { + "@type": "KeyExtractor", + "key": "loggingLevel", + "defaultValue": "info" + } + }, + { + "CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:port", + "CombinedSettingsResolver:_computers_value": { + "@type": "KeyExtractor", + "key": "port", + "defaultValue": 3000 + } + }, + { + "CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:rootFilePath", + "CombinedSettingsResolver:_computers_value": { + "@type": "AssetPathExtractor", + "key": "rootFilePath", + "defaultPath": "./" + } + }, + { + "CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:sparqlEndpoint", + "CombinedSettingsResolver:_computers_value": { + "@type": "KeyExtractor", + "key": "sparqlEndpoint" + } + }, + { + "CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:showStackTrace", + "CombinedSettingsResolver:_computers_value": { + "@type": "KeyExtractor", + "key": "showStackTrace", + "defaultValue": false + } + }, + { + "CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:AssetPathResolver", + "CombinedSettingsResolver:_computers_value": { + "@type": "AssetPathExtractor", + "key": "podConfigJson", + "defaultPath": "./pod-config.json" + } + } + ] + } + ] +} diff --git a/config/default.json b/config/default.json index aa4346295..0a05b7009 100644 --- a/config/default.json +++ b/config/default.json @@ -1,9 +1,10 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-prefilled-root.json", "files-scs:config/app/setup/optional.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/dynamic.json b/config/dynamic.json index d6552ca80..d12b88f28 100644 --- a/config/dynamic.json +++ b/config/dynamic.json @@ -1,9 +1,10 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", "files-scs:config/app/setup/required.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/example-https-file.json b/config/example-https-file.json index 77b2163a3..f3a8f17e4 100644 --- a/config/example-https-file.json +++ b/config/example-https-file.json @@ -1,9 +1,10 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", "files-scs:config/app/setup/required.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", diff --git a/config/file-no-setup.json b/config/file-no-setup.json index 26ff5f0ba..11d93a85a 100644 --- a/config/file-no-setup.json +++ b/config/file-no-setup.json @@ -1,9 +1,10 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", "files-scs:config/app/setup/disabled.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/file.json b/config/file.json index 4e220273a..873ca50b0 100644 --- a/config/file.json +++ b/config/file.json @@ -1,9 +1,10 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", "files-scs:config/app/setup/required.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/http/handler/default.json b/config/http/handler/default.json index 01e0b34b1..1a6ec5566 100644 --- a/config/http/handler/default.json +++ b/config/http/handler/default.json @@ -1,7 +1,7 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ - "files-scs:config/app/init/initializers/root.json" + "files-scs:config/http/handler/handlers/oidc.json" ], "@graph": [ { @@ -15,6 +15,7 @@ "handlers": [ { "@id": "urn:solid-server:default:StaticAssetHandler" }, { "@id": "urn:solid-server:default:SetupHandler" }, + { "@id": "urn:solid-server:default:OidcHandler" }, { "@id": "urn:solid-server:default:AuthResourceHttpHandler" }, { "@id": "urn:solid-server:default:IdentityProviderHandler" }, { "@id": "urn:solid-server:default:LdpHandler" } diff --git a/config/http/handler/handlers/oidc.json b/config/http/handler/handlers/oidc.json new file mode 100644 index 000000000..e31e2d109 --- /dev/null +++ b/config/http/handler/handlers/oidc.json @@ -0,0 +1,18 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Routes all OIDC related requests to the OIDC library.", + "@id": "urn:solid-server:default:OidcHandler", + "@type": "RouterHandler", + "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, + "args_allowedMethods": [ "*" ], + "args_allowedPathNames": [ "^/.oidc/.*", "^/\\.well-known/openid-configuration" ], + "args_handler": { + "@type": "OidcHttpHandler", + "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" } + } + } + ] +} diff --git a/config/http/handler/simple.json b/config/http/handler/simple.json index 5d1b02d17..f71a54778 100644 --- a/config/http/handler/simple.json +++ b/config/http/handler/simple.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "This version of the server has no IDP or pod provisioning.", diff --git a/config/http/middleware/handlers/constant-headers.json b/config/http/middleware/handlers/constant-headers.json index dc36a8ab6..eecae8cae 100644 --- a/config/http/middleware/handlers/constant-headers.json +++ b/config/http/middleware/handlers/constant-headers.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Adds several constant headers.", diff --git a/config/http/middleware/handlers/cors.json b/config/http/middleware/handlers/cors.json index 8f7b66e08..f818b6c36 100644 --- a/config/http/middleware/handlers/cors.json +++ b/config/http/middleware/handlers/cors.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Adds all the necessary CORS headers.", diff --git a/config/http/middleware/handlers/updates-via.json b/config/http/middleware/handlers/updates-via.json index a7f4af6e6..cf584d166 100644 --- a/config/http/middleware/handlers/updates-via.json +++ b/config/http/middleware/handlers/updates-via.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Advertises the websocket connection.", diff --git a/config/http/middleware/no-websockets.json b/config/http/middleware/no-websockets.json index 918e868e3..dffbc5bdc 100644 --- a/config/http/middleware/no-websockets.json +++ b/config/http/middleware/no-websockets.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/http/middleware/handlers/constant-headers.json", "files-scs:config/http/middleware/handlers/cors.json" diff --git a/config/http/middleware/websockets.json b/config/http/middleware/websockets.json index 960b1d6e5..8ef50ddb7 100644 --- a/config/http/middleware/websockets.json +++ b/config/http/middleware/websockets.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/http/middleware/handlers/constant-headers.json", "files-scs:config/http/middleware/handlers/cors.json", diff --git a/config/http/server-factory/https-example.json b/config/http/server-factory/https-example.json index 612b27879..b3ef1d75a 100644 --- a/config/http/server-factory/https-example.json +++ b/config/http/server-factory/https-example.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "An example of how to set up a server with HTTPS", diff --git a/config/http/server-factory/no-websockets.json b/config/http/server-factory/no-websockets.json index 6ef20bd2f..1b0ef0203 100644 --- a/config/http/server-factory/no-websockets.json +++ b/config/http/server-factory/no-websockets.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Creates a server that supports HTTP requests.", diff --git a/config/http/server-factory/websockets.json b/config/http/server-factory/websockets.json index 2dfc9d031..65b446492 100644 --- a/config/http/server-factory/websockets.json +++ b/config/http/server-factory/websockets.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Creates a server that supports both websocket and HTTP requests.", diff --git a/config/http/static/default.json b/config/http/static/default.json index 7d3bb2c79..e78beb842 100644 --- a/config/http/static/default.json +++ b/config/http/static/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Servers static files on fixed URLs.", @@ -12,17 +12,22 @@ "StaticAssetHandler:_assets_value": "@css:templates/images/favicon.ico" }, { - "StaticAssetHandler:_assets_key": "/.well_known/css/styles/", + "StaticAssetHandler:_assets_key": "/.well-known/css/styles/", "StaticAssetHandler:_assets_value": "@css:templates/styles/" }, { - "StaticAssetHandler:_assets_key": "/.well_known/css/fonts/", + "StaticAssetHandler:_assets_key": "/.well-known/css/fonts/", "StaticAssetHandler:_assets_value": "@css:templates/fonts/" }, { - "StaticAssetHandler:_assets_key": "/.well_known/css/images/", + "StaticAssetHandler:_assets_key": "/.well-known/css/images/", "StaticAssetHandler:_assets_value": "@css:templates/images/" + }, + { + "StaticAssetHandler:_assets_key": "/.well-known/css/scripts/", + "StaticAssetHandler:_assets_value": "@css:templates/scripts/" } + ] } ] diff --git a/config/identity/access/initializers/idp.json b/config/identity/access/initializers/idp.json index dad2509f5..a68036fc7 100644 --- a/config/identity/access/initializers/idp.json +++ b/config/identity/access/initializers/idp.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Makes sure the IDP container has the necessary root resources.", diff --git a/config/identity/access/initializers/well-known.json b/config/identity/access/initializers/well-known.json index b700434de..1807343e3 100644 --- a/config/identity/access/initializers/well-known.json +++ b/config/identity/access/initializers/well-known.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Makes sure the .well-known container has the necessary root resources. Some IDP resources are stored there due to OIDC requirements.", diff --git a/config/identity/access/public.json b/config/identity/access/public.json index 572208501..1bcce633d 100644 --- a/config/identity/access/public.json +++ b/config/identity/access/public.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Allow everyone to register new pods.", diff --git a/config/identity/access/restricted.json b/config/identity/access/restricted.json index d3799afa1..03457e71c 100644 --- a/config/identity/access/restricted.json +++ b/config/identity/access/restricted.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/identity/access/initializers/idp.json", "files-scs:config/identity/access/initializers/well-known.json" diff --git a/config/identity/email/default.json b/config/identity/email/default.json index fefb97680..1f252d9c3 100644 --- a/config/identity/email/default.json +++ b/config/identity/email/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "The default configuration does not contain credentials for an email client. In production systems, you likely want to set up your own.", diff --git a/config/identity/email/example.json b/config/identity/email/example.json index 82d6d99a7..5b0173fa0 100644 --- a/config/identity/email/example.json +++ b/config/identity/email/example.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "This is an example of what an actual email sender configuration would look like.", diff --git a/config/identity/handler/account-store/default.json b/config/identity/handler/account-store/default.json index 896d9ebc4..aae44a0ee 100644 --- a/config/identity/handler/account-store/default.json +++ b/config/identity/handler/account-store/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "The storage adapter that persists usernames, passwords, etc.", @@ -8,7 +8,21 @@ "saltRounds": 10, "storage": { "@id": "urn:solid-server:default:AccountStorage" + }, + "forgotPasswordStorage": { + "@id": "urn:solid-server:default:ExpiringForgotPasswordStorage" } + }, + { + "comment": "Stores expiring data. This class has a `finalize` function that needs to be called after stopping the server.", + "@id": "urn:solid-server:default:ExpiringForgotPasswordStorage", + "@type": "WrappedExpiringStorage", + "source": { "@id": "urn:solid-server:default:ForgotPasswordStorage" } + }, + { + "comment": "Makes sure the expiring storage cleanup timer is stopped when the application needs to stop.", + "@id": "urn:solid-server:default:Finalizer", + "ParallelFinalizer:_finalizers": [ { "@id": "urn:solid-server:default:ExpiringForgotPasswordStorage" } ] } ] } diff --git a/config/identity/handler/adapter-factory/webid.json b/config/identity/handler/adapter-factory/webid.json index 1a6bf0ba0..502dc334f 100644 --- a/config/identity/handler/adapter-factory/webid.json +++ b/config/identity/handler/adapter-factory/webid.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "An adapter is responsible for storing all interaction metadata.", diff --git a/config/identity/handler/default.json b/config/identity/handler/default.json index f6104b5d3..f4b15ca9a 100644 --- a/config/identity/handler/default.json +++ b/config/identity/handler/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/identity/handler/account-store/default.json", "files-scs:config/identity/handler/adapter-factory/webid.json", @@ -14,7 +14,7 @@ "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, "args_allowedMethods": [ "*" ], - "args_allowedPathNames": [ "^/idp/.*", "^/\\.well-known/openid-configuration" ], + "args_allowedPathNames": [ "^/idp/.*" ], "args_handler": { "@id": "urn:solid-server:default:IdentityProviderParsingHandler" } }, { @@ -39,16 +39,9 @@ "comment": "Handles IDP handler behaviour.", "@id": "urn:solid-server:default:IdentityProviderHttpHandler", "@type": "IdentityProviderHttpHandler", - "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "args_idpPath": "/idp", "args_providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }, "args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, - "args_interactionCompleter": { - "comment": "Responsible for finishing OIDC interactions.", - "@type": "InteractionCompleter", - "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" } - }, - "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" } + "args_handler": { "@id": "urn:solid-server:default:InteractionHandler" } } ] } diff --git a/config/identity/handler/interaction/routes.json b/config/identity/handler/interaction/routes.json index 83be9a153..e49083ba2 100644 --- a/config/identity/handler/interaction/routes.json +++ b/config/identity/handler/interaction/routes.json @@ -1,19 +1,55 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ + "files-scs:config/identity/handler/interaction/routes/consent.json", "files-scs:config/identity/handler/interaction/routes/forgot-password.json", + "files-scs:config/identity/handler/interaction/routes/index.json", "files-scs:config/identity/handler/interaction/routes/login.json", + "files-scs:config/identity/handler/interaction/routes/prompt.json", "files-scs:config/identity/handler/interaction/routes/reset-password.json", - "files-scs:config/identity/handler/interaction/routes/session.json" + "files-scs:config/identity/handler/interaction/views/controls.json", + "files-scs:config/identity/handler/interaction/views/html.json" ], "@graph": [ { - "@id": "urn:solid-server:default:IdentityProviderHttpHandler", - "IdentityProviderHttpHandler:_args_interactionRoutes": [ - { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" }, - { "@id": "urn:solid-server:auth:password:LoginRoute" }, - { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" }, - { "@id": "urn:solid-server:auth:password:SessionRoute" } + "@id": "urn:solid-server:default:InteractionHandler", + "@type": "WaterfallHandler", + "handlers": [ + { + "comment": "Returns the relevant HTML pages for the interactions when needed", + "@id": "urn:solid-server:auth:password:HtmlViewHandler" + }, + { + "comment": "Adds controls and API version to JSON responses.", + "@id": "urn:solid-server:auth:password:ControlHandler", + "ControlHandler:_source" : { "@id": "urn:solid-server:auth:password:LocationInteractionHandler" } + } + ] + }, + { + "comment": "Converts 3xx redirects to 200 JSON responses for consumption by browser scripts.", + "@id": "urn:solid-server:auth:password:LocationInteractionHandler", + "@type": "LocationInteractionHandler", + "LocationInteractionHandler:_source" : { "@id": "urn:solid-server:auth:password:InteractionRouteHandler" } + }, + { + "comment": "Handles every interaction based on their route.", + "@id": "urn:solid-server:auth:password:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [ + { + "comment": [ + "This handler is required to prevent Components.js issues with arrays.", + "This might be fixed in the next Components.js release after which this can be removed." + ], + "@type": "UnsupportedAsyncHandler" + }, + { "@id": "urn:solid-server:auth:password:IndexRouteHandler" }, + { "@id": "urn:solid-server:auth:password:PromptRouteHandler" }, + { "@id": "urn:solid-server:auth:password:LoginRouteHandler" }, + { "@id": "urn:solid-server:auth:password:ConsentRouteHandler" }, + { "@id": "urn:solid-server:auth:password:ForgotPasswordRouteHandler" }, + { "@id": "urn:solid-server:auth:password:ResetPasswordRouteHandler" } ] } ] diff --git a/config/identity/handler/interaction/routes/consent.json b/config/identity/handler/interaction/routes/consent.json new file mode 100644 index 000000000..fd7419a06 --- /dev/null +++ b/config/identity/handler/interaction/routes/consent.json @@ -0,0 +1,21 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles the interaction that occurs when a logged in user wants to authenticate with a new app.", + "@id": "urn:solid-server:auth:password:ConsentRouteHandler", + "@type":"InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:auth:password:ConsentRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/consent/" + }, + "source": { + "@id": "urn:solid-server:auth:password:ConsentHandler", + "@type": "ConsentHandler", + "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" } + } + } + ] +} diff --git a/config/identity/handler/interaction/routes/forgot-password.json b/config/identity/handler/interaction/routes/forgot-password.json index 3fe3ca6c5..7bf2b117e 100644 --- a/config/identity/handler/interaction/routes/forgot-password.json +++ b/config/identity/handler/interaction/routes/forgot-password.json @@ -1,33 +1,26 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { - "comment": "Handles all functionality on the forgot password page", - "@id": "urn:solid-server:auth:password:ForgotPasswordRoute", - "@type": "BasicInteractionRoute", - "route": "^/forgotpassword/$", - "viewTemplates": { - "BasicInteractionRoute:_viewTemplates_key": "text/html", - "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/forgot-password.html.ejs" + "comment": "Handles the forgot password interaction", + "@id": "urn:solid-server:auth:password:ForgotPasswordRouteHandler", + "@type":"InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:auth:password:ForgotPasswordRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/forgotpassword/" }, - "responseTemplates": { - "BasicInteractionRoute:_responseTemplates_key": "text/html", - "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/forgot-password-response.html.ejs" - }, - "controls": { - "BasicInteractionRoute:_controls_key": "forgotPassword", - "BasicInteractionRoute:_controls_value": "/forgotpassword/" - }, - "handler": { + "source": { + "@id": "urn:solid-server:auth:password:ForgotPasswordHandler", "@type": "ForgotPasswordHandler", "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, - "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "args_idpPath": "/idp", "args_templateEngine": { "@type": "EjsTemplateEngine", "template": "@css:templates/identity/email-password/reset-password-email.html.ejs" }, - "args_emailSender": { "@id": "urn:solid-server:default:EmailSender" } + "args_emailSender": { "@id": "urn:solid-server:default:EmailSender" }, + "args_resetRoute": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" } } } ] diff --git a/config/identity/handler/interaction/routes/index.json b/config/identity/handler/interaction/routes/index.json new file mode 100644 index 000000000..2c935720f --- /dev/null +++ b/config/identity/handler/interaction/routes/index.json @@ -0,0 +1,21 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Root API entry. Returns an empty body so we can add controls pointing to other interaction routes.", + "@id": "urn:solid-server:auth:password:IndexRouteHandler", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:auth:password:IndexRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "relativePath": "/idp/" + }, + "source": { + "@id": "urn:solid-server:auth:password:IndexHandler", + "@type": "FixedInteractionHandler", + "response": {} + } + } + ] +} diff --git a/config/identity/handler/interaction/routes/login.json b/config/identity/handler/interaction/routes/login.json index 2be5fe2c0..626b398d7 100644 --- a/config/identity/handler/interaction/routes/login.json +++ b/config/identity/handler/interaction/routes/login.json @@ -1,21 +1,18 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { - "comment": "Handles all functionality on the Login Page", - "@id": "urn:solid-server:auth:password:LoginRoute", - "@type": "BasicInteractionRoute", - "route": "^/login/$", - "prompt": "login", - "viewTemplates": { - "BasicInteractionRoute:_viewTemplates_key": "text/html", - "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/login.html.ejs" + "comment": "Handles the login interaction", + "@id": "urn:solid-server:auth:password:LoginRouteHandler", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:auth:password:LoginRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/login/" }, - "controls": { - "BasicInteractionRoute:_controls_key": "login", - "BasicInteractionRoute:_controls_value": "/login/" - }, - "handler": { + "source": { + "@id": "urn:solid-server:auth:password:LoginHandler", "@type": "LoginHandler", "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" } } diff --git a/config/identity/handler/interaction/routes/prompt.json b/config/identity/handler/interaction/routes/prompt.json new file mode 100644 index 000000000..538ae07c0 --- /dev/null +++ b/config/identity/handler/interaction/routes/prompt.json @@ -0,0 +1,30 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles OIDC redirects containing a prompt, such as login or consent.", + "@id": "urn:solid-server:auth:password:PromptRouteHandler", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:auth:password:PromptRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/prompt/" + }, + "source": { + "@type": "PromptHandler", + "@id": "urn:solid-server:auth:password:PromptHandler", + "promptRoutes": [ + { + "PromptHandler:_promptRoutes_key": "login", + "PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:auth:password:LoginRoute" } + }, + { + "PromptHandler:_promptRoutes_key": "consent", + "PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:auth:password:ConsentRoute" } + } + ] + } + } + ] +} diff --git a/config/identity/handler/interaction/routes/reset-password.json b/config/identity/handler/interaction/routes/reset-password.json index c4cdd7644..7c33c9e10 100644 --- a/config/identity/handler/interaction/routes/reset-password.json +++ b/config/identity/handler/interaction/routes/reset-password.json @@ -1,21 +1,18 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", - "comment": "Exports 2 handlers: one for viewing the page and one for doing the reset.", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { - "comment": "Handles the reset password page submission", - "@id": "urn:solid-server:auth:password:ResetPasswordRoute", - "@type": "BasicInteractionRoute", - "route": "^/resetpassword/[^/]*$", - "viewTemplates": { - "BasicInteractionRoute:_viewTemplates_key": "text/html", - "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/reset-password.html.ejs" + "comment": "Handles the reset password interaction", + "@id": "urn:solid-server:auth:password:ResetPasswordRouteHandler", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:auth:password:ResetPasswordRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/resetpassword/" }, - "responseTemplates": { - "BasicInteractionRoute:_responseTemplates_key": "text/html", - "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/reset-password-response.html.ejs" - }, - "handler": { + "source": { + "@id": "urn:solid-server:auth:password:ResetPasswordHandler", "@type": "ResetPasswordHandler", "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" } } diff --git a/config/identity/handler/interaction/routes/session.json b/config/identity/handler/interaction/routes/session.json deleted file mode 100644 index c3450a4fb..000000000 --- a/config/identity/handler/interaction/routes/session.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", - "@graph": [ - { - "comment": "Handles confirm requests", - "@id": "urn:solid-server:auth:password:SessionRoute", - "@type": "BasicInteractionRoute", - "route": "^/confirm/$", - "prompt": "consent", - "viewTemplates": { - "BasicInteractionRoute:_viewTemplates_key": "text/html", - "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/confirm.html.ejs" - }, - "handler": { "@type": "SessionHttpHandler" } - } - ] -} diff --git a/config/identity/handler/interaction/views/controls.json b/config/identity/handler/interaction/views/controls.json new file mode 100644 index 000000000..ab514e16a --- /dev/null +++ b/config/identity/handler/interaction/views/controls.json @@ -0,0 +1,27 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "@graph": [ + { + "@id": "urn:solid-server:auth:password:ControlHandler", + "@type": "ControlHandler", + "controls": [ + { + "ControlHandler:_controls_key": "index", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:IndexRoute" } + }, + { + "ControlHandler:_controls_key": "prompt", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:PromptRoute" } + }, + { + "ControlHandler:_controls_key": "login", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:LoginRoute" } + }, + { + "ControlHandler:_controls_key": "forgotPassword", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" } + } + ] + } + ] +} diff --git a/config/identity/handler/interaction/views/html.json b/config/identity/handler/interaction/views/html.json new file mode 100644 index 000000000..f99bc125a --- /dev/null +++ b/config/identity/handler/interaction/views/html.json @@ -0,0 +1,44 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "@graph": [ + { + "@id": "urn:solid-server:auth:password:HtmlViewHandler", + "@type": "HtmlViewHandler", + "index": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "templateEngine": { + "comment": "Renders the specific page and embeds it into the main HTML body.", + "@type": "ChainedTemplateEngine", + "renderedName": "htmlBody", + "engines": [ + { + "comment": "Will be called with specific templates to generate HTML snippets.", + "@type": "EjsTemplateEngine" + }, + { + "comment": "Will embed the result of the first engine into the main HTML template.", + "@type": "EjsTemplateEngine", + "template": "@css:templates/main.html.ejs" + } + ] + }, + "templates": [ + { + "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/login.html.ejs", + "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:LoginRoute" } + }, + { + "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/consent.html.ejs", + "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ConsentRoute" } + }, + { + "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/forgot-password.html.ejs", + "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" } + }, + { + "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/reset-password.html.ejs", + "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" } + } + ] + } + ] +} diff --git a/config/identity/handler/provider-factory/identity.json b/config/identity/handler/provider-factory/identity.json index 1ddff6918..04bed93e1 100644 --- a/config/identity/handler/provider-factory/identity.json +++ b/config/identity/handler/provider-factory/identity.json @@ -1,49 +1,50 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": [ - "Sets all the relevant oidc parameters.", - "webid claim is in openid scope until an official scope has been decided: https://github.com/solid/authentication-panel/issues/86" - ], + "Sets all the relevant Solid-OIDC parameters.", + "dPoP is draft-01 since that is the latest version v6 of the OIDC library supports." + ], "@id": "urn:solid-server:default:IdentityProviderFactory", "@type": "IdentityProviderFactory", "args_adapterFactory": { "@id": "urn:solid-server:default:IdpAdapterFactory" }, "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "args_idpPath": "/idp", + "args_oidcPath": "/.oidc", + "args_interactionHandler": { "@id": "urn:solid-server:auth:password:PromptHandler" }, "args_storage": { "@id": "urn:solid-server:default:IdpKeyStorage" }, "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" }, "args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }, "config": { "claims": { - "openid": [ "webid", "client_id" ] + "openid": [ "azp" ], + "webid": [ "webid" ] }, "cookies": { "long": { "signed": true, "maxAge": 86400000 }, "short": { "signed": true } }, - "discovery": { - "solid_oidc_supported": "https://solidproject.org/TR/solid-oidc" - }, "features": { "claimsParameter": { "enabled": true }, "devInteractions": { "enabled": false }, - "dPoP": { "enabled": true, "ack": "draft-01" }, + "dPoP": { "enabled": true, "ack": "draft-03" }, "introspection": { "enabled": true }, "registration": { "enabled": true }, - "revocation": { "enabled": true } + "revocation": { "enabled": true }, + "userinfo": { "enabled": false } }, - "formats": { - "AccessToken": "jwt" - }, - "scopes": [ "openid", "profile", "offline_access" ], - "subjectTypes": [ "public", "pairwise" ], + "scopes": [ "openid", "profile", "offline_access", "webid" ], + "subjectTypes": [ "public" ], "ttl": { "AccessToken": 3600, "AuthorizationCode": 600, + "BackchannelAuthenticationRequest": 600, "DeviceCode": 600, + "Grant": 1209600, "IdToken": 3600, - "RefreshToken": 86400 + "Interaction": 3600, + "RefreshToken": 86400, + "Session": 1209600 } } } diff --git a/config/identity/ownership/token.json b/config/identity/ownership/token.json index fb3275c70..363186c8a 100644 --- a/config/identity/ownership/token.json +++ b/config/identity/ownership/token.json @@ -1,11 +1,10 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Determines WebID ownership by requesting a specific value to be added to the WebID document", "@id": "urn:solid-server:auth:password:OwnershipValidator", "@type": "TokenOwnershipValidator", - "converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, "storage": { "@id": "urn:solid-server:default:ExpiringTokenStorage" } }, diff --git a/config/identity/ownership/unsafe-no-check.json b/config/identity/ownership/unsafe-no-check.json index 400e5e090..ab43da0a9 100644 --- a/config/identity/ownership/unsafe-no-check.json +++ b/config/identity/ownership/unsafe-no-check.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": [ diff --git a/config/identity/pod/dynamic.json b/config/identity/pod/dynamic.json index 2c6dc2f49..052b84bb1 100644 --- a/config/identity/pod/dynamic.json +++ b/config/identity/pod/dynamic.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/identity/pod/pod-generators/templated.json", "files-scs:config/identity/pod/resource-generators/templated.json" diff --git a/config/identity/pod/pod-generators/templated.json b/config/identity/pod/pod-generators/templated.json index 81e1a2bf0..da78a796a 100644 --- a/config/identity/pod/pod-generators/templated.json +++ b/config/identity/pod/pod-generators/templated.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Generates ResourceStores that correspond to new pods.", diff --git a/config/identity/pod/resource-generators/templated.json b/config/identity/pod/resource-generators/templated.json index 98963f350..fc2b6b1ab 100644 --- a/config/identity/pod/resource-generators/templated.json +++ b/config/identity/pod/resource-generators/templated.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Generates resources based on the templates stored in the template folder.", diff --git a/config/identity/pod/static.json b/config/identity/pod/static.json index f7add1c83..47bc308e8 100644 --- a/config/identity/pod/static.json +++ b/config/identity/pod/static.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/identity/pod/resource-generators/templated.json" ], diff --git a/config/identity/registration/disabled.json b/config/identity/registration/disabled.json index 712f75207..97c7b8192 100644 --- a/config/identity/registration/disabled.json +++ b/config/identity/registration/disabled.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Disable registration by not attaching a registration handler." diff --git a/config/identity/registration/enabled.json b/config/identity/registration/enabled.json index 7ff5663b9..a781dc9a5 100644 --- a/config/identity/registration/enabled.json +++ b/config/identity/registration/enabled.json @@ -1,14 +1,38 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/identity/registration/route/registration.json" ], "@graph": [ { - "comment": "Enable registration by adding a registration handler to the list of interaction routes.", - "@id": "urn:solid-server:default:IdentityProviderHttpHandler", - "IdentityProviderHttpHandler:_args_interactionRoutes": [ - { "@id": "urn:solid-server:auth:password:RegistrationRoute" } + "@id": "urn:solid-server:auth:password:InteractionRouteHandler", + "WaterfallHandler:_handlers": [ + { + "comment": [ + "This handler is required to prevent Components.js issues with arrays.", + "This might be fixed in the next Components.js release after which this can be removed." + ], + "@type": "UnsupportedAsyncHandler" + }, + { "@id": "urn:solid-server:auth:password:RegistrationRouteHandler" } + ] + }, + { + "@id": "urn:solid-server:auth:password:ControlHandler", + "ControlHandler:_controls": [ + { + "ControlHandler:_controls_key": "register", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:RegistrationRoute" } + } + ] + }, + { + "@id": "urn:solid-server:auth:password:HtmlViewHandler", + "HtmlViewHandler:_templates": [ + { + "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/register.html.ejs", + "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:RegistrationRoute" } + } ] } ] diff --git a/config/identity/registration/route/registration.json b/config/identity/registration/route/registration.json index c0aff91af..16e9ded4c 100644 --- a/config/identity/registration/route/registration.json +++ b/config/identity/registration/route/registration.json @@ -1,24 +1,18 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { - "comment": "Handles all functionality on the register page", - "@id": "urn:solid-server:auth:password:RegistrationRoute", - "@type": "BasicInteractionRoute", - "route": "^/register/$", - "viewTemplates": { - "BasicInteractionRoute:_viewTemplates_key": "text/html", - "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/register.html.ejs" + "comment": "Handles the register interaction", + "@id": "urn:solid-server:auth:password:RegistrationRouteHandler", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:auth:password:RegistrationRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/register/" }, - "responseTemplates": { - "BasicInteractionRoute:_responseTemplates_key": "text/html", - "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/register-response.html.ejs" - }, - "controls": { - "BasicInteractionRoute:_controls_key": "register", - "BasicInteractionRoute:_controls_value": "/register/" - }, - "handler": { + "source": { + "@id": "urn:solid-server:auth:password:RegistrationHandler", "@type": "RegistrationHandler", "registrationManager": { "@type": "RegistrationManager", diff --git a/config/ldp/authentication/debug-auth-header.json b/config/ldp/authentication/debug-auth-header.json index 92ebefc34..fc194c14a 100644 --- a/config/ldp/authentication/debug-auth-header.json +++ b/config/ldp/authentication/debug-auth-header.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": [ diff --git a/config/ldp/authentication/debug-test-agent.json b/config/ldp/authentication/debug-test-agent.json index 96fb75313..2b230b1be 100644 --- a/config/ldp/authentication/debug-test-agent.json +++ b/config/ldp/authentication/debug-test-agent.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": [ diff --git a/config/ldp/authentication/dpop-bearer.json b/config/ldp/authentication/dpop-bearer.json index e30632089..385841ebd 100644 --- a/config/ldp/authentication/dpop-bearer.json +++ b/config/ldp/authentication/dpop-bearer.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Supports DPoP and Bearer access tokens, or no credentials.", diff --git a/config/ldp/authorization/allow-all.json b/config/ldp/authorization/allow-all.json index 149e5808a..183b28ea0 100644 --- a/config/ldp/authorization/allow-all.json +++ b/config/ldp/authorization/allow-all.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": [ diff --git a/config/ldp/authorization/readers/access-checkers/agent-class.json b/config/ldp/authorization/readers/access-checkers/agent-class.json index a86b314ea..504274359 100644 --- a/config/ldp/authorization/readers/access-checkers/agent-class.json +++ b/config/ldp/authorization/readers/access-checkers/agent-class.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Checks access based on the agent being authenticated or not.", diff --git a/config/ldp/authorization/readers/access-checkers/agent-group.json b/config/ldp/authorization/readers/access-checkers/agent-group.json index fca974b8f..fafe17aa4 100644 --- a/config/ldp/authorization/readers/access-checkers/agent-group.json +++ b/config/ldp/authorization/readers/access-checkers/agent-group.json @@ -1,11 +1,10 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Checks if the agent belongs to a group that has access.", "@id": "urn:solid-server:default:AgentGroupAccessChecker", "@type": "AgentGroupAccessChecker", - "converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, "cache": { "@id": "urn:solid-server:default:ExpiringAclCache", "@type": "WrappedExpiringStorage", diff --git a/config/ldp/authorization/readers/access-checkers/agent.json b/config/ldp/authorization/readers/access-checkers/agent.json index ab62e7bfd..f87e1fd7b 100644 --- a/config/ldp/authorization/readers/access-checkers/agent.json +++ b/config/ldp/authorization/readers/access-checkers/agent.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Checks if a specific WebID has access.", diff --git a/config/ldp/authorization/readers/acl.json b/config/ldp/authorization/readers/acl.json index 3f2fe1ab8..cf1f2177c 100644 --- a/config/ldp/authorization/readers/acl.json +++ b/config/ldp/authorization/readers/acl.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/ldp/authorization/readers/access-checkers/agent.json", "files-scs:config/ldp/authorization/readers/access-checkers/agent-class.json", diff --git a/config/ldp/authorization/readers/ownership.json b/config/ldp/authorization/readers/ownership.json index 69bee3242..79863d37c 100644 --- a/config/ldp/authorization/readers/ownership.json +++ b/config/ldp/authorization/readers/ownership.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Allows pod owners to always edit permissions on the data.", diff --git a/config/ldp/authorization/webacl.json b/config/ldp/authorization/webacl.json index ee8a74b7a..61c208dac 100644 --- a/config/ldp/authorization/webacl.json +++ b/config/ldp/authorization/webacl.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/ldp/authorization/readers/acl.json", "files-scs:config/ldp/authorization/readers/ownership.json" diff --git a/config/ldp/handler/components/authorizer.json b/config/ldp/handler/components/authorizer.json index 08dd27836..a962ef954 100644 --- a/config/ldp/handler/components/authorizer.json +++ b/config/ldp/handler/components/authorizer.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Matches requested permissions with those available.", diff --git a/config/ldp/handler/components/error-handler.json b/config/ldp/handler/components/error-handler.json index 406bc88c2..abfbfacfe 100644 --- a/config/ldp/handler/components/error-handler.json +++ b/config/ldp/handler/components/error-handler.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Wraps around the main error handler as a fallback in case something goes wrong.", @@ -7,10 +7,19 @@ "@type": "SafeErrorHandler", "showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" }, "errorHandler": { - "comment": "Changes an error into a valid representation to send as a response.", - "@type": "ConvertingErrorHandler", - "converter": { "@id": "urn:solid-server:default:UiEnabledConverter" }, - "showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" } + "@type": "WaterfallHandler", + "handlers": [ + { + "comment": "Internally redirects are created by throwing a specific error, this handler converts them to the correct response.", + "@type": "RedirectingErrorHandler" + }, + { + "comment": "Converts an Error object into a representation for an HTTP response.", + "@type": "ConvertingErrorHandler", + "converter": { "@id": "urn:solid-server:default:UiEnabledConverter" }, + "showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" } + } + ] } } ] diff --git a/config/ldp/handler/components/operation-handler.json b/config/ldp/handler/components/operation-handler.json index b3ff0d3fa..0a15f9ecc 100644 --- a/config/ldp/handler/components/operation-handler.json +++ b/config/ldp/handler/components/operation-handler.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "@id": "urn:solid-server:default:OperationHandler", @@ -28,6 +28,10 @@ { "@type": "PatchOperationHandler", "store": { "@id": "urn:solid-server:default:ResourceStore" } + }, + { + "@type": "StaticThrowHandler", + "error": { "@type": "MethodNotAllowedHttpError" } } ] } diff --git a/config/ldp/handler/components/operation-metadata.json b/config/ldp/handler/components/operation-metadata.json index 283944802..951958aec 100644 --- a/config/ldp/handler/components/operation-metadata.json +++ b/config/ldp/handler/components/operation-metadata.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "@id": "urn:solid-server:default:OperationMetadataCollector", diff --git a/config/ldp/handler/components/request-parser.json b/config/ldp/handler/components/request-parser.json index da90f2480..e49ceb190 100644 --- a/config/ldp/handler/components/request-parser.json +++ b/config/ldp/handler/components/request-parser.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Handles everything related to parsing a Request.", @@ -16,10 +16,23 @@ "args_bodyParser": { "@type": "WaterfallHandler", "handlers": [ - { "@type": "SparqlUpdateBodyParser" }, + { "@id": "urn:solid-server:default:PatchBodyParser" }, { "@type": "RawBodyParser" } ] } + }, + { + "comment": "Handles body parsing for PATCH requests. Those requests need to generate an interpreted Patch body.", + "@id": "urn:solid-server:default:PatchBodyParser", + "@type": "MethodFilterHandler", + "methods": [ "PATCH" ], + "source": { + "@type": "WaterfallHandler", + "handlers": [ + { "@type": "N3PatchBodyParser" }, + { "@type": "SparqlUpdateBodyParser" } + ] + } } ] } diff --git a/config/ldp/handler/components/response-writer.json b/config/ldp/handler/components/response-writer.json index 4adee6290..a9c8a3ed3 100644 --- a/config/ldp/handler/components/response-writer.json +++ b/config/ldp/handler/components/response-writer.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Writes the result to the response stream.", diff --git a/config/ldp/handler/default.json b/config/ldp/handler/default.json index 4aa398581..b6046e468 100644 --- a/config/ldp/handler/default.json +++ b/config/ldp/handler/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/ldp/handler/components/authorizer.json", "files-scs:config/ldp/handler/components/error-handler.json", diff --git a/config/ldp/metadata-parser/default.json b/config/ldp/metadata-parser/default.json index bd9449c6a..e886b3b90 100644 --- a/config/ldp/metadata-parser/default.json +++ b/config/ldp/metadata-parser/default.json @@ -1,7 +1,8 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/ldp/metadata-parser/parsers/content-type.json", + "files-scs:config/ldp/metadata-parser/parsers/content-length.json", "files-scs:config/ldp/metadata-parser/parsers/slug.json", "files-scs:config/ldp/metadata-parser/parsers/link.json" ], @@ -12,6 +13,7 @@ "@type": "ParallelHandler", "handlers": [ { "@id": "urn:solid-server:default:ContentTypeParser" }, + { "@id": "urn:solid-server:default:ContentLengthParser" }, { "@id": "urn:solid-server:default:SlugParser" }, { "@id": "urn:solid-server:default:LinkRelParser" } ] diff --git a/config/ldp/metadata-parser/parsers/content-length.json b/config/ldp/metadata-parser/parsers/content-length.json new file mode 100644 index 000000000..d66a6452a --- /dev/null +++ b/config/ldp/metadata-parser/parsers/content-length.json @@ -0,0 +1,10 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Converts content-length headers into RDF metadata.", + "@id": "urn:solid-server:default:ContentLengthParser", + "@type": "ContentLengthParser" + } + ] +} diff --git a/config/ldp/metadata-parser/parsers/content-type.json b/config/ldp/metadata-parser/parsers/content-type.json index 5589a9718..3b77acd18 100644 --- a/config/ldp/metadata-parser/parsers/content-type.json +++ b/config/ldp/metadata-parser/parsers/content-type.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts content-type headers into RDF metadata.", diff --git a/config/ldp/metadata-parser/parsers/link.json b/config/ldp/metadata-parser/parsers/link.json index 5ef87460b..15ed1922e 100644 --- a/config/ldp/metadata-parser/parsers/link.json +++ b/config/ldp/metadata-parser/parsers/link.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts link headers into RDF metadata.", diff --git a/config/ldp/metadata-parser/parsers/slug.json b/config/ldp/metadata-parser/parsers/slug.json index 35524b515..f54db9ed5 100644 --- a/config/ldp/metadata-parser/parsers/slug.json +++ b/config/ldp/metadata-parser/parsers/slug.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts slug headers into RDF metadata.", diff --git a/config/ldp/metadata-writer/default.json b/config/ldp/metadata-writer/default.json index 70f6234d3..86230d135 100644 --- a/config/ldp/metadata-writer/default.json +++ b/config/ldp/metadata-writer/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/ldp/metadata-writer/writers/constant.json", "files-scs:config/ldp/metadata-writer/writers/link-rel.json", diff --git a/config/ldp/metadata-writer/writers/constant.json b/config/ldp/metadata-writer/writers/constant.json index aaaaf8ef0..542450d90 100644 --- a/config/ldp/metadata-writer/writers/constant.json +++ b/config/ldp/metadata-writer/writers/constant.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Adds fixed headers to the response.", @@ -8,7 +8,7 @@ "headers": [ { "ConstantMetadataWriter:_headers_key": "Accept-Patch", - "ConstantMetadataWriter:_headers_value": "application/sparql-update" + "ConstantMetadataWriter:_headers_value": "application/sparql-update, text/n3" }, { "ConstantMetadataWriter:_headers_key": "Allow", diff --git a/config/ldp/metadata-writer/writers/link-rel.json b/config/ldp/metadata-writer/writers/link-rel.json index 47d07f060..2fb4383cd 100644 --- a/config/ldp/metadata-writer/writers/link-rel.json +++ b/config/ldp/metadata-writer/writers/link-rel.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts all triples with the given predicates to Link headers.", diff --git a/config/ldp/metadata-writer/writers/mapped.json b/config/ldp/metadata-writer/writers/mapped.json index c3013cadb..45748bc55 100644 --- a/config/ldp/metadata-writer/writers/mapped.json +++ b/config/ldp/metadata-writer/writers/mapped.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts all triples with the given predicate to headers of the given type.", diff --git a/config/ldp/metadata-writer/writers/modified.json b/config/ldp/metadata-writer/writers/modified.json index 901247041..f000a0f12 100644 --- a/config/ldp/metadata-writer/writers/modified.json +++ b/config/ldp/metadata-writer/writers/modified.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Adds the Last-Modified and ETag headers.", diff --git a/config/ldp/metadata-writer/writers/wac-allow.json b/config/ldp/metadata-writer/writers/wac-allow.json index 9a0efde98..c9a20e2fa 100644 --- a/config/ldp/metadata-writer/writers/wac-allow.json +++ b/config/ldp/metadata-writer/writers/wac-allow.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Adds the correct Wac-Allow header.", diff --git a/config/ldp/metadata-writer/writers/www-auth.json b/config/ldp/metadata-writer/writers/www-auth.json index 859a78646..7c19e328b 100644 --- a/config/ldp/metadata-writer/writers/www-auth.json +++ b/config/ldp/metadata-writer/writers/www-auth.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Adds the WWW-Authenticate header on 401 responses. The current auth value is required for the legacy solid-auth-client.", diff --git a/config/ldp/modes/default.json b/config/ldp/modes/default.json index b83d49299..d7de98099 100644 --- a/config/ldp/modes/default.json +++ b/config/ldp/modes/default.json @@ -1,14 +1,40 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Determines required modes based on HTTP methods.", "@id": "urn:solid-server:default:ModesExtractor", "@type": "WaterfallHandler", "handlers": [ - { "@type": "MethodModesExtractor" }, - { "@type": "SparqlPatchModesExtractor" } + { + "comment": "Extract access modes for PATCH requests based on the request body.", + "@id": "urn:solid-server:default:PatchModesExtractor" + }, + { + "comment": "Extract access modes based on the HTTP method.", + "@type": "MethodModesExtractor" + }, + { + "@type": "StaticThrowHandler", + "error": { "@type": "MethodNotAllowedHttpError" } + } ] + }, + { + "@id": "urn:solid-server:default:PatchModesExtractor", + "@type": "MethodFilterHandler", + "methods": [ "PATCH" ], + "source": { + "@type": "WaterfallHandler", + "handlers": [ + { "@type": "N3PatchModesExtractor" }, + { "@type": "SparqlUpdateModesExtractor" }, + { + "@type": "StaticThrowHandler", + "error": { "@type": "UnsupportedMediaTypeHttpError" } + } + ] + } } ] } diff --git a/config/memory-subdomains.json b/config/memory-subdomains.json index fef343dd2..b2c0925b1 100644 --- a/config/memory-subdomains.json +++ b/config/memory-subdomains.json @@ -1,9 +1,10 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", "files-scs:config/app/setup/optional.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/path-routing.json b/config/path-routing.json index f05e98833..ee9c6eb3c 100644 --- a/config/path-routing.json +++ b/config/path-routing.json @@ -1,9 +1,10 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", "files-scs:config/app/setup/disabled.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/quota-file.json b/config/quota-file.json new file mode 100644 index 000000000..070751620 --- /dev/null +++ b/config/quota-file.json @@ -0,0 +1,48 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "import": [ + "files-scs:config/app/main/default.json", + "files-scs:config/app/init/default.json", + "files-scs:config/app/setup/required.json", + "files-scs:config/http/handler/default.json", + "files-scs:config/http/middleware/websockets.json", + "files-scs:config/http/server-factory/websockets.json", + "files-scs:config/http/static/default.json", + "files-scs:config/identity/access/public.json", + "files-scs:config/identity/email/default.json", + "files-scs:config/identity/handler/default.json", + "files-scs:config/identity/ownership/token.json", + "files-scs:config/identity/pod/static.json", + "files-scs:config/identity/registration/enabled.json", + "files-scs:config/ldp/authentication/dpop-bearer.json", + "files-scs:config/ldp/authorization/allow-all.json", + "files-scs:config/ldp/handler/default.json", + "files-scs:config/ldp/metadata-parser/default.json", + "files-scs:config/ldp/metadata-writer/default.json", + "files-scs:config/ldp/modes/default.json", + "files-scs:config/storage/backend/pod-quota-file.json", + "files-scs:config/storage/key-value/resource-store.json", + "files-scs:config/storage/middleware/default.json", + "files-scs:config/util/auxiliary/acl.json", + "files-scs:config/util/identifiers/suffix.json", + "files-scs:config/util/index/default.json", + "files-scs:config/util/logging/winston.json", + "files-scs:config/util/representation-conversion/default.json", + "files-scs:config/util/resource-locker/memory.json", + "files-scs:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": "A server that stores its resources on disk while enforcing quota." + }, + { + "@id": "urn:solid-server:default:QuotaStrategy", + "PodQuotaStrategy:_limit_amount": 7000, + "PodQuotaStrategy:_limit_unit": "bytes" + }, + { + "@id": "urn:solid-server:default:SizeReporter", + "FileSizeReporter:_ignoreFolders": [ "^/\\.internal$" ] + } + ] +} diff --git a/config/restrict-idp.json b/config/restrict-idp.json index 4ad5edfd2..5570f8f87 100644 --- a/config/restrict-idp.json +++ b/config/restrict-idp.json @@ -1,9 +1,10 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", "files-scs:config/app/setup/disabled.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/sparql-endpoint-no-setup.json b/config/sparql-endpoint-no-setup.json index 4f4f9731d..6f1451a1e 100644 --- a/config/sparql-endpoint-no-setup.json +++ b/config/sparql-endpoint-no-setup.json @@ -1,9 +1,10 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", "files-scs:config/app/setup/disabled.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/sparql-endpoint.json b/config/sparql-endpoint.json index af64b6186..eee264d88 100644 --- a/config/sparql-endpoint.json +++ b/config/sparql-endpoint.json @@ -1,9 +1,10 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", "files-scs:config/app/setup/required.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/sparql-file-storage.json b/config/sparql-file-storage.json index ecf08373c..1a8401669 100644 --- a/config/sparql-file-storage.json +++ b/config/sparql-file-storage.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", diff --git a/config/storage/README.md b/config/storage/README.md index 52e679bfb..9626d2f6a 100644 --- a/config/storage/README.md +++ b/config/storage/README.md @@ -5,7 +5,9 @@ Options related to how data and resources are stored. The final part of the ResourceStore chain that handles data access. * *dynamic*: The routing store used here is needed when using dynamic pod creation. * *file*: Default setup with a file backend. +* *global-quota-file*: File backend with a global quota over the entire server. * *memory*: Default setup with a memory backend. +* *pod-quota-file*: File backend with a max quota per pod. * *regex*: Uses a different backend based on the container that is being used. * *sparql*: Default setup with a SPARQL endpoint backend. Also updates the converting store so all incoming data is transformed into quads. diff --git a/config/storage/backend/data-accessors/file.json b/config/storage/backend/data-accessors/file.json index a8414c938..4548fc9db 100644 --- a/config/storage/backend/data-accessors/file.json +++ b/config/storage/backend/data-accessors/file.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Stores data on a file system.", diff --git a/config/storage/backend/data-accessors/memory.json b/config/storage/backend/data-accessors/memory.json index 2a398064b..f6d733e25 100644 --- a/config/storage/backend/data-accessors/memory.json +++ b/config/storage/backend/data-accessors/memory.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Stores data in memory.", diff --git a/config/storage/backend/data-accessors/sparql-endpoint.json b/config/storage/backend/data-accessors/sparql-endpoint.json index dc4fc1854..6d6318e83 100644 --- a/config/storage/backend/data-accessors/sparql-endpoint.json +++ b/config/storage/backend/data-accessors/sparql-endpoint.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Stores data on a SPARQL endpoint. Only supports quad object streams.", diff --git a/config/storage/backend/dynamic.json b/config/storage/backend/dynamic.json index 84675d8d4..e046af8ac 100644 --- a/config/storage/backend/dynamic.json +++ b/config/storage/backend/dynamic.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/storage/backend/data-accessors/file.json" ], diff --git a/config/storage/backend/file.json b/config/storage/backend/file.json index bef105a76..45bc7b015 100644 --- a/config/storage/backend/file.json +++ b/config/storage/backend/file.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/storage/backend/data-accessors/file.json" ], diff --git a/config/storage/backend/global-quota-file.json b/config/storage/backend/global-quota-file.json new file mode 100644 index 000000000..9d7d2f0b0 --- /dev/null +++ b/config/storage/backend/global-quota-file.json @@ -0,0 +1,17 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "import": [ + "files-scs:config/storage/backend/quota/global-quota-file.json", + "files-scs:config/storage/backend/quota/quota-file.json" + ], + "@graph": [ + { + "comment": "A global quota store setup with a file system backend.", + "@id": "urn:solid-server:default:ResourceStore_Backend", + "@type": "DataAccessorBasedStore", + "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" }, + "auxiliaryStrategy": { "@id": "urn:solid-server:default:AuxiliaryStrategy" }, + "accessor": { "@id": "urn:solid-server:default:FileDataAccessor" } + } + ] +} diff --git a/config/storage/backend/memory.json b/config/storage/backend/memory.json index 46a1da431..ba8ab3fc0 100644 --- a/config/storage/backend/memory.json +++ b/config/storage/backend/memory.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/storage/backend/data-accessors/memory.json" ], diff --git a/config/storage/backend/pod-quota-file.json b/config/storage/backend/pod-quota-file.json new file mode 100644 index 000000000..81fdeadaa --- /dev/null +++ b/config/storage/backend/pod-quota-file.json @@ -0,0 +1,17 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "import": [ + "files-scs:config/storage/backend/quota/pod-quota-file.json", + "files-scs:config/storage/backend/quota/quota-file.json" + ], + "@graph": [ + { + "comment": "A pod quota store setup with a file system backend.", + "@id": "urn:solid-server:default:ResourceStore_Backend", + "@type": "DataAccessorBasedStore", + "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" }, + "auxiliaryStrategy": { "@id": "urn:solid-server:default:AuxiliaryStrategy" }, + "accessor": { "@id": "urn:solid-server:default:FileDataAccessor" } + } + ] +} diff --git a/config/storage/backend/quota/global-quota-file.json b/config/storage/backend/quota/global-quota-file.json new file mode 100644 index 000000000..2747c7aa2 --- /dev/null +++ b/config/storage/backend/quota/global-quota-file.json @@ -0,0 +1,13 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "comment": "Configuration of a GlobalQuotaStrategy to enforce quota globally on the server.", + "@graph": [ + { + "comment": "Enforces quota globally for all data on the server", + "@id": "urn:solid-server:default:QuotaStrategy", + "@type": "GlobalQuotaStrategy", + "reporter": { "@id": "urn:solid-server:default:SizeReporter" }, + "base": { "@id": "urn:solid-server:default:variable:baseUrl" } + } + ] +} diff --git a/config/storage/backend/quota/pod-quota-file.json b/config/storage/backend/quota/pod-quota-file.json new file mode 100644 index 000000000..f69b4f267 --- /dev/null +++ b/config/storage/backend/quota/pod-quota-file.json @@ -0,0 +1,14 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "comment": "Configuration of a PodQuotaStrategy to enforce pod quotas on the server.", + "@graph": [ + { + "comment": "Enforces quota for all data per pod on the server", + "@id": "urn:solid-server:default:QuotaStrategy", + "@type": "PodQuotaStrategy", + "reporter": { "@id": "urn:solid-server:default:SizeReporter" }, + "accessor": { "@id": "urn:solid-server:default:AtomicFileDataAccessor" }, + "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" } + } + ] +} diff --git a/config/storage/backend/quota/quota-file.json b/config/storage/backend/quota/quota-file.json new file mode 100644 index 000000000..d168d1941 --- /dev/null +++ b/config/storage/backend/quota/quota-file.json @@ -0,0 +1,37 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "comment": "DataAccessor configuration using a QuotaStrategy to enforce quota on the server.", + "@graph": [ + { + "comment": "DataAccessor that writes data to the disk with atomicity in mind", + "@id": "urn:solid-server:default:AtomicFileDataAccessor", + "@type": "AtomicFileDataAccessor", + "resourceMapper": { "@id": "urn:solid-server:default:FileIdentifierMapper" }, + "rootFilePath": { "@id": "urn:solid-server:default:variable:rootFilePath" }, + "tempFilePath": "/.internal/tempFiles/" + }, + + { + "comment": "Calculates the space already taken up by a resource", + "@id": "urn:solid-server:default:SizeReporter", + "@type": "FileSizeReporter", + "fileIdentifierMapper": { "@id": "urn:solid-server:default:FileIdentifierMapper" }, + "rootFilePath": { "@id": "urn:solid-server:default:variable:rootFilePath" } + }, + + { + "comment": "Validates the data being written to the server", + "@id": "urn:solid-server:default:QuotaValidator", + "@type": "QuotaValidator", + "strategy": { "@id": "urn:solid-server:default:QuotaStrategy" } + }, + + { + "comment": "Simple wrapper for another DataAccessor but adds validation", + "@id": "urn:solid-server:default:FileDataAccessor", + "@type": "ValidatingDataAccessor", + "accessor": { "@id": "urn:solid-server:default:AtomicFileDataAccessor" }, + "validator": { "@id": "urn:solid-server:default:QuotaValidator" } + } + ] +} diff --git a/config/storage/backend/regex.json b/config/storage/backend/regex.json index 05e0ea78a..ee1a3a328 100644 --- a/config/storage/backend/regex.json +++ b/config/storage/backend/regex.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/storage/backend/data-accessors/file.json", "files-scs:config/storage/backend/data-accessors/memory.json", diff --git a/config/storage/backend/sparql.json b/config/storage/backend/sparql.json index 8954fe94c..02224fddb 100644 --- a/config/storage/backend/sparql.json +++ b/config/storage/backend/sparql.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/storage/backend/data-accessors/sparql-endpoint.json" ], diff --git a/config/storage/key-value/memory.json b/config/storage/key-value/memory.json index 41d766b38..92409512f 100644 --- a/config/storage/key-value/memory.json +++ b/config/storage/key-value/memory.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "These storage solutions store their data in memory." @@ -33,6 +33,11 @@ "comment": "Storage used by setup components.", "@id": "urn:solid-server:default:SetupStorage", "@type": "MemoryMapStorage" + }, + { + "comment": "Storage used for ForgotPassword records", + "@id": "urn:solid-server:default:ForgotPasswordStorage", + "@type":"MemoryMapStorage" } ] } diff --git a/config/storage/key-value/resource-store.json b/config/storage/key-value/resource-store.json index 23d2d9aa3..58f5c711c 100644 --- a/config/storage/key-value/resource-store.json +++ b/config/storage/key-value/resource-store.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "These storage solutions use the specified container in the ResourceStore to store their data." @@ -47,6 +47,14 @@ "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "container": "/.internal/accounts/" }, + { + "comment": "Storage used for ForgotPassword records", + "@id": "urn:solid-server:default:ForgotPasswordStorage", + "@type":"JsonResourceStorage", + "source": { "@id": "urn:solid-server:default:ResourceStore" }, + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "container": "/.internal/forgot-password/" + }, { "comment": "Storage used by setup components.", "@id": "urn:solid-server:default:SetupStorage", diff --git a/config/storage/middleware/default.json b/config/storage/middleware/default.json index 89f765fb2..6b4f041c5 100644 --- a/config/storage/middleware/default.json +++ b/config/storage/middleware/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/storage/middleware/stores/converting.json", "files-scs:config/storage/middleware/stores/locking.json", diff --git a/config/storage/middleware/stores/converting.json b/config/storage/middleware/stores/converting.json index 5b5378c47..0c2c86078 100644 --- a/config/storage/middleware/stores/converting.json +++ b/config/storage/middleware/stores/converting.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts all outgoing resources based on the preferences.", diff --git a/config/storage/middleware/stores/locking.json b/config/storage/middleware/stores/locking.json index c41af95c3..b67ac78df 100644 --- a/config/storage/middleware/stores/locking.json +++ b/config/storage/middleware/stores/locking.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Locks resources when they are accessed until the operation is finished.", diff --git a/config/storage/middleware/stores/monitoring.json b/config/storage/middleware/stores/monitoring.json index 980058fff..8df191730 100644 --- a/config/storage/middleware/stores/monitoring.json +++ b/config/storage/middleware/stores/monitoring.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Emits events on changes to resources.", diff --git a/config/storage/middleware/stores/patching.json b/config/storage/middleware/stores/patching.json index 70368a170..cc1e71532 100644 --- a/config/storage/middleware/stores/patching.json +++ b/config/storage/middleware/stores/patching.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Allows for PATCH operations on stores that don't have native support.", @@ -14,18 +14,31 @@ { "comment": "Makes sure PATCH operations on containers target the metadata.", "@type": "ContainerPatcher", - "patcher": { "@type": "SparqlUpdatePatcher" } + "patcher": { "@id": "urn:solid-server:default:PatchHandler_RDF" } }, { "@type": "ConvertingPatcher", - "patcher": { "@type": "SparqlUpdatePatcher" }, + "patcher": { "@id": "urn:solid-server:default:PatchHandler_RDF" }, "converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, "intermediateType": "internal/quads", "defaultType": "text/turtle" + }, + { + "@type": "StaticThrowHandler", + "error": { "@type": "UnsupportedMediaTypeHttpError" } } ] } } + }, + { + "comment": "Dedicated handlers that apply specific types of patch documents", + "@id": "urn:solid-server:default:PatchHandler_RDF", + "@type": "WaterfallHandler", + "handlers": [ + { "@type": "N3Patcher" }, + { "@type": "SparqlUpdatePatcher" } + ] } ] } diff --git a/config/util/README.md b/config/util/README.md index 831e18d23..5dc8dc3c2 100644 --- a/config/util/README.md +++ b/config/util/README.md @@ -36,6 +36,7 @@ to the ChainedConverter list. ## Resource-locker Which locking mechanism to use to for example prevent 2 write simultaneous write requests. +* *debug-void*: No locking mechanism, does not prevent simultaneous read/writes. * *memory*: Uses an in-memory locking mechanism. * *redis*: Uses a Redis store for locking. diff --git a/config/util/auxiliary/acl.json b/config/util/auxiliary/acl.json index fb413929c..d3bea924f 100644 --- a/config/util/auxiliary/acl.json +++ b/config/util/auxiliary/acl.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/util/auxiliary/strategies/acl.json" ], diff --git a/config/util/auxiliary/no-acl.json b/config/util/auxiliary/no-acl.json index 19c6a3fcf..01e1614cb 100644 --- a/config/util/auxiliary/no-acl.json +++ b/config/util/auxiliary/no-acl.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": [ diff --git a/config/util/auxiliary/strategies/acl.json b/config/util/auxiliary/strategies/acl.json index 1d0674095..042434dfb 100644 --- a/config/util/auxiliary/strategies/acl.json +++ b/config/util/auxiliary/strategies/acl.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Contains all features of acl auxiliary resources (suffix, link header, etc.).", diff --git a/config/util/identifiers/subdomain.json b/config/util/identifiers/subdomain.json index 90e1d39ea..158eb61b5 100644 --- a/config/util/identifiers/subdomain.json +++ b/config/util/identifiers/subdomain.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "comment": "Supports multiple roots such as both http://test.com/ and http://alice.test.com/.", "@graph": [ { diff --git a/config/util/identifiers/suffix.json b/config/util/identifiers/suffix.json index 88df3ef21..352962b33 100644 --- a/config/util/identifiers/suffix.json +++ b/config/util/identifiers/suffix.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "comment": "Only supports a single root, such as http://test.com/. A new pod URL would be http://test.com/alice/.", "@graph": [ { diff --git a/config/util/index/default.json b/config/util/index/default.json index 68cedd743..3d417d340 100644 --- a/config/util/index/default.json +++ b/config/util/index/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "This value can be used to set a custom handler for index files. See the example file.", diff --git a/config/util/index/example.json b/config/util/index/example.json index ebae46e59..211e4dfb4 100644 --- a/config/util/index/example.json +++ b/config/util/index/example.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": [ diff --git a/config/util/logging/no-logging.json b/config/util/logging/no-logging.json index 6a370ef1e..52046e6f4 100644 --- a/config/util/logging/no-logging.json +++ b/config/util/logging/no-logging.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Ignores log messages.", diff --git a/config/util/logging/winston.json b/config/util/logging/winston.json index 07fc822f2..81cb4fea0 100644 --- a/config/util/logging/winston.json +++ b/config/util/logging/winston.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Uses the winston library for logging", diff --git a/config/util/representation-conversion/converters/content-type-replacer.json b/config/util/representation-conversion/converters/content-type-replacer.json index b7c88ec58..233995e6d 100644 --- a/config/util/representation-conversion/converters/content-type-replacer.json +++ b/config/util/representation-conversion/converters/content-type-replacer.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Changes content-type without changing content. E.g., if application/json is requested, application/ld+json is also valid.", diff --git a/config/util/representation-conversion/converters/dynamic-json-template.json b/config/util/representation-conversion/converters/dynamic-json-template.json index c381c0fb1..26dd4f6dd 100644 --- a/config/util/representation-conversion/converters/dynamic-json-template.json +++ b/config/util/representation-conversion/converters/dynamic-json-template.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Uses the JSON data as parameters for a template.", diff --git a/config/util/representation-conversion/converters/errors.json b/config/util/representation-conversion/converters/errors.json index 17eb62f7a..800965ed8 100644 --- a/config/util/representation-conversion/converters/errors.json +++ b/config/util/representation-conversion/converters/errors.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "@id": "urn:solid-server:default:ErrorToJsonConverter", diff --git a/config/util/representation-conversion/converters/form-to-json.json b/config/util/representation-conversion/converters/form-to-json.json index fc991ffd6..4122f7402 100644 --- a/config/util/representation-conversion/converters/form-to-json.json +++ b/config/util/representation-conversion/converters/form-to-json.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts application/x-www-form-urlencoded to application/json.", diff --git a/config/util/representation-conversion/converters/markdown.json b/config/util/representation-conversion/converters/markdown.json index 183ce27be..45f2be4cd 100644 --- a/config/util/representation-conversion/converters/markdown.json +++ b/config/util/representation-conversion/converters/markdown.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Renders Markdown snippets into the main page template.", diff --git a/config/util/representation-conversion/converters/quad-to-rdf.json b/config/util/representation-conversion/converters/quad-to-rdf.json index 90221247c..3d884aeac 100644 --- a/config/util/representation-conversion/converters/quad-to-rdf.json +++ b/config/util/representation-conversion/converters/quad-to-rdf.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts Quad objects to many RDF serialization.", diff --git a/config/util/representation-conversion/converters/rdf-to-quad.json b/config/util/representation-conversion/converters/rdf-to-quad.json index c19ca71b1..2ce2a96ca 100644 --- a/config/util/representation-conversion/converters/rdf-to-quad.json +++ b/config/util/representation-conversion/converters/rdf-to-quad.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts many RDF serialization to Quad objects.", diff --git a/config/util/representation-conversion/default.json b/config/util/representation-conversion/default.json index 32e972d69..c2045d07b 100644 --- a/config/util/representation-conversion/default.json +++ b/config/util/representation-conversion/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/util/representation-conversion/converters/content-type-replacer.json", "files-scs:config/util/representation-conversion/converters/dynamic-json-template.json", @@ -15,18 +15,13 @@ "@id": "urn:solid-server:default:RepresentationConverter", "@type": "WaterfallHandler", "handlers": [ - { "@id": "urn:solid-server:default:MarkdownToHtmlConverter" }, { "@id": "urn:solid-server:default:DynamicJsonToTemplateConverter" }, - { - "@type": "IfNeededConverter", - "comment": "Only continue converting if the requester cannot accept the available content type" - }, - { "@id": "urn:solid-server:default:ContentTypeReplacer" }, { "comment": "Automatically finds a path through a set of converters from one type to another.", "@id": "urn:solid-server:default:ChainedConverter", "@type": "ChainedConverter", "converters": [ + { "@id": "urn:solid-server:default:ContentTypeReplacer" }, { "@id": "urn:solid-server:default:RdfToQuadConverter" }, { "@id": "urn:solid-server:default:QuadToRdfConverter" }, { "@id": "urn:solid-server:default:ContainerToTemplateConverter" }, diff --git a/config/util/resource-locker/debug-void.json b/config/util/resource-locker/debug-void.json new file mode 100644 index 000000000..7776b2670 --- /dev/null +++ b/config/util/resource-locker/debug-void.json @@ -0,0 +1,13 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "@graph": [ + { + "comment": [ + "DO NOT USE IN PRODUCTION. ONLY FOR DEVELOPMENT, TESTING, OR DEBUGGING.", + "Allows multiple simultaneous read operations and write operations without locks." + ], + "@id": "urn:solid-server:default:ResourceLocker", + "@type": "VoidLocker" + } + ] +} diff --git a/config/util/resource-locker/memory.json b/config/util/resource-locker/memory.json index ac9c73201..a4e7a2829 100644 --- a/config/util/resource-locker/memory.json +++ b/config/util/resource-locker/memory.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Allows multiple simultaneous read operations. Locks are stored in memory. Locks expire after inactivity.", diff --git a/config/util/resource-locker/redis.json b/config/util/resource-locker/redis.json index 118150846..9f3ee717e 100644 --- a/config/util/resource-locker/redis.json +++ b/config/util/resource-locker/redis.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Allows multiple simultaneous read operations. Locks are stored in memory. Locks expire after inactivity.", diff --git a/config/util/variables/default.json b/config/util/variables/default.json index d259bcfb6..09f34c94c 100644 --- a/config/util/variables/default.json +++ b/config/util/variables/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "comment": "Variables used throughout the other configs. Can be set using the CLI.", "@graph": [ { @@ -8,7 +8,7 @@ "@type": "Variable" }, { - "comment": "Needs to be set to the base URL of the server for authnetication and authorization to function.", + "comment": "Needs to be set to the base URL of the server for authentication and authorization to function.", "@id": "urn:solid-server:default:variable:baseUrl", "@type": "Variable" }, diff --git a/package-lock.json b/package-lock.json index 8adfba005..c58a0d663 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@solid/community-server", - "version": "2.0.1", + "version": "3.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@solid/community-server", - "version": "2.0.1", + "version": "3.0.0", "license": "MIT", "dependencies": { "@comunica/actor-init-sparql": "^1.22.3", @@ -16,12 +16,14 @@ "@types/bcrypt": "^5.0.0", "@types/cors": "^2.8.12", "@types/end-of-stream": "^1.4.1", + "@types/fs-extra": "^9.0.13", "@types/lodash.orderby": "^4.6.6", "@types/marked": "^4.0.2", "@types/mime-types": "^2.1.1", "@types/n3": "^1.10.4", "@types/node": "^14.18.0", "@types/nodemailer": "^6.4.4", + "@types/oidc-provider": "^7.8.1", "@types/pump": "^1.1.1", "@types/punycode": "^2.1.0", "@types/redis": "^2.8.30", @@ -41,6 +43,7 @@ "end-of-stream": "^1.4.4", "escape-string-regexp": "^4.0.0", "fetch-sparql-endpoint": "^2.4.0", + "fs-extra": "^10.0.0", "handlebars": "^4.7.7", "jose": "^4.4.0", "lodash.orderby": "^4.6.0", @@ -48,11 +51,13 @@ "mime-types": "^2.1.34", "n3": "^1.13.0", "nodemailer": "^6.7.2", - "oidc-provider": "^6.31.1", + "oidc-provider": "^7.10.6", "pump": "^3.0.0", "punycode": "^2.1.1", + "rdf-dereference": "^1.9.0", "rdf-parse": "^1.9.1", "rdf-serialize": "^1.2.0", + "rdf-terms": "^1.7.1", "redis": "^3.1.2", "redlock": "^4.2.0", "sparqlalgebrajs": "^4.0.2", @@ -68,12 +73,11 @@ "community-solid-server": "bin/server.js" }, "devDependencies": { - "@inrupt/solid-client-authn-node": "^1.11.3", + "@inrupt/solid-client-authn-node": "^1.11.5", "@microsoft/tsdoc-config": "^0.15.2", "@tsconfig/node12": "^1.0.9", "@types/cheerio": "^0.22.30", "@types/ejs": "^3.1.0", - "@types/fs-extra": "^9.0.13", "@types/jest": "^27.4.0", "@types/set-cookie-parser": "^2.4.2", "@types/supertest": "^2.0.11", @@ -88,7 +92,6 @@ "eslint-plugin-jest": "^26.0.0", "eslint-plugin-tsdoc": "^0.2.14", "eslint-plugin-unused-imports": "^2.0.0", - "fs-extra": "^10.0.0", "husky": "^4.3.8", "jest": "^27.4.7", "jest-rdf": "^1.7.0", @@ -3726,9 +3729,9 @@ } }, "node_modules/@inrupt/solid-client-authn-core": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.11.3.tgz", - "integrity": "sha512-XhxlH+mmCbDQxRQVCQWR5tS/jM0S+lHyFkxDhT9Ts3gAokv4YfwgYsIe2jHgiY8T4Qp3keYFy4RVhZdSRgKGIQ==", + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.11.5.tgz", + "integrity": "sha512-hsGKgv2SsTEo33V5t9crRs+RdKgdtxuIb3VoiGmaDXqLkd9rI/CZZkqnpwUskf4VuBN7Z3h9TKAFohRJMcvF7Q==", "dev": true, "dependencies": { "@inrupt/solid-common-vocab": "^1.0.0", @@ -3742,24 +3745,24 @@ } }, "node_modules/@inrupt/solid-client-authn-node": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-node/-/solid-client-authn-node-1.11.3.tgz", - "integrity": "sha512-/4z6TcFOLbFwXPFK9P2TGeCVY19vMTDn1UBRiOJ4PA6arsTefWdtd1JMwSTkdB3BKg8bJJPEaYuA87odtixPCQ==", + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-node/-/solid-client-authn-node-1.11.5.tgz", + "integrity": "sha512-ToPEcnCL5BeQdfeB3MzhR9I+ZXQyuoGnumt60R57QarUi7de/BU2Ag+IAEvcbm+HvEtfF5LaabWbapryTRPGYA==", "dev": true, "dependencies": { - "@inrupt/solid-client-authn-core": "^1.11.3", - "@types/node": "^16.11.12", + "@inrupt/solid-client-authn-core": "^1.11.5", + "@types/node": "^17.0.2", "@types/uuid": "^8.3.0", "cross-fetch": "^3.0.6", "jose": "^4.3.7", - "openid-client": "^4.2.2", + "openid-client": "^5.1.0", "uuid": "^8.3.2" } }, "node_modules/@inrupt/solid-client-authn-node/node_modules/@types/node": { - "version": "16.11.22", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.22.tgz", - "integrity": "sha512-DYNtJWauMQ9RNpesl4aVothr97/tIJM8HbyOXJ0AYT1Z2bEjLHyfjOBPAQQVMLf8h3kSShYfNk8Wnto8B2zHUA==", + "version": "17.0.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.18.tgz", + "integrity": "sha512-eKj4f/BsN/qcculZiRSujogjvp5O/k4lOW5m35NopjZM/QwLOR075a8pJW5hD+Rtdm2DaCVPENS6KtSQnUD6BA==", "dev": true }, "node_modules/@inrupt/solid-common-vocab": { @@ -4175,14 +4178,6 @@ "node": ">= 8" } }, - "node_modules/@panva/asn1.js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", - "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/@rdfjs/types": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.0.1.tgz", @@ -4195,6 +4190,7 @@ "version": "0.14.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", + "dev": true, "engines": { "node": ">=6" } @@ -4234,6 +4230,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==", + "dev": true, "dependencies": { "defer-to-connect": "^1.0.1" }, @@ -4340,10 +4337,9 @@ } }, "node_modules/@types/cacheable-request": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.1.tgz", - "integrity": "sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ==", - "dev": true, + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.2.tgz", + "integrity": "sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA==", "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "*", @@ -4434,7 +4430,6 @@ "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -4454,10 +4449,9 @@ "integrity": "sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ==" }, "node_modules/@types/http-cache-semantics": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz", - "integrity": "sha512-c3Xy026kOF7QOTn00hbIllV1dLR9hG9NkSrLQgCVs8NF6sBU+VGWjD3wLPhmh1TYAc7ugCFsvHYMN4VcBN1U1A==", - "dev": true + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", + "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" }, "node_modules/@types/http-errors": { "version": "1.8.0", @@ -4524,10 +4518,9 @@ "integrity": "sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==" }, "node_modules/@types/keyv": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.1.tgz", - "integrity": "sha512-MPtoySlAZQ37VoLaPcTHCu1RWJ4llDkULYZIzOYxlhxBqYPB0RsRlmMU0R6tahtFe27mIdkHV+551ZWV4PLmVw==", - "dev": true, + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.3.tgz", + "integrity": "sha512-FXCJgyyN3ivVgRoml4h94G/p3kY+u/B86La+QptcqJaWtBWtmc6TtkNfS40n9bIvyLteHh7zXOtgbobORKPbDg==", "dependencies": { "@types/node": "*" } @@ -4630,6 +4623,14 @@ "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==", "dev": true }, + "node_modules/@types/oidc-provider": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@types/oidc-provider/-/oidc-provider-7.8.1.tgz", + "integrity": "sha512-MsmVKYFN9i27kfJh3hqj7F6aQNue6A/1aBKVJH07I3WYMriUDqMtYU0MWtheFPI1Tm9kwa0JHheaOdNRjuxboA==", + "dependencies": { + "@types/koa": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -4708,7 +4709,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -5367,19 +5367,6 @@ "node": ">= 6.0.0" } }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -5470,11 +5457,6 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=" - }, "node_modules/anymatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", @@ -5946,10 +5928,9 @@ } }, "node_modules/cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true, + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-6.0.4.tgz", + "integrity": "sha512-mbcDEZCkv2CZF4G01kr8eBd/5agkt9oCqz75tJMSIsquvRZ2sL6Hi5zGVKi/0OSC9oO1GHfJ2AV0ZIOY9vye0A==", "engines": { "node": ">=10.6.0" } @@ -5958,6 +5939,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==", + "dev": true, "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", @@ -5975,6 +5957,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, "dependencies": { "pump": "^3.0.0" }, @@ -5989,6 +5972,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, "engines": { "node": ">=8" } @@ -6232,15 +6216,6 @@ "node": ">=0.8.0" } }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/cli-boxes": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", @@ -6746,6 +6721,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", + "dev": true, "dependencies": { "mimic-response": "^1.0.0" }, @@ -6791,7 +6767,8 @@ "node_modules/defer-to-connect": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", - "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==" + "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==", + "dev": true }, "node_modules/define-properties": { "version": "1.1.3", @@ -6836,9 +6813,13 @@ } }, "node_modules/destroy": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", - "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.1.0.tgz", + "integrity": "sha512-R5QZrOXxSs0JDUIU/VANvRJlQVMts9C0L76HToQdPdlftfZCE7W6dyH0G4GZ5UW9fRqUOhAoCE2aGekuu+3HjQ==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } }, "node_modules/detect-libc": { "version": "1.0.3", @@ -7004,7 +6985,8 @@ "node_modules/duplexer3": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", - "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=" + "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", + "dev": true }, "node_modules/ee-first": { "version": "1.1.1", @@ -8327,7 +8309,6 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.0.tgz", "integrity": "sha512-C5owb14u9eJwizKGdchcDUQeFtlSHHthBk8pbX9Vc1PFZrLombudjDnNns88aYslCyF6IY5SUw3Roz6xShcEIQ==", - "dev": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -8341,7 +8322,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "dev": true, "engines": { "node": ">= 10.0.0" } @@ -8508,6 +8488,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, "dependencies": { "pump": "^3.0.0" }, @@ -8550,12 +8531,6 @@ "node": ">=10" } }, - "node_modules/git-raw-commits/node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, "node_modules/git-raw-commits/node_modules/through2": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz", @@ -8908,6 +8883,7 @@ "version": "9.6.0", "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==", + "dev": true, "dependencies": { "@sindresorhus/is": "^0.14.0", "@szmarczak/http-timer": "^1.1.2", @@ -8928,8 +8904,7 @@ "node_modules/graceful-fs": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==" }, "node_modules/graphql": { "version": "15.8.0", @@ -9074,7 +9049,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -9086,7 +9060,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dev": true, "dependencies": { "has-symbols": "^1.0.2" }, @@ -9186,67 +9159,50 @@ } }, "node_modules/http-assert": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.4.1.tgz", - "integrity": "sha512-rdw7q6GTlibqVVbXr0CKelfV5iY8G2HqEUkhSk297BMbSpSL8crXC+9rjKoMcZZEsksX30le6f/4ul4E28gegw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", "dependencies": { "deep-equal": "~1.0.1", - "http-errors": "~1.7.2" + "http-errors": "~1.8.0" }, "engines": { "node": ">= 0.8" } }, - "node_modules/http-assert/node_modules/http-errors": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", - "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.1.1", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/http-assert/node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, "node_modules/http-cache-semantics": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==" }, "node_modules/http-errors": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.0.tgz", - "integrity": "sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", "dependencies": { "depd": "~1.1.2", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" + "toidentifier": "1.0.1" }, "engines": { "node": ">= 0.6" } }, - "node_modules/http-errors/node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, "node_modules/http-errors/node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/http-errors/node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, "node_modules/http-link-header": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http-link-header/-/http-link-header-1.0.3.tgz", @@ -9272,7 +9228,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" @@ -9285,7 +9240,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, "engines": { "node": ">=10" }, @@ -9530,9 +9484,9 @@ } }, "node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "2.0.0", @@ -9692,9 +9646,12 @@ } }, "node_modules/is-generator-function": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.8.tgz", - "integrity": "sha512-2Omr/twNtufVZFr1GhxjOMFPAj2sjc/dKaIqBhvo4qciXfJmITGH6ZGd8eZYNHza8t1y0e01AuqRhJwfWp26WQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -10845,7 +10802,8 @@ "node_modules/json-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", - "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=" + "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=", + "dev": true }, "node_modules/json-parse-better-errors": { "version": "1.0.2", @@ -10896,7 +10854,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, "dependencies": { "universalify": "^2.0.0" }, @@ -10908,7 +10865,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "dev": true, "engines": { "node": ">= 10.0.0" } @@ -11009,6 +10965,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==", + "dev": true, "dependencies": { "json-buffer": "3.0.0" } @@ -11032,16 +10989,16 @@ } }, "node_modules/koa": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.1.tgz", - "integrity": "sha512-Lb2Dloc72auj5vK4X4qqL7B5jyDPQaZucc9sR/71byg7ryoD1NCaCm63CShk9ID9quQvDEi1bGR/iGjCG7As3w==", + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.4.tgz", + "integrity": "sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g==", "dependencies": { "accepts": "^1.3.5", "cache-content-type": "^1.0.0", "content-disposition": "~0.5.2", "content-type": "^1.0.4", "cookies": "~0.8.0", - "debug": "~3.1.0", + "debug": "^4.3.2", "delegates": "^1.0.0", "depd": "^2.0.0", "destroy": "^1.0.4", @@ -11052,7 +11009,7 @@ "http-errors": "^1.6.3", "is-generator-function": "^1.0.7", "koa-compose": "^4.1.0", - "koa-convert": "^1.2.0", + "koa-convert": "^2.0.0", "on-finished": "^2.3.0", "only": "~0.0.2", "parseurl": "^1.3.2", @@ -11070,31 +11027,15 @@ "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==" }, "node_modules/koa-convert": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-1.2.0.tgz", - "integrity": "sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA=", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", + "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", "dependencies": { "co": "^4.6.0", - "koa-compose": "^3.0.0" + "koa-compose": "^4.1.0" }, "engines": { - "node": ">= 4" - } - }, - "node_modules/koa-convert/node_modules/koa-compose": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-3.2.1.tgz", - "integrity": "sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec=", - "dependencies": { - "any-promise": "^1.1.0" - } - }, - "node_modules/koa/node_modules/debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dependencies": { - "ms": "2.0.0" + "node": ">= 10" } }, "node_modules/koa/node_modules/depd": { @@ -11244,11 +11185,6 @@ "lodash._reinterpolate": "^3.0.0" } }, - "node_modules/lodash.uniqwith": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniqwith/-/lodash.uniqwith-4.5.0.tgz", - "integrity": "sha1-egy/ZfQ7WShiWp1NDcVLGMrcfvM=" - }, "node_modules/logform": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/logform/-/logform-2.3.2.tgz", @@ -11301,6 +11237,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -11660,9 +11597,9 @@ } }, "node_modules/nanoid": { - "version": "3.1.22", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.22.tgz", - "integrity": "sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.0.tgz", + "integrity": "sha512-JzxqqT5u/x+/KOFSd7JP15DOo9nOoHpx6DYatqIHUW2+flybkm+mdcraotSQR5WcnZr+qhGVh8Ted0KdfSMxlg==", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -11901,6 +11838,7 @@ "version": "4.5.1", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz", "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==", + "dev": true, "engines": { "node": ">=8" } @@ -11963,9 +11901,9 @@ } }, "node_modules/object-hash": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.1.1.tgz", - "integrity": "sha512-VOJmgmS+7wvXf8CjbQmimtCnEx3IAoLxI3fp2fbWehxrWBcAQFbk+vcwb6vzR0VZv/eNCJ/27j151ZTwqW/JeQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", "engines": { "node": ">= 6" } @@ -12071,44 +12009,141 @@ } }, "node_modules/oidc-provider": { - "version": "6.31.1", - "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-6.31.1.tgz", - "integrity": "sha512-YYfcJKGrobdaW2v7bx5crd4yfxFTKAoOTHdQs/gsu6fwzc/yD/qjforQX2VgbTX+ySK8nm7EaAwJuJJSFRo1MA==", + "version": "7.10.6", + "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-7.10.6.tgz", + "integrity": "sha512-7fbnormUyTLP34dmR5WXoJtTWtfj6MsFNzIMKVRKv21e18NIXggn14EBUFC5rrMMtmeExb03+lJI/v+opD+0oQ==", "dependencies": { "@koa/cors": "^3.1.0", - "@types/koa": "^2.11.4", - "debug": "^4.1.1", - "ejs": "^3.1.5", - "got": "^9.6.0", - "jose": "^2.0.4", - "jsesc": "^3.0.1", - "koa": "^2.13.0", + "cacheable-lookup": "^6.0.1", + "debug": "^4.3.2", + "ejs": "^3.1.6", + "got": "^11.8.2", + "jose": "^4.1.4", + "jsesc": "^3.0.2", + "koa": "^2.13.3", "koa-compose": "^4.1.0", - "lru-cache": "^6.0.0", - "nanoid": "^3.1.10", - "object-hash": "^2.0.3", + "nanoid": "^3.1.28", + "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.1", + "paseto2": "npm:paseto@^2.1.3", + "quick-lru": "^5.1.1", "raw-body": "^2.4.1" }, "engines": { - "node": "^10.13.0 || >=12.0.0" + "node": "^12.19.0 || ^14.15.0 || ^16.13.0" }, "funding": { "url": "https://github.com/sponsors/panva" + }, + "optionalDependencies": { + "paseto3": "npm:paseto@^3.0.0" } }, - "node_modules/oidc-provider/node_modules/jose": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz", - "integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==", - "dependencies": { - "@panva/asn1.js": "^1.0.0" - }, + "node_modules/oidc-provider/node_modules/@sindresorhus/is": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.4.0.tgz", + "integrity": "sha512-QppPM/8l3Mawvh4rn9CNEYIU9bxpXUCRMaX9yUpvBk1nMKusLKpfXGDEKExKaPhLzcn3lzil7pR6rnJ11HgeRQ==", "engines": { - "node": ">=10.13.0 < 13 || >=13.7.0" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/panva" + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/oidc-provider/node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/oidc-provider/node_modules/cacheable-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz", + "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/oidc-provider/node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oidc-provider/node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "engines": { + "node": ">=10" + } + }, + "node_modules/oidc-provider/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oidc-provider/node_modules/got": { + "version": "11.8.3", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.3.tgz", + "integrity": "sha512-7gtQ5KiPh1RtGS9/Jbv1ofDpBFuq42gyfEib+ejaRBJuj/3tQFeR5+gw57e4ipaU8c/rCjvX6fkQz2lyDlGAOg==", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/oidc-provider/node_modules/got/node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "engines": { + "node": ">=10.6.0" } }, "node_modules/oidc-provider/node_modules/jsesc": { @@ -12122,6 +12157,76 @@ "node": ">=6" } }, + "node_modules/oidc-provider/node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, + "node_modules/oidc-provider/node_modules/keyv": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.1.1.tgz", + "integrity": "sha512-tGv1yP6snQVDSM4X6yxrv2zzq/EvpW+oYiUz6aueW1u9CtS8RzUQYxxmFwgZlO2jSgCxQbchhxaqXXp2hnKGpQ==", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/oidc-provider/node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/oidc-provider/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oidc-provider/node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oidc-provider/node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/oidc-provider/node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oidc-provider/node_modules/responselike": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz", + "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==", + "dependencies": { + "lowercase-keys": "^2.0.0" + } + }, "node_modules/oidc-token-hash": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz", @@ -12187,213 +12292,23 @@ } }, "node_modules/openid-client": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-4.7.4.tgz", - "integrity": "sha512-n+RURXYuR0bBZo9i0pn+CXZSyg5JYQ1nbwEwPQvLE7EcJt/vMZ2iIMjLehl5DvCN53XUoPVZs9KAE5r6d9fxsw==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.1.3.tgz", + "integrity": "sha512-i5quCXurPkN50ndRLE2D3Q6khz6AieJ0gTKOmsl3G4ZIP/Udf5Qw5CMRdhMvbFvfKRrkcCWPFXmduFUFYTC0xw==", "dev": true, "dependencies": { - "aggregate-error": "^3.1.0", - "got": "^11.8.0", - "jose": "^2.0.5", + "jose": "^4.1.4", "lru-cache": "^6.0.0", - "make-error": "^1.3.6", "object-hash": "^2.0.1", "oidc-token-hash": "^5.0.1" }, "engines": { - "node": "^10.19.0 || >=12.0.0 < 13 || >=13.7.0 < 14 || >= 14.2.0" + "node": "^12.19.0 || ^14.15.0 || ^16.13.0" }, "funding": { "url": "https://github.com/sponsors/panva" } }, - "node_modules/openid-client/node_modules/@sindresorhus/is": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.1.tgz", - "integrity": "sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/openid-client/node_modules/@szmarczak/http-timer": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.5.tgz", - "integrity": "sha512-PyRA9sm1Yayuj5OIoJ1hGt2YISX45w9WcFbh6ddT0Z/0yaFxOtGLInr4jUfU1EAFVs0Yfyfev4RNwBlUaHdlDQ==", - "dev": true, - "dependencies": { - "defer-to-connect": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/openid-client/node_modules/cacheable-request": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz", - "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==", - "dev": true, - "dependencies": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/openid-client/node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/openid-client/node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/openid-client/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/openid-client/node_modules/got": { - "version": "11.8.2", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.2.tgz", - "integrity": "sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==", - "dev": true, - "dependencies": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.1", - "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=10.19.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, - "node_modules/openid-client/node_modules/jose": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz", - "integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==", - "dev": true, - "dependencies": { - "@panva/asn1.js": "^1.0.0" - }, - "engines": { - "node": ">=10.13.0 < 13 || >=13.7.0" - }, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/openid-client/node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "node_modules/openid-client/node_modules/keyv": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.3.tgz", - "integrity": "sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA==", - "dev": true, - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/openid-client/node_modules/lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/openid-client/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/openid-client/node_modules/normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/openid-client/node_modules/p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/openid-client/node_modules/responselike": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz", - "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==", - "dev": true, - "dependencies": { - "lowercase-keys": "^2.0.0" - } - }, "node_modules/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -12415,6 +12330,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==", + "dev": true, "engines": { "node": ">=6" } @@ -12540,6 +12456,31 @@ "node": ">= 0.8" } }, + "node_modules/paseto2": { + "name": "paseto", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/paseto/-/paseto-2.1.3.tgz", + "integrity": "sha512-BNkbvr0ZFDbh3oV13QzT5jXIu8xpFc9r0o5mvWBhDU1GBkVt1IzHK1N6dcYmN7XImrUmPQ0HCUXmoe2WPo8xsg==", + "engines": { + "node": "^12.19.0 || >=14.15.0" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/paseto3": { + "name": "paseto", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/paseto/-/paseto-3.1.0.tgz", + "integrity": "sha512-oVSKoCH89M0WU3I+13NoCP9wGRel0BlQumwxsDZPk1yJtqS76PWKRM7vM9D4bz4PcScT0aIiAipC7lW6hSgkBQ==", + "optional": true, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -12655,6 +12596,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=", + "dev": true, "engines": { "node": ">=4" } @@ -12856,11 +12798,6 @@ "node": ">= 0.6" } }, - "node_modules/raw-body/node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -13054,13 +12991,13 @@ } }, "node_modules/rdf-terms": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.7.0.tgz", - "integrity": "sha512-K83ACD+MuWFS3mNxwCRNYQAmc/Z9iK7PgqJq9N4VP8sUVlP7ioB2pPNQHKHy0IQh4RTkEq6fg4R4q7YlweLBZQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.7.1.tgz", + "integrity": "sha512-zhYKqTrXTsoybs05Dpu1b+FDnS3+RsU4Fxsqj5aG7frPXDx0MMnIQOKUKpJL7KKYOtq/JE5JsLup6lggnxPqig==", "dependencies": { "@rdfjs/types": "*", - "lodash.uniqwith": "^4.5.0", - "rdf-data-factory": "^1.1.0" + "rdf-data-factory": "^1.1.0", + "rdf-string": "^1.6.0" } }, "node_modules/rdfa-streaming-parser": { @@ -13333,10 +13270,9 @@ } }, "node_modules/resolve-alpn": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.1.2.tgz", - "integrity": "sha512-8OyfzhAtA32LVUsJSke3auIyINcwdh5l3cvYKdKO0nvsYSKuiLfTM5i78PJswFPT8y6cPW+L1v6/hE95chcpDA==", - "dev": true + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" }, "node_modules/resolve-cwd": { "version": "3.0.0", @@ -13381,6 +13317,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=", + "dev": true, "dependencies": { "lowercase-keys": "^1.0.0" } @@ -14263,6 +14200,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==", + "dev": true, "engines": { "node": ">=6" } @@ -14640,6 +14578,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=", + "dev": true, "dependencies": { "prepend-http": "^2.0.0" }, @@ -17996,9 +17935,9 @@ "dev": true }, "@inrupt/solid-client-authn-core": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.11.3.tgz", - "integrity": "sha512-XhxlH+mmCbDQxRQVCQWR5tS/jM0S+lHyFkxDhT9Ts3gAokv4YfwgYsIe2jHgiY8T4Qp3keYFy4RVhZdSRgKGIQ==", + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.11.5.tgz", + "integrity": "sha512-hsGKgv2SsTEo33V5t9crRs+RdKgdtxuIb3VoiGmaDXqLkd9rI/CZZkqnpwUskf4VuBN7Z3h9TKAFohRJMcvF7Q==", "dev": true, "requires": { "@inrupt/solid-common-vocab": "^1.0.0", @@ -18012,24 +17951,24 @@ } }, "@inrupt/solid-client-authn-node": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-node/-/solid-client-authn-node-1.11.3.tgz", - "integrity": "sha512-/4z6TcFOLbFwXPFK9P2TGeCVY19vMTDn1UBRiOJ4PA6arsTefWdtd1JMwSTkdB3BKg8bJJPEaYuA87odtixPCQ==", + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-node/-/solid-client-authn-node-1.11.5.tgz", + "integrity": "sha512-ToPEcnCL5BeQdfeB3MzhR9I+ZXQyuoGnumt60R57QarUi7de/BU2Ag+IAEvcbm+HvEtfF5LaabWbapryTRPGYA==", "dev": true, "requires": { - "@inrupt/solid-client-authn-core": "^1.11.3", - "@types/node": "^16.11.12", + "@inrupt/solid-client-authn-core": "^1.11.5", + "@types/node": "^17.0.2", "@types/uuid": "^8.3.0", "cross-fetch": "^3.0.6", "jose": "^4.3.7", - "openid-client": "^4.2.2", + "openid-client": "^5.1.0", "uuid": "^8.3.2" }, "dependencies": { "@types/node": { - "version": "16.11.22", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.22.tgz", - "integrity": "sha512-DYNtJWauMQ9RNpesl4aVothr97/tIJM8HbyOXJ0AYT1Z2bEjLHyfjOBPAQQVMLf8h3kSShYfNk8Wnto8B2zHUA==", + "version": "17.0.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.18.tgz", + "integrity": "sha512-eKj4f/BsN/qcculZiRSujogjvp5O/k4lOW5m35NopjZM/QwLOR075a8pJW5hD+Rtdm2DaCVPENS6KtSQnUD6BA==", "dev": true } } @@ -18371,11 +18310,6 @@ "fastq": "^1.6.0" } }, - "@panva/asn1.js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", - "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==" - }, "@rdfjs/types": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.0.1.tgz", @@ -18387,7 +18321,8 @@ "@sindresorhus/is": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", - "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==" + "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", + "dev": true }, "@sinonjs/commons": { "version": "1.8.3", @@ -18424,6 +18359,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==", + "dev": true, "requires": { "defer-to-connect": "^1.0.1" } @@ -18524,10 +18460,9 @@ } }, "@types/cacheable-request": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.1.tgz", - "integrity": "sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ==", - "dev": true, + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.2.tgz", + "integrity": "sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA==", "requires": { "@types/http-cache-semantics": "*", "@types/keyv": "*", @@ -18618,7 +18553,6 @@ "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", - "dev": true, "requires": { "@types/node": "*" } @@ -18638,10 +18572,9 @@ "integrity": "sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ==" }, "@types/http-cache-semantics": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz", - "integrity": "sha512-c3Xy026kOF7QOTn00hbIllV1dLR9hG9NkSrLQgCVs8NF6sBU+VGWjD3wLPhmh1TYAc7ugCFsvHYMN4VcBN1U1A==", - "dev": true + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", + "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" }, "@types/http-errors": { "version": "1.8.0", @@ -18708,10 +18641,9 @@ "integrity": "sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==" }, "@types/keyv": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.1.tgz", - "integrity": "sha512-MPtoySlAZQ37VoLaPcTHCu1RWJ4llDkULYZIzOYxlhxBqYPB0RsRlmMU0R6tahtFe27mIdkHV+551ZWV4PLmVw==", - "dev": true, + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.3.tgz", + "integrity": "sha512-FXCJgyyN3ivVgRoml4h94G/p3kY+u/B86La+QptcqJaWtBWtmc6TtkNfS40n9bIvyLteHh7zXOtgbobORKPbDg==", "requires": { "@types/node": "*" } @@ -18814,6 +18746,14 @@ "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==", "dev": true }, + "@types/oidc-provider": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@types/oidc-provider/-/oidc-provider-7.8.1.tgz", + "integrity": "sha512-MsmVKYFN9i27kfJh3hqj7F6aQNue6A/1aBKVJH07I3WYMriUDqMtYU0MWtheFPI1Tm9kwa0JHheaOdNRjuxboA==", + "requires": { + "@types/koa": "*" + } + }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -18891,7 +18831,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==", - "dev": true, "requires": { "@types/node": "*" } @@ -19332,16 +19271,6 @@ "debug": "4" } }, - "aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "requires": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - } - }, "ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -19408,11 +19337,6 @@ } } }, - "any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=" - }, "anymatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", @@ -19784,15 +19708,15 @@ } }, "cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-6.0.4.tgz", + "integrity": "sha512-mbcDEZCkv2CZF4G01kr8eBd/5agkt9oCqz75tJMSIsquvRZ2sL6Hi5zGVKi/0OSC9oO1GHfJ2AV0ZIOY9vye0A==" }, "cacheable-request": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==", + "dev": true, "requires": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", @@ -19807,6 +19731,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, "requires": { "pump": "^3.0.0" } @@ -19814,7 +19739,8 @@ "lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==" + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true } } }, @@ -19997,12 +19923,6 @@ } } }, - "clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true - }, "cli-boxes": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", @@ -20420,6 +20340,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", + "dev": true, "requires": { "mimic-response": "^1.0.0" } @@ -20456,7 +20377,8 @@ "defer-to-connect": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", - "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==" + "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==", + "dev": true }, "define-properties": { "version": "1.1.3", @@ -20489,9 +20411,9 @@ "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" }, "destroy": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", - "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.1.0.tgz", + "integrity": "sha512-R5QZrOXxSs0JDUIU/VANvRJlQVMts9C0L76HToQdPdlftfZCE7W6dyH0G4GZ5UW9fRqUOhAoCE2aGekuu+3HjQ==" }, "detect-libc": { "version": "1.0.3", @@ -20610,7 +20532,8 @@ "duplexer3": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", - "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=" + "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", + "dev": true }, "ee-first": { "version": "1.1.1", @@ -21612,7 +21535,6 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.0.tgz", "integrity": "sha512-C5owb14u9eJwizKGdchcDUQeFtlSHHthBk8pbX9Vc1PFZrLombudjDnNns88aYslCyF6IY5SUw3Roz6xShcEIQ==", - "dev": true, "requires": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -21622,8 +21544,7 @@ "universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "dev": true + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==" } } }, @@ -21751,6 +21672,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, "requires": { "pump": "^3.0.0" } @@ -21778,12 +21700,6 @@ "through2": "^3.0.0" }, "dependencies": { - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, "through2": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz", @@ -22043,6 +21959,7 @@ "version": "9.6.0", "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==", + "dev": true, "requires": { "@sindresorhus/is": "^0.14.0", "@szmarczak/http-timer": "^1.1.2", @@ -22060,8 +21977,7 @@ "graceful-fs": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==" }, "graphql": { "version": "15.8.0", @@ -22176,14 +22092,12 @@ "has-symbols": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", - "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", - "dev": true + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==" }, "has-tostringtag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dev": true, "requires": { "has-symbols": "^1.0.2" } @@ -22257,31 +22171,12 @@ } }, "http-assert": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.4.1.tgz", - "integrity": "sha512-rdw7q6GTlibqVVbXr0CKelfV5iY8G2HqEUkhSk297BMbSpSL8crXC+9rjKoMcZZEsksX30le6f/4ul4E28gegw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", "requires": { "deep-equal": "~1.0.1", - "http-errors": "~1.7.2" - }, - "dependencies": { - "http-errors": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", - "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", - "requires": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.1.1", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - } + "http-errors": "~1.8.0" } }, "http-cache-semantics": { @@ -22290,26 +22185,26 @@ "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==" }, "http-errors": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.0.tgz", - "integrity": "sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", "requires": { "depd": "~1.1.2", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" + "toidentifier": "1.0.1" }, "dependencies": { - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, "setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" } } }, @@ -22332,7 +22227,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, "requires": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" @@ -22341,8 +22235,7 @@ "quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==" } } }, @@ -22507,9 +22400,9 @@ } }, "inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "ini": { "version": "2.0.0", @@ -22623,9 +22516,12 @@ "dev": true }, "is-generator-function": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.8.tgz", - "integrity": "sha512-2Omr/twNtufVZFr1GhxjOMFPAj2sjc/dKaIqBhvo4qciXfJmITGH6ZGd8eZYNHza8t1y0e01AuqRhJwfWp26WQ==" + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "requires": { + "has-tostringtag": "^1.0.0" + } }, "is-glob": { "version": "4.0.3", @@ -23493,7 +23389,8 @@ "json-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", - "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=" + "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=", + "dev": true }, "json-parse-better-errors": { "version": "1.0.2", @@ -23538,7 +23435,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, "requires": { "graceful-fs": "^4.1.6", "universalify": "^2.0.0" @@ -23547,8 +23443,7 @@ "universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "dev": true + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==" } } }, @@ -23632,6 +23527,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==", + "dev": true, "requires": { "json-buffer": "3.0.0" } @@ -23649,16 +23545,16 @@ "dev": true }, "koa": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.1.tgz", - "integrity": "sha512-Lb2Dloc72auj5vK4X4qqL7B5jyDPQaZucc9sR/71byg7ryoD1NCaCm63CShk9ID9quQvDEi1bGR/iGjCG7As3w==", + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.4.tgz", + "integrity": "sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g==", "requires": { "accepts": "^1.3.5", "cache-content-type": "^1.0.0", "content-disposition": "~0.5.2", "content-type": "^1.0.4", "cookies": "~0.8.0", - "debug": "~3.1.0", + "debug": "^4.3.2", "delegates": "^1.0.0", "depd": "^2.0.0", "destroy": "^1.0.4", @@ -23669,7 +23565,7 @@ "http-errors": "^1.6.3", "is-generator-function": "^1.0.7", "koa-compose": "^4.1.0", - "koa-convert": "^1.2.0", + "koa-convert": "^2.0.0", "on-finished": "^2.3.0", "only": "~0.0.2", "parseurl": "^1.3.2", @@ -23678,14 +23574,6 @@ "vary": "^1.1.2" }, "dependencies": { - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "requires": { - "ms": "2.0.0" - } - }, "depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -23699,22 +23587,12 @@ "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==" }, "koa-convert": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-1.2.0.tgz", - "integrity": "sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA=", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", + "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", "requires": { "co": "^4.6.0", - "koa-compose": "^3.0.0" - }, - "dependencies": { - "koa-compose": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-3.2.1.tgz", - "integrity": "sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec=", - "requires": { - "any-promise": "^1.1.0" - } - } + "koa-compose": "^4.1.0" } }, "kuler": { @@ -23840,11 +23718,6 @@ "lodash._reinterpolate": "^3.0.0" } }, - "lodash.uniqwith": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniqwith/-/lodash.uniqwith-4.5.0.tgz", - "integrity": "sha1-egy/ZfQ7WShiWp1NDcVLGMrcfvM=" - }, "logform": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/logform/-/logform-2.3.2.tgz", @@ -23892,7 +23765,8 @@ "lowercase-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", - "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==" + "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", + "dev": true }, "lru-cache": { "version": "6.0.0", @@ -24160,9 +24034,9 @@ } }, "nanoid": { - "version": "3.1.22", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.22.tgz", - "integrity": "sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ==" + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.0.tgz", + "integrity": "sha512-JzxqqT5u/x+/KOFSd7JP15DOo9nOoHpx6DYatqIHUW2+flybkm+mdcraotSQR5WcnZr+qhGVh8Ted0KdfSMxlg==" }, "natural-compare": { "version": "1.4.0", @@ -24348,7 +24222,8 @@ "normalize-url": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz", - "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==" + "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==", + "dev": true }, "npm-run-path": { "version": "4.0.1", @@ -24396,9 +24271,9 @@ "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-hash": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.1.1.tgz", - "integrity": "sha512-VOJmgmS+7wvXf8CjbQmimtCnEx3IAoLxI3fp2fbWehxrWBcAQFbk+vcwb6vzR0VZv/eNCJ/27j151ZTwqW/JeQ==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==" }, "object-inspect": { "version": "1.11.1", @@ -24471,38 +24346,151 @@ } }, "oidc-provider": { - "version": "6.31.1", - "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-6.31.1.tgz", - "integrity": "sha512-YYfcJKGrobdaW2v7bx5crd4yfxFTKAoOTHdQs/gsu6fwzc/yD/qjforQX2VgbTX+ySK8nm7EaAwJuJJSFRo1MA==", + "version": "7.10.6", + "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-7.10.6.tgz", + "integrity": "sha512-7fbnormUyTLP34dmR5WXoJtTWtfj6MsFNzIMKVRKv21e18NIXggn14EBUFC5rrMMtmeExb03+lJI/v+opD+0oQ==", "requires": { "@koa/cors": "^3.1.0", - "@types/koa": "^2.11.4", - "debug": "^4.1.1", - "ejs": "^3.1.5", - "got": "^9.6.0", - "jose": "^2.0.4", - "jsesc": "^3.0.1", - "koa": "^2.13.0", + "cacheable-lookup": "^6.0.1", + "debug": "^4.3.2", + "ejs": "^3.1.6", + "got": "^11.8.2", + "jose": "^4.1.4", + "jsesc": "^3.0.2", + "koa": "^2.13.3", "koa-compose": "^4.1.0", - "lru-cache": "^6.0.0", - "nanoid": "^3.1.10", - "object-hash": "^2.0.3", + "nanoid": "^3.1.28", + "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.1", + "paseto2": "npm:paseto@^2.1.3", + "paseto3": "npm:paseto@^3.0.0", + "quick-lru": "^5.1.1", "raw-body": "^2.4.1" }, "dependencies": { - "jose": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz", - "integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==", + "@sindresorhus/is": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.4.0.tgz", + "integrity": "sha512-QppPM/8l3Mawvh4rn9CNEYIU9bxpXUCRMaX9yUpvBk1nMKusLKpfXGDEKExKaPhLzcn3lzil7pR6rnJ11HgeRQ==" + }, + "@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", "requires": { - "@panva/asn1.js": "^1.0.0" + "defer-to-connect": "^2.0.0" + } + }, + "cacheable-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz", + "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==", + "requires": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + } + }, + "decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "requires": { + "mimic-response": "^3.1.0" + } + }, + "defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==" + }, + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "requires": { + "pump": "^3.0.0" + } + }, + "got": { + "version": "11.8.3", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.3.tgz", + "integrity": "sha512-7gtQ5KiPh1RtGS9/Jbv1ofDpBFuq42gyfEib+ejaRBJuj/3tQFeR5+gw57e4ipaU8c/rCjvX6fkQz2lyDlGAOg==", + "requires": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "dependencies": { + "cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==" + } } }, "jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==" + }, + "json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, + "keyv": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.1.1.tgz", + "integrity": "sha512-tGv1yP6snQVDSM4X6yxrv2zzq/EvpW+oYiUz6aueW1u9CtS8RzUQYxxmFwgZlO2jSgCxQbchhxaqXXp2hnKGpQ==", + "requires": { + "json-buffer": "3.0.1" + } + }, + "lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==" + }, + "mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" + }, + "normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==" + }, + "p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==" + }, + "quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==" + }, + "responselike": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz", + "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==", + "requires": { + "lowercase-keys": "^2.0.0" + } } } }, @@ -24556,150 +24544,15 @@ "dev": true }, "openid-client": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-4.7.4.tgz", - "integrity": "sha512-n+RURXYuR0bBZo9i0pn+CXZSyg5JYQ1nbwEwPQvLE7EcJt/vMZ2iIMjLehl5DvCN53XUoPVZs9KAE5r6d9fxsw==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.1.3.tgz", + "integrity": "sha512-i5quCXurPkN50ndRLE2D3Q6khz6AieJ0gTKOmsl3G4ZIP/Udf5Qw5CMRdhMvbFvfKRrkcCWPFXmduFUFYTC0xw==", "dev": true, "requires": { - "aggregate-error": "^3.1.0", - "got": "^11.8.0", - "jose": "^2.0.5", + "jose": "^4.1.4", "lru-cache": "^6.0.0", - "make-error": "^1.3.6", "object-hash": "^2.0.1", "oidc-token-hash": "^5.0.1" - }, - "dependencies": { - "@sindresorhus/is": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.1.tgz", - "integrity": "sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g==", - "dev": true - }, - "@szmarczak/http-timer": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.5.tgz", - "integrity": "sha512-PyRA9sm1Yayuj5OIoJ1hGt2YISX45w9WcFbh6ddT0Z/0yaFxOtGLInr4jUfU1EAFVs0Yfyfev4RNwBlUaHdlDQ==", - "dev": true, - "requires": { - "defer-to-connect": "^2.0.0" - } - }, - "cacheable-request": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz", - "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==", - "dev": true, - "requires": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" - } - }, - "decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "requires": { - "mimic-response": "^3.1.0" - } - }, - "defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true - }, - "get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "requires": { - "pump": "^3.0.0" - } - }, - "got": { - "version": "11.8.2", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.2.tgz", - "integrity": "sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==", - "dev": true, - "requires": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.1", - "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" - } - }, - "jose": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz", - "integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==", - "dev": true, - "requires": { - "@panva/asn1.js": "^1.0.0" - } - }, - "json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "keyv": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.3.tgz", - "integrity": "sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA==", - "dev": true, - "requires": { - "json-buffer": "3.0.1" - } - }, - "lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true - }, - "mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true - }, - "normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true - }, - "p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true - }, - "responselike": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz", - "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==", - "dev": true, - "requires": { - "lowercase-keys": "^2.0.0" - } - } } }, "optionator": { @@ -24719,7 +24572,8 @@ "p-cancelable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", - "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==" + "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==", + "dev": true }, "p-limit": { "version": "2.3.0", @@ -24814,6 +24668,17 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, + "paseto2": { + "version": "npm:paseto@2.1.3", + "resolved": "https://registry.npmjs.org/paseto/-/paseto-2.1.3.tgz", + "integrity": "sha512-BNkbvr0ZFDbh3oV13QzT5jXIu8xpFc9r0o5mvWBhDU1GBkVt1IzHK1N6dcYmN7XImrUmPQ0HCUXmoe2WPo8xsg==" + }, + "paseto3": { + "version": "npm:paseto@3.1.0", + "resolved": "https://registry.npmjs.org/paseto/-/paseto-3.1.0.tgz", + "integrity": "sha512-oVSKoCH89M0WU3I+13NoCP9wGRel0BlQumwxsDZPk1yJtqS76PWKRM7vM9D4bz4PcScT0aIiAipC7lW6hSgkBQ==", + "optional": true + }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -24895,7 +24760,8 @@ "prepend-http": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", - "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=" + "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=", + "dev": true }, "pretty-format": { "version": "27.4.6", @@ -25047,11 +24913,6 @@ "statuses": ">= 1.5.0 < 2", "toidentifier": "1.0.0" } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" } } }, @@ -25241,13 +25102,13 @@ } }, "rdf-terms": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.7.0.tgz", - "integrity": "sha512-K83ACD+MuWFS3mNxwCRNYQAmc/Z9iK7PgqJq9N4VP8sUVlP7ioB2pPNQHKHy0IQh4RTkEq6fg4R4q7YlweLBZQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.7.1.tgz", + "integrity": "sha512-zhYKqTrXTsoybs05Dpu1b+FDnS3+RsU4Fxsqj5aG7frPXDx0MMnIQOKUKpJL7KKYOtq/JE5JsLup6lggnxPqig==", "requires": { "@rdfjs/types": "*", - "lodash.uniqwith": "^4.5.0", - "rdf-data-factory": "^1.1.0" + "rdf-data-factory": "^1.1.0", + "rdf-string": "^1.6.0" } }, "rdfa-streaming-parser": { @@ -25451,10 +25312,9 @@ } }, "resolve-alpn": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.1.2.tgz", - "integrity": "sha512-8OyfzhAtA32LVUsJSke3auIyINcwdh5l3cvYKdKO0nvsYSKuiLfTM5i78PJswFPT8y6cPW+L1v6/hE95chcpDA==", - "dev": true + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" }, "resolve-cwd": { "version": "3.0.0", @@ -25489,6 +25349,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=", + "dev": true, "requires": { "lowercase-keys": "^1.0.0" } @@ -26206,7 +26067,8 @@ "to-readable-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", - "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==" + "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==", + "dev": true }, "to-regex-range": { "version": "5.0.1", @@ -26473,6 +26335,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=", + "dev": true, "requires": { "prepend-http": "^2.0.0" } diff --git a/package.json b/package.json index c7ded3732..8936e891e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@solid/community-server", - "version": "2.0.1", + "version": "3.0.0", "description": "Community Solid Server: an open and modular implementation of the Solid specifications", "keywords": [ "solid", @@ -20,13 +20,13 @@ "lsd:module": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server", "lsd:components": "dist/components/components.jsonld", "lsd:contexts": { - "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld": "dist/components/context.jsonld" + "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld": "dist/components/context.jsonld" }, "lsd:importPaths": { - "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/": "dist/components/", - "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/config/": "config/", - "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/dist/": "dist/", - "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/templates/config/": "templates/config/" + "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/": "dist/components/", + "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/config/": "config/", + "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/dist/": "dist/", + "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/templates/config/": "templates/config/" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -82,12 +82,14 @@ "@types/bcrypt": "^5.0.0", "@types/cors": "^2.8.12", "@types/end-of-stream": "^1.4.1", + "@types/fs-extra": "^9.0.13", "@types/lodash.orderby": "^4.6.6", "@types/marked": "^4.0.2", "@types/mime-types": "^2.1.1", "@types/n3": "^1.10.4", "@types/node": "^14.18.0", "@types/nodemailer": "^6.4.4", + "@types/oidc-provider": "^7.8.1", "@types/pump": "^1.1.1", "@types/punycode": "^2.1.0", "@types/redis": "^2.8.30", @@ -107,6 +109,7 @@ "end-of-stream": "^1.4.4", "escape-string-regexp": "^4.0.0", "fetch-sparql-endpoint": "^2.4.0", + "fs-extra": "^10.0.0", "handlebars": "^4.7.7", "jose": "^4.4.0", "lodash.orderby": "^4.6.0", @@ -114,11 +117,13 @@ "mime-types": "^2.1.34", "n3": "^1.13.0", "nodemailer": "^6.7.2", - "oidc-provider": "^6.31.1", + "oidc-provider": "^7.10.6", "pump": "^3.0.0", "punycode": "^2.1.1", + "rdf-dereference": "^1.9.0", "rdf-parse": "^1.9.1", "rdf-serialize": "^1.2.0", + "rdf-terms": "^1.7.1", "redis": "^3.1.2", "redlock": "^4.2.0", "sparqlalgebrajs": "^4.0.2", @@ -131,12 +136,11 @@ "yargs": "^17.3.1" }, "devDependencies": { - "@inrupt/solid-client-authn-node": "^1.11.3", + "@inrupt/solid-client-authn-node": "^1.11.5", "@microsoft/tsdoc-config": "^0.15.2", "@tsconfig/node12": "^1.0.9", "@types/cheerio": "^0.22.30", "@types/ejs": "^3.1.0", - "@types/fs-extra": "^9.0.13", "@types/jest": "^27.4.0", "@types/set-cookie-parser": "^2.4.2", "@types/supertest": "^2.0.11", @@ -151,7 +155,6 @@ "eslint-plugin-jest": "^26.0.0", "eslint-plugin-tsdoc": "^0.2.14", "eslint-plugin-unused-imports": "^2.0.0", - "fs-extra": "^10.0.0", "husky": "^4.3.8", "jest": "^27.4.7", "jest-rdf": "^1.7.0", diff --git a/src/authorization/access/AgentGroupAccessChecker.ts b/src/authorization/access/AgentGroupAccessChecker.ts index b56cc537d..64b458efc 100644 --- a/src/authorization/access/AgentGroupAccessChecker.ts +++ b/src/authorization/access/AgentGroupAccessChecker.ts @@ -1,6 +1,5 @@ import type { Store, Term } from 'n3'; import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; -import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter'; import type { ExpiringStorage } from '../../storage/keyvalue/ExpiringStorage'; import { fetchDataset } from '../../util/FetchUtil'; import { promiseSome } from '../../util/PromiseUtil'; @@ -19,14 +18,11 @@ import { AccessChecker } from './AccessChecker'; * `expiration` parameter is how long entries in the cache should be stored in seconds, defaults to 3600. */ export class AgentGroupAccessChecker extends AccessChecker { - private readonly converter: RepresentationConverter; private readonly cache: ExpiringStorage>; private readonly expiration: number; - public constructor(converter: RepresentationConverter, cache: ExpiringStorage>, - expiration = 3600) { + public constructor(cache: ExpiringStorage>, expiration = 3600) { super(); - this.converter = converter; this.cache = cache; this.expiration = expiration * 1000; } @@ -65,7 +61,7 @@ export class AgentGroupAccessChecker extends AccessChecker { let result = await this.cache.get(url); if (!result) { const prom = (async(): Promise => { - const representation = await fetchDataset(url, this.converter); + const representation = await fetchDataset(url); return readableToQuads(representation.data); })(); await this.cache.set(url, prom, this.expiration); diff --git a/src/authorization/permissions/ModesExtractor.ts b/src/authorization/permissions/ModesExtractor.ts index a62691834..1ded7055f 100644 --- a/src/authorization/permissions/ModesExtractor.ts +++ b/src/authorization/permissions/ModesExtractor.ts @@ -2,4 +2,7 @@ import type { Operation } from '../../http/Operation'; import { AsyncHandler } from '../../util/handlers/AsyncHandler'; import type { AccessMode } from './Permissions'; +/** + * Extracts all {@link AccessMode}s that are necessary to execute the given {@link Operation}. + */ export abstract class ModesExtractor extends AsyncHandler> {} diff --git a/src/authorization/permissions/N3PatchModesExtractor.ts b/src/authorization/permissions/N3PatchModesExtractor.ts new file mode 100644 index 000000000..bc4d8a2cc --- /dev/null +++ b/src/authorization/permissions/N3PatchModesExtractor.ts @@ -0,0 +1,44 @@ +import type { Operation } from '../../http/Operation'; +import type { N3Patch } from '../../http/representation/N3Patch'; +import { isN3Patch } from '../../http/representation/N3Patch'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { ModesExtractor } from './ModesExtractor'; +import { AccessMode } from './Permissions'; + +/** + * Extracts the required access modes from an N3 Patch. + * + * Solid, §5.3.1: "When ?conditions is non-empty, servers MUST treat the request as a Read operation. + * When ?insertions is non-empty, servers MUST (also) treat the request as an Append operation. + * When ?deletions is non-empty, servers MUST treat the request as a Read and Write operation." + * https://solid.github.io/specification/protocol#n3-patch + */ +export class N3PatchModesExtractor extends ModesExtractor { + public async canHandle({ body }: Operation): Promise { + if (!isN3Patch(body)) { + throw new NotImplementedHttpError('Can only determine permissions of N3 Patch documents.'); + } + } + + public async handle({ body }: Operation): Promise> { + const { deletes, inserts, conditions } = body as N3Patch; + + const accessModes = new Set(); + + // When ?conditions is non-empty, servers MUST treat the request as a Read operation. + if (conditions.length > 0) { + accessModes.add(AccessMode.read); + } + // When ?insertions is non-empty, servers MUST (also) treat the request as an Append operation. + if (inserts.length > 0) { + accessModes.add(AccessMode.append); + } + // When ?deletions is non-empty, servers MUST treat the request as a Read and Write operation. + if (deletes.length > 0) { + accessModes.add(AccessMode.read); + accessModes.add(AccessMode.write); + } + + return accessModes; + } +} diff --git a/src/authorization/permissions/SparqlPatchModesExtractor.ts b/src/authorization/permissions/SparqlUpdateModesExtractor.ts similarity index 89% rename from src/authorization/permissions/SparqlPatchModesExtractor.ts rename to src/authorization/permissions/SparqlUpdateModesExtractor.ts index cae2798d6..1cdf3f223 100644 --- a/src/authorization/permissions/SparqlPatchModesExtractor.ts +++ b/src/authorization/permissions/SparqlUpdateModesExtractor.ts @@ -6,11 +6,13 @@ import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpErr import { ModesExtractor } from './ModesExtractor'; import { AccessMode } from './Permissions'; -export class SparqlPatchModesExtractor extends ModesExtractor { - public async canHandle({ method, body }: Operation): Promise { - if (method !== 'PATCH') { - throw new NotImplementedHttpError(`Cannot determine permissions of ${method}, only PATCH.`); - } +/** + * Generates permissions for a SPARQL DELETE/INSERT body. + * Updates with only an INSERT can be done with just append permissions, + * while DELETEs require write permissions as well. + */ +export class SparqlUpdateModesExtractor extends ModesExtractor { + public async canHandle({ body }: Operation): Promise { if (!this.isSparql(body)) { throw new NotImplementedHttpError('Cannot determine permissions of non-SPARQL patches.'); } diff --git a/src/http/auxiliary/ComposedAuxiliaryStrategy.ts b/src/http/auxiliary/ComposedAuxiliaryStrategy.ts index 25f576b06..8fe47ab3e 100644 --- a/src/http/auxiliary/ComposedAuxiliaryStrategy.ts +++ b/src/http/auxiliary/ComposedAuxiliaryStrategy.ts @@ -58,7 +58,10 @@ export class ComposedAuxiliaryStrategy implements AuxiliaryStrategy { public async validate(representation: Representation): Promise { if (this.validator) { - return this.validator.handleSafe(representation); + await this.validator.handleSafe({ + representation, + identifier: { path: representation.metadata.identifier.value }, + }); } } } diff --git a/src/http/auxiliary/RdfValidator.ts b/src/http/auxiliary/RdfValidator.ts index a9fc56eea..e2a4a72cb 100644 --- a/src/http/auxiliary/RdfValidator.ts +++ b/src/http/auxiliary/RdfValidator.ts @@ -3,6 +3,7 @@ import type { RepresentationConverter } from '../../storage/conversion/Represent import { INTERNAL_QUADS } from '../../util/ContentTypes'; import { cloneRepresentation } from '../../util/ResourceUtil'; import type { Representation } from '../representation/Representation'; +import type { ValidatorInput } from './Validator'; import { Validator } from './Validator'; /** @@ -17,12 +18,11 @@ export class RdfValidator extends Validator { this.converter = converter; } - public async handle(representation: Representation): Promise { + public async handle({ representation, identifier }: ValidatorInput): Promise { // If the data already is quads format we know it's RDF if (representation.metadata.contentType === INTERNAL_QUADS) { - return; + return representation; } - const identifier = { path: representation.metadata.identifier.value }; const preferences = { type: { [INTERNAL_QUADS]: 1 }}; let result; try { @@ -39,5 +39,7 @@ export class RdfValidator extends Validator { } // Drain stream to make sure data was parsed correctly await arrayifyStream(result.data); + + return representation; } } diff --git a/src/http/auxiliary/Validator.ts b/src/http/auxiliary/Validator.ts index 38a83f3d8..974cb4555 100644 --- a/src/http/auxiliary/Validator.ts +++ b/src/http/auxiliary/Validator.ts @@ -1,7 +1,13 @@ import { AsyncHandler } from '../../util/handlers/AsyncHandler'; import type { Representation } from '../representation/Representation'; +import type { ResourceIdentifier } from '../representation/ResourceIdentifier'; + +export type ValidatorInput = { + representation: Representation; + identifier: ResourceIdentifier; +}; /** * Generic interface for classes that validate Representations in some way. */ -export abstract class Validator extends AsyncHandler { } +export abstract class Validator extends AsyncHandler { } diff --git a/src/http/input/body/N3PatchBodyParser.ts b/src/http/input/body/N3PatchBodyParser.ts new file mode 100644 index 000000000..d6a1ccfd0 --- /dev/null +++ b/src/http/input/body/N3PatchBodyParser.ts @@ -0,0 +1,138 @@ +import type { NamedNode, Quad, Quad_Subject, Variable } from '@rdfjs/types'; +import { DataFactory, Parser, Store } from 'n3'; +import { getBlankNodes, getTerms, getVariables } from 'rdf-terms'; +import { TEXT_N3 } from '../../../util/ContentTypes'; +import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; +import { createErrorMessage } from '../../../util/errors/ErrorUtil'; +import { UnprocessableEntityHttpError } from '../../../util/errors/UnprocessableEntityHttpError'; +import { UnsupportedMediaTypeHttpError } from '../../../util/errors/UnsupportedMediaTypeHttpError'; +import { guardedStreamFrom, readableToString } from '../../../util/StreamUtil'; +import { RDF, SOLID } from '../../../util/Vocabularies'; +import type { N3Patch } from '../../representation/N3Patch'; +import type { BodyParserArgs } from './BodyParser'; +import { BodyParser } from './BodyParser'; + +const defaultGraph = DataFactory.defaultGraph(); + +/** + * Parses an N3 Patch document and makes sure it conforms to the specification requirements. + * Requirements can be found at Solid Protocol, §5.3.1: https://solid.github.io/specification/protocol#n3-patch + */ +export class N3PatchBodyParser extends BodyParser { + public async canHandle({ metadata }: BodyParserArgs): Promise { + if (metadata.contentType !== TEXT_N3) { + throw new UnsupportedMediaTypeHttpError('This parser only supports N3 Patch documents.'); + } + } + + public async handle({ request, metadata }: BodyParserArgs): Promise { + const n3 = await readableToString(request); + const parser = new Parser({ format: TEXT_N3, baseIRI: metadata.identifier.value }); + let store: Store; + try { + store = new Store(parser.parse(n3)); + } catch (error: unknown) { + throw new BadRequestHttpError(`Invalid N3: ${createErrorMessage(error)}`); + } + + // Solid, §5.3.1: "A patch resource MUST contain a triple ?patch rdf:type solid:InsertDeletePatch." + // "The patch document MUST contain exactly one patch resource, + // identified by one or more of the triple patterns described above, which all share the same ?patch subject." + const patches = store.getSubjects(RDF.terms.type, SOLID.terms.InsertDeletePatch, defaultGraph); + if (patches.length !== 1) { + throw new UnprocessableEntityHttpError( + `This patcher only supports N3 Patch documents with exactly 1 solid:InsertDeletePatch entry, but received ${ + patches.length}.`, + ); + } + return { + ...this.parsePatch(patches[0], store), + binary: true, + data: guardedStreamFrom(n3), + metadata, + isEmpty: false, + }; + } + + /** + * Extracts the deletes/inserts/conditions from a solid:InsertDeletePatch entry. + */ + private parsePatch(patch: Quad_Subject, store: Store): { deletes: Quad[]; inserts: Quad[]; conditions: Quad[] } { + // Solid, §5.3.1: "A patch resource MUST be identified by a URI or blank node, which we refer to as ?patch + // in the remainder of this section." + if (patch.termType !== 'NamedNode' && patch.termType !== 'BlankNode') { + throw new UnprocessableEntityHttpError('An N3 Patch subject needs to be a blank or named node.'); + } + + // Extract all quads from the corresponding formulae + const deletes = this.findQuads(store, patch, SOLID.terms.deletes); + const inserts = this.findQuads(store, patch, SOLID.terms.inserts); + const conditions = this.findQuads(store, patch, SOLID.terms.where); + + // Make sure there are no forbidden combinations + const conditionVars = this.findVariables(conditions); + this.verifyQuads(deletes, conditionVars); + this.verifyQuads(inserts, conditionVars); + + return { deletes, inserts, conditions }; + } + + /** + * Finds all quads in a where/deletes/inserts formula. + * The returned quads will be updated so their graph is the default graph instead of the N3 reference to the formula. + * Will error in case there are multiple instances of the subject/predicate combination. + */ + private findQuads(store: Store, subject: Quad_Subject, predicate: NamedNode): Quad[] { + const graphs = store.getObjects(subject, predicate, defaultGraph); + if (graphs.length > 1) { + throw new UnprocessableEntityHttpError(`An N3 Patch can have at most 1 ${predicate.value}.`); + } + if (graphs.length === 0) { + return []; + } + // This might not return all quads in case of nested formulae, + // but these are not allowed and will throw an error later when checking for blank nodes. + // Another check would be needed in case blank nodes are allowed in the future. + const quads: Quad[] = store.getQuads(null, null, null, graphs[0]); + + // Remove the graph references so they can be interpreted as standard triples + // independent of the formula they were in. + return quads.map((quad): Quad => DataFactory.quad(quad.subject, quad.predicate, quad.object, defaultGraph)); + } + + /** + * Finds all variables in a set of quads. + */ + private findVariables(quads: Quad[]): Set { + return new Set( + quads.flatMap((quad): Variable[] => getVariables(getTerms(quad))) + .map((variable): string => variable.value), + ); + } + + /** + * Verifies if the delete/insert triples conform to the specification requirements: + * - They should not contain blank nodes. + * - They should not contain variables that do not occur in the conditions. + */ + private verifyQuads(otherQuads: Quad[], conditionVars: Set): void { + for (const quad of otherQuads) { + const terms = getTerms(quad); + const blankNodes = getBlankNodes(terms); + // Solid, §5.3.1: "The ?insertions and ?deletions formulae MUST NOT contain blank nodes." + if (blankNodes.length > 0) { + throw new UnprocessableEntityHttpError(`An N3 Patch delete/insert formula can not contain blank nodes.`); + } + const variables = getVariables(terms); + for (const variable of variables) { + // Solid, §5.3.1: "The ?insertions and ?deletions formulae + // MUST NOT contain variables that do not occur in the ?conditions formula." + if (!conditionVars.has(variable.value)) { + throw new UnprocessableEntityHttpError( + `An N3 Patch delete/insert formula can only contain variables found in the conditions formula.`, + ); + } + } + } + } +} diff --git a/src/http/input/metadata/ContentLengthParser.ts b/src/http/input/metadata/ContentLengthParser.ts new file mode 100644 index 000000000..a0cf84954 --- /dev/null +++ b/src/http/input/metadata/ContentLengthParser.ts @@ -0,0 +1,23 @@ +import { getLoggerFor } from '../../../logging/LogUtil'; +import type { HttpRequest } from '../../../server/HttpRequest'; +import type { RepresentationMetadata } from '../../representation/RepresentationMetadata'; +import { MetadataParser } from './MetadataParser'; + +/** + * Parser for the `content-length` header. + */ +export class ContentLengthParser extends MetadataParser { + protected readonly logger = getLoggerFor(this); + + public async handle(input: { request: HttpRequest; metadata: RepresentationMetadata }): Promise { + const contentLength = input.request.headers['content-length']; + if (contentLength) { + const length = /^\s*(\d+)\s*(?:;.*)?$/u.exec(contentLength)?.[1]; + if (length) { + input.metadata.contentLength = Number(length); + } else { + this.logger.warn(`Invalid content-length header found: ${contentLength}.`); + } + } + } +} diff --git a/src/http/output/error/RedirectingErrorHandler.ts b/src/http/output/error/RedirectingErrorHandler.ts new file mode 100644 index 000000000..8e72d34e0 --- /dev/null +++ b/src/http/output/error/RedirectingErrorHandler.ts @@ -0,0 +1,23 @@ +import { NotImplementedHttpError } from '../../../util/errors/NotImplementedHttpError'; +import { RedirectHttpError } from '../../../util/errors/RedirectHttpError'; +import { RedirectResponseDescription } from '../response/RedirectResponseDescription'; +import type { ResponseDescription } from '../response/ResponseDescription'; +import type { ErrorHandlerArgs } from './ErrorHandler'; +import { ErrorHandler } from './ErrorHandler'; + +/** + * Internally we create redirects by throwing specific {@link RedirectHttpError}s. + * This Error handler converts those to {@link RedirectResponseDescription}s that are used for output. + */ +export class RedirectingErrorHandler extends ErrorHandler { + public async canHandle({ error }: ErrorHandlerArgs): Promise { + if (!RedirectHttpError.isInstance(error)) { + throw new NotImplementedHttpError('Only redirect errors are supported.'); + } + } + + public async handle({ error }: ErrorHandlerArgs): Promise { + // Cast verified by canHandle + return new RedirectResponseDescription(error as RedirectHttpError); + } +} diff --git a/src/http/output/response/RedirectResponseDescription.ts b/src/http/output/response/RedirectResponseDescription.ts index c6a588aba..293fd3596 100644 --- a/src/http/output/response/RedirectResponseDescription.ts +++ b/src/http/output/response/RedirectResponseDescription.ts @@ -1,14 +1,15 @@ import { DataFactory } from 'n3'; +import type { RedirectHttpError } from '../../../util/errors/RedirectHttpError'; import { SOLID_HTTP } from '../../../util/Vocabularies'; import { RepresentationMetadata } from '../../representation/RepresentationMetadata'; import { ResponseDescription } from './ResponseDescription'; /** - * Corresponds to a 301/302 response, containing the relevant location metadata. + * Corresponds to a redirect response, containing the relevant location metadata. */ export class RedirectResponseDescription extends ResponseDescription { - public constructor(location: string, permanently = false) { - const metadata = new RepresentationMetadata({ [SOLID_HTTP.location]: DataFactory.namedNode(location) }); - super(permanently ? 301 : 302, metadata); + public constructor(error: RedirectHttpError) { + const metadata = new RepresentationMetadata({ [SOLID_HTTP.location]: DataFactory.namedNode(error.location) }); + super(error.statusCode, metadata); } } diff --git a/src/http/representation/N3Patch.ts b/src/http/representation/N3Patch.ts new file mode 100644 index 000000000..80ab1c6e8 --- /dev/null +++ b/src/http/representation/N3Patch.ts @@ -0,0 +1,18 @@ +import type { Quad } from 'rdf-js'; +import type { Patch } from './Patch'; + +/** + * A Representation of an N3 Patch. + * All quads should be in the default graph. + */ +export interface N3Patch extends Patch { + deletes: Quad[]; + inserts: Quad[]; + conditions: Quad[]; +} + +export function isN3Patch(patch: unknown): patch is N3Patch { + return Array.isArray((patch as N3Patch).deletes) && + Array.isArray((patch as N3Patch).inserts) && + Array.isArray((patch as N3Patch).conditions); +} diff --git a/src/http/representation/RepresentationMetadata.ts b/src/http/representation/RepresentationMetadata.ts index a998c5950..2cb3402a3 100644 --- a/src/http/representation/RepresentationMetadata.ts +++ b/src/http/representation/RepresentationMetadata.ts @@ -2,8 +2,8 @@ import { DataFactory, Store } from 'n3'; import type { BlankNode, DefaultGraph, Literal, NamedNode, Quad, Term } from 'rdf-js'; import { getLoggerFor } from '../../logging/LogUtil'; import { InternalServerError } from '../../util/errors/InternalServerError'; -import { toNamedTerm, toObjectTerm, toCachedNamedNode, isTerm } from '../../util/TermUtil'; -import { CONTENT_TYPE, CONTENT_TYPE_TERM } from '../../util/Vocabularies'; +import { toNamedTerm, toObjectTerm, toCachedNamedNode, isTerm, toLiteral } from '../../util/TermUtil'; +import { CONTENT_TYPE, CONTENT_TYPE_TERM, CONTENT_LENGTH_TERM, XSD } from '../../util/Vocabularies'; import type { ResourceIdentifier } from './ResourceIdentifier'; import { isResourceIdentifier } from './ResourceIdentifier'; @@ -316,4 +316,18 @@ export class RepresentationMetadata { public set contentType(input) { this.set(CONTENT_TYPE_TERM, input); } + + /** + * Shorthand for the CONTENT_LENGTH predicate. + */ + public get contentLength(): number | undefined { + const length = this.get(CONTENT_LENGTH_TERM); + return length?.value ? Number(length.value) : undefined; + } + + public set contentLength(input) { + if (input) { + this.set(CONTENT_LENGTH_TERM, toLiteral(input, XSD.terms.integer)); + } + } } diff --git a/src/identity/IdentityProviderHttpHandler.ts b/src/identity/IdentityProviderHttpHandler.ts index 5d9f416b2..aa9f146ef 100644 --- a/src/identity/IdentityProviderHttpHandler.ts +++ b/src/identity/IdentityProviderHttpHandler.ts @@ -1,243 +1,81 @@ -import type { Operation } from '../http/Operation'; -import type { ErrorHandler } from '../http/output/error/ErrorHandler'; -import { RedirectResponseDescription } from '../http/output/response/RedirectResponseDescription'; -import { ResponseDescription } from '../http/output/response/ResponseDescription'; -import { BasicRepresentation } from '../http/representation/BasicRepresentation'; +import { OkResponseDescription } from '../http/output/response/OkResponseDescription'; +import type { ResponseDescription } from '../http/output/response/ResponseDescription'; import { getLoggerFor } from '../logging/LogUtil'; -import type { HttpRequest } from '../server/HttpRequest'; import type { OperationHttpHandlerInput } from '../server/OperationHttpHandler'; import { OperationHttpHandler } from '../server/OperationHttpHandler'; import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter'; import { APPLICATION_JSON } from '../util/ContentTypes'; -import { BadRequestHttpError } from '../util/errors/BadRequestHttpError'; -import { joinUrl, trimTrailingSlashes } from '../util/PathUtil'; -import { addTemplateMetadata, cloneRepresentation } from '../util/ResourceUtil'; -import { readJsonStream } from '../util/StreamUtil'; import type { ProviderFactory } from './configuration/ProviderFactory'; -import type { Interaction } from './interaction/email-password/handler/InteractionHandler'; -import type { InteractionRoute, TemplatedInteractionResult } from './interaction/routing/InteractionRoute'; -import type { InteractionCompleter } from './interaction/util/InteractionCompleter'; - -// Registration is not standardized within Solid yet, so we use a custom versioned API for now -const API_VERSION = '0.2'; +import type { + InteractionHandler, + Interaction, +} from './interaction/InteractionHandler'; export interface IdentityProviderHttpHandlerArgs { - /** - * Base URL of the server. - */ - baseUrl: string; - /** - * Relative path of the IDP entry point. - */ - idpPath: string; /** * Used to generate the OIDC provider. */ providerFactory: ProviderFactory; /** - * All routes handling the custom IDP behaviour. - */ - interactionRoutes: InteractionRoute[]; - /** - * Used for content negotiation. + * Used for converting the input data. */ converter: RepresentationConverter; /** - * Used for POST requests that need to be handled by the OIDC library. + * Handles the requests. */ - interactionCompleter: InteractionCompleter; - /** - * Used for converting output errors. - */ - errorHandler: ErrorHandler; + handler: InteractionHandler; } /** - * Handles all requests relevant for the entire IDP interaction, - * by sending them to either a matching {@link InteractionRoute}, - * or the generated Provider from the {@link ProviderFactory} if there is no match. + * Generates the active Interaction object if there is an ongoing OIDC interaction + * and sends it to the {@link InteractionHandler}. * - * The InteractionRoutes handle all requests where we need custom behaviour, - * such as everything related to generating and validating an account. - * The Provider handles all the default request such as the initial handshake. + * Input data will first be converted to JSON. * - * This handler handles all requests since it assumes all those requests are relevant for the IDP interaction. - * A {@link RouterHandler} should be used to filter out other requests. + * Only GET and POST methods are accepted. */ export class IdentityProviderHttpHandler extends OperationHttpHandler { protected readonly logger = getLoggerFor(this); - private readonly baseUrl: string; private readonly providerFactory: ProviderFactory; - private readonly interactionRoutes: InteractionRoute[]; private readonly converter: RepresentationConverter; - private readonly interactionCompleter: InteractionCompleter; - private readonly errorHandler: ErrorHandler; - - private readonly controls: Record; + private readonly handler: InteractionHandler; public constructor(args: IdentityProviderHttpHandlerArgs) { - // It is important that the RequestParser does not read out the Request body stream. - // Otherwise we can't pass it anymore to the OIDC library when needed. super(); - // Trimming trailing slashes so the relative URL starts with a slash after slicing this off - this.baseUrl = trimTrailingSlashes(joinUrl(args.baseUrl, args.idpPath)); this.providerFactory = args.providerFactory; - this.interactionRoutes = args.interactionRoutes; this.converter = args.converter; - this.interactionCompleter = args.interactionCompleter; - this.errorHandler = args.errorHandler; - - this.controls = Object.assign( - {}, - ...this.interactionRoutes.map((route): Record => this.getRouteControls(route)), - ); + this.handler = args.handler; } - /** - * Finds the matching route and resolves the operation. - */ - public async handle({ operation, request, response }: OperationHttpHandlerInput): - Promise { + public async handle({ operation, request, response }: OperationHttpHandlerInput): Promise { // This being defined means we're in an OIDC session let oidcInteraction: Interaction | undefined; try { const provider = await this.providerFactory.getProvider(); - // This being defined means we're in an OIDC session oidcInteraction = await provider.interactionDetails(request, response); + this.logger.debug('Found an active OIDC interaction.'); } catch { - // Just a regular request + this.logger.debug('No active OIDC interaction found.'); } - // If our own interaction handler does not support the input, it is either invalid or a request for the OIDC library - const route = await this.findRoute(operation, oidcInteraction); - - if (!route) { - const provider = await this.providerFactory.getProvider(); - this.logger.debug(`Sending request to oidc-provider: ${request.url}`); - // Even though the typings do not indicate this, this is a Promise that needs to be awaited. - // Otherwise the `BaseHttpServerFactory` will write a 404 before the OIDC library could handle the response. - // eslint-disable-next-line @typescript-eslint/await-thenable - await provider.callback(request, response); - return; - } - - // Cloning input data so it can be sent back in case of errors - let clone = operation.body; - - // IDP handlers expect JSON data - if (!operation.body.isEmpty) { + // Convert input data to JSON + // Allows us to still support form data + const { contentType } = operation.body.metadata; + if (contentType && contentType !== APPLICATION_JSON) { + this.logger.debug(`Converting input ${contentType} to ${APPLICATION_JSON}`); const args = { representation: operation.body, preferences: { type: { [APPLICATION_JSON]: 1 }}, identifier: operation.target, }; - operation.body = await this.converter.handleSafe(args); - clone = await cloneRepresentation(operation.body); + operation = { + ...operation, + body: await this.converter.handleSafe(args), + }; } - const result = await route.handleOperation(operation, oidcInteraction); - - // Reset the body so it can be reused when needed for output - operation.body = clone; - - return this.handleInteractionResult(operation, request, result, oidcInteraction); - } - - /** - * Finds a route that supports the given request. - */ - private async findRoute(operation: Operation, oidcInteraction?: Interaction): Promise { - if (!operation.target.path.startsWith(this.baseUrl)) { - // This is either an invalid request or a call to the .well-known configuration - return; - } - const pathName = operation.target.path.slice(this.baseUrl.length); - - for (const route of this.interactionRoutes) { - if (route.supportsPath(pathName, oidcInteraction?.prompt.name)) { - return route; - } - } - } - - /** - * Creates a ResponseDescription based on the InteractionHandlerResult. - * This will either be a redirect if type is "complete" or a data stream if the type is "response". - */ - private async handleInteractionResult(operation: Operation, request: HttpRequest, - result: TemplatedInteractionResult, oidcInteraction?: Interaction): Promise { - let responseDescription: ResponseDescription | undefined; - - if (result.type === 'complete') { - if (!oidcInteraction) { - throw new BadRequestHttpError( - 'This action can only be performed as part of an OIDC authentication flow.', - { errorCode: 'E0002' }, - ); - } - // Create a redirect URL with the OIDC library - const location = await this.interactionCompleter.handleSafe({ ...result.details, request }); - responseDescription = new RedirectResponseDescription(location); - } else if (result.type === 'error') { - // We want to show the errors on the original page in case of html interactions, so we can't just throw them here - const preferences = { type: { [APPLICATION_JSON]: 1 }}; - const response = await this.errorHandler.handleSafe({ error: result.error, preferences }); - const details = await readJsonStream(response.data!); - - // Add the input data to the JSON response; - if (!operation.body.isEmpty) { - details.prefilled = await readJsonStream(operation.body.data); - - // Don't send passwords back - delete details.prefilled.password; - delete details.prefilled.confirmPassword; - } - - responseDescription = - await this.handleResponseResult(details, operation, result.templateFiles, oidcInteraction, response.statusCode); - } else { - // Convert the response object to a data stream - responseDescription = - await this.handleResponseResult(result.details ?? {}, operation, result.templateFiles, oidcInteraction); - } - - return responseDescription; - } - - /** - * Converts an InteractionResponseResult to a ResponseDescription by first converting to a Representation - * and applying necessary conversions. - */ - private async handleResponseResult(details: Record, operation: Operation, - templateFiles: Record, oidcInteraction?: Interaction, statusCode = 200): - Promise { - const json = { - ...details, - apiVersion: API_VERSION, - authenticating: Boolean(oidcInteraction), - controls: this.controls, - }; - const representation = new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON); - - // Template metadata is required for conversion - for (const [ type, templateFile ] of Object.entries(templateFiles)) { - addTemplateMetadata(representation.metadata, templateFile, type); - } - - // Potentially convert the Representation based on the preferences - const args = { representation, preferences: operation.preferences, identifier: operation.target }; - const converted = await this.converter.handleSafe(args); - - return new ResponseDescription(statusCode, converted.metadata, converted.data); - } - - /** - * Converts the controls object of a route to one with full URLs. - */ - private getRouteControls(route: InteractionRoute): Record { - const entries = Object.entries(route.getControls()) - .map(([ name, path ]): [ string, string ] => [ name, joinUrl(this.baseUrl, path) ]); - return Object.fromEntries(entries); + const representation = await this.handler.handleSafe({ operation, oidcInteraction }); + return new OkResponseDescription(representation.metadata, representation.data); } } diff --git a/src/identity/OidcHttpHandler.ts b/src/identity/OidcHttpHandler.ts new file mode 100644 index 000000000..03fb340d6 --- /dev/null +++ b/src/identity/OidcHttpHandler.ts @@ -0,0 +1,27 @@ +import { getLoggerFor } from '../logging/LogUtil'; +import type { HttpHandlerInput } from '../server/HttpHandler'; +import { HttpHandler } from '../server/HttpHandler'; +import type { ProviderFactory } from './configuration/ProviderFactory'; + +/** + * HTTP handler that redirects all requests to the OIDC library. + */ +export class OidcHttpHandler extends HttpHandler { + protected readonly logger = getLoggerFor(this); + + private readonly providerFactory: ProviderFactory; + + public constructor(providerFactory: ProviderFactory) { + super(); + this.providerFactory = providerFactory; + } + + public async handle({ request, response }: HttpHandlerInput): Promise { + const provider = await this.providerFactory.getProvider(); + this.logger.debug(`Sending request to oidc-provider: ${request.url}`); + // Even though the typings do not indicate this, this is a Promise that needs to be awaited. + // Otherwise, the `BaseHttpServerFactory` will write a 404 before the OIDC library could handle the response. + // eslint-disable-next-line @typescript-eslint/await-thenable + await provider.callback()(request, response); + } +} diff --git a/src/identity/configuration/IdentityProviderFactory.ts b/src/identity/configuration/IdentityProviderFactory.ts index 72dce748c..93b5545ac 100644 --- a/src/identity/configuration/IdentityProviderFactory.ts +++ b/src/identity/configuration/IdentityProviderFactory.ts @@ -4,18 +4,24 @@ import { randomBytes } from 'crypto'; import type { JWK } from 'jose'; import { exportJWK, generateKeyPair } from 'jose'; -import type { AnyObject, +import type { Account, + Adapter, CanBePromise, - KoaContextWithOIDC, Configuration, - Account, ErrorOut, - Adapter } from 'oidc-provider'; + KoaContextWithOIDC, + ResourceServer, + UnknownObject } from 'oidc-provider'; import { Provider } from 'oidc-provider'; +import type { Operation } from '../../http/Operation'; import type { ErrorHandler } from '../../http/output/error/ErrorHandler'; import type { ResponseWriter } from '../../http/output/ResponseWriter'; +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage'; -import { ensureTrailingSlash, joinUrl } from '../../util/PathUtil'; +import { InternalServerError } from '../../util/errors/InternalServerError'; +import { RedirectHttpError } from '../../util/errors/RedirectHttpError'; +import { joinUrl } from '../../util/PathUtil'; +import type { InteractionHandler } from '../interaction/InteractionHandler'; import type { AdapterFactory } from '../storage/AdapterFactory'; import type { ProviderFactory } from './ProviderFactory'; @@ -29,10 +35,13 @@ export interface IdentityProviderFactoryArgs { */ baseUrl: string; /** - * Path of the IDP component in the server. - * Should start with a slash. + * Path for all requests targeting the OIDC library. */ - idpPath: string; + oidcPath: string; + /** + * The handler responsible for redirecting interaction requests to the correct URL. + */ + interactionHandler: InteractionHandler; /** * Storage used to store cookie and JWT keys so they can be re-used in case of multithreading. */ @@ -55,17 +64,19 @@ const COOKIES_KEY = 'cookie-secret'; * The provider will be cached and returned on subsequent calls. * Cookie and JWT keys will be stored in an internal storage so they can be re-used over multiple threads. * Necessary claims for Solid OIDC interactions will be added. - * Routes will be updated based on the `baseUrl` and `idpPath`. + * Routes will be updated based on the `baseUrl` and `oidcPath`. */ export class IdentityProviderFactory implements ProviderFactory { private readonly config: Configuration; private readonly adapterFactory!: AdapterFactory; private readonly baseUrl!: string; - private readonly idpPath!: string; + private readonly oidcPath!: string; + private readonly interactionHandler!: InteractionHandler; private readonly storage!: KeyValueStorage; private readonly errorHandler!: ErrorHandler; private readonly responseWriter!: ResponseWriter; + private readonly jwtAlg = 'ES256'; private provider?: Provider; /** @@ -73,9 +84,6 @@ export class IdentityProviderFactory implements ProviderFactory { * @param args - Remaining parameters required for the factory. */ public constructor(config: Configuration, args: IdentityProviderFactoryArgs) { - if (!args.idpPath.startsWith('/')) { - throw new Error('idpPath needs to start with a /'); - } this.config = config; Object.assign(this, args); } @@ -106,6 +114,7 @@ export class IdentityProviderFactory implements ProviderFactory { // Allow provider to interpret reverse proxy headers const provider = new Provider(this.baseUrl, config); provider.proxy = true; + return provider; } @@ -132,11 +141,23 @@ export class IdentityProviderFactory implements ProviderFactory { keys: await this.generateCookieKeys(), }; + // Solid OIDC requires pkce https://solid.github.io/solid-oidc/#concepts + config.pkce = { + methods: [ 'S256' ], + required: (): true => true, + }; + + // Default client settings that might not be defined. + // Mostly relevant for WebID clients. + config.clientDefaults = { + id_token_signed_response_alg: this.jwtAlg, + }; + return config; } /** - * Generates a JWKS using a single RS256 JWK.. + * Generates a JWKS using a single JWK. * The JWKS will be cached so subsequent calls return the same key. */ private async generateJwks(): Promise<{ keys: JWK[] }> { @@ -146,10 +167,10 @@ export class IdentityProviderFactory implements ProviderFactory { return jwks; } // If they are not, generate and save them - const { privateKey } = await generateKeyPair('RS256'); + const { privateKey } = await generateKeyPair(this.jwtAlg); const jwk = await exportJWK(privateKey); // Required for Solid authn client - jwk.alg = 'RS256'; + jwk.alg = this.jwtAlg; // In node v15.12.0 the JWKS does not get accepted because the JWK is not a plain object, // which is why we convert it into a plain object here. // Potentially this can be changed at a later point in time to `{ keys: [ jwk ]}`. @@ -183,37 +204,60 @@ export class IdentityProviderFactory implements ProviderFactory { } /** - * Adds the necessary claims the to id token and access token based on the Solid OIDC spec. + * Adds the necessary claims the to id and access tokens based on the Solid OIDC spec. */ private configureClaims(config: Configuration): void { - // Access token audience is 'solid', ID token audience is the client_id - config.audiences = (ctx, sub, token, use): string => - use === 'access_token' ? 'solid' : token.clientId!; - // Returns the id_token // See https://solid.github.io/authentication-panel/solid-oidc/#tokens-id + // Some fields are still missing, see https://github.com/solid/community-server/issues/1154#issuecomment-1040233385 config.findAccount = async(ctx: KoaContextWithOIDC, sub: string): Promise => ({ accountId: sub, - claims: async(): Promise<{ sub: string; [key: string]: any }> => - ({ sub, webid: sub }), + async claims(): Promise<{ sub: string; [key: string]: any }> { + return { sub, webid: sub, azp: ctx.oidc.client?.clientId }; + }, }); // Add extra claims in case an AccessToken is being issued. // Specifically this sets the required webid and client_id claims for the access token - // See https://solid.github.io/authentication-panel/solid-oidc/#tokens-access - config.extraAccessTokenClaims = (ctx, token): CanBePromise => + // See https://solid.github.io/solid-oidc/#resource-access-validation + config.extraTokenClaims = (ctx, token): CanBePromise => this.isAccessToken(token) ? - { webid: token.accountId, client_id: token.clientId } : + { webid: token.accountId } : {}; + + config.features = { + ...config.features, + resourceIndicators: { + defaultResource(): string { + // This value is irrelevant, but is necessary to trigger the `getResourceServerInfo` call below, + // where it will be an input parameter in case the client provided no value. + // Note that an empty string is not a valid value. + return 'http://example.com/'; + }, + enabled: true, + // This call is necessary to force the OIDC library to return a JWT access token. + // See https://github.com/panva/node-oidc-provider/discussions/959#discussioncomment-524757 + getResourceServerInfo: (): ResourceServer => ({ + // The scopes of the Resource Server. + // Since this is irrelevant at the moment, an empty string is fine. + scope: '', + audience: 'solid', + accessTokenFormat: 'jwt', + jwt: { + sign: { alg: this.jwtAlg }, + }, + }), + }, + }; } /** * Creates the route string as required by the `oidc-provider` library. - * In case base URL is `http://test.com/foo/`, `idpPath` is `/idp` and `relative` is `device/auth`, + * In case base URL is `http://test.com/foo/`, `oidcPath` is `/idp` and `relative` is `device/auth`, * this would result in `/foo/idp/device/auth`. */ private createRoute(relative: string): string { - return new URL(joinUrl(this.baseUrl, this.idpPath, relative)).pathname; + return new URL(joinUrl(this.baseUrl, this.oidcPath, relative)).pathname; } /** @@ -223,13 +267,33 @@ export class IdentityProviderFactory implements ProviderFactory { // When oidc-provider cannot fulfill the authorization request for any of the possible reasons // (missing user session, requested ACR not fulfilled, prompt requested, ...) // it will resolve the interactions.url helper function and redirect the User-Agent to that url. + // Another requirement is that `features.userinfo` is disabled in the configuration. config.interactions = { - url: (): string => ensureTrailingSlash(this.idpPath), + url: async(ctx, oidcInteraction): Promise => { + const operation: Operation = { + method: ctx.method, + target: { path: ctx.request.href }, + preferences: {}, + body: new BasicRepresentation(), + }; + + // Instead of sending a 3xx redirect to the client (via a RedirectHttpError), + // we need to pass the location URL to the OIDC library + try { + await this.interactionHandler.handleSafe({ operation, oidcInteraction }); + } catch (error: unknown) { + if (RedirectHttpError.isInstance(error)) { + return error.location; + } + throw error; + } + throw new InternalServerError('Could not correctly redirect for the given interaction.'); + }, }; config.routes = { authorization: this.createRoute('auth'), - check_session: this.createRoute('session/check'), + backchannel_authentication: this.createRoute('backchannel'), code_verification: this.createRoute('device'), device_authorization: this.createRoute('device/auth'), end_session: this.createRoute('session/end'), @@ -248,8 +312,14 @@ export class IdentityProviderFactory implements ProviderFactory { */ private configureErrors(config: Configuration): void { config.renderError = async(ctx: KoaContextWithOIDC, out: ErrorOut, error: Error): Promise => { - // This allows us to stream directly to to the response object, see https://github.com/koajs/koa/issues/944 + // This allows us to stream directly to the response object, see https://github.com/koajs/koa/issues/944 ctx.respond = false; + + // OIDC library hides extra details in this field + if (out.error_description) { + error.message += ` - ${out.error_description}`; + } + const result = await this.errorHandler.handleSafe({ error, preferences: { type: { 'text/plain': 1 }}}); await this.responseWriter.handleSafe({ response: ctx.res, result }); }; diff --git a/src/identity/interaction/BaseInteractionHandler.ts b/src/identity/interaction/BaseInteractionHandler.ts new file mode 100644 index 000000000..e81afe7a2 --- /dev/null +++ b/src/identity/interaction/BaseInteractionHandler.ts @@ -0,0 +1,51 @@ +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import { InteractionHandler } from './InteractionHandler'; + +/** + * Abstract implementation for handlers that always return a fixed JSON view on a GET. + * POST requests are passed to an abstract function. + * Other methods will be rejected. + */ +export abstract class BaseInteractionHandler extends InteractionHandler { + private readonly view: string; + + protected constructor(view: Record) { + super(); + this.view = JSON.stringify(view); + } + + public async canHandle(input: InteractionHandlerInput): Promise { + await super.canHandle(input); + const { method } = input.operation; + if (method !== 'GET' && method !== 'POST') { + throw new MethodNotAllowedHttpError('Only GET/POST requests are supported.'); + } + } + + public async handle(input: InteractionHandlerInput): Promise { + switch (input.operation.method) { + case 'GET': return this.handleGet(input); + case 'POST': return this.handlePost(input); + default: throw new MethodNotAllowedHttpError(); + } + } + + /** + * Returns a fixed JSON view. + * @param input - Input parameters, only the operation target is used. + */ + protected async handleGet(input: InteractionHandlerInput): Promise { + return new BasicRepresentation(this.view, input.operation.target, APPLICATION_JSON); + } + + /** + * Function that will be called for POST requests. + * Input data remains unchanged. + * @param input - Input operation and OidcInteraction if it exists. + */ + protected abstract handlePost(input: InteractionHandlerInput): Promise; +} diff --git a/src/identity/interaction/ConsentHandler.ts b/src/identity/interaction/ConsentHandler.ts new file mode 100644 index 000000000..79e1df6c6 --- /dev/null +++ b/src/identity/interaction/ConsentHandler.ts @@ -0,0 +1,142 @@ +import type { + AllClientMetadata, + InteractionResults, + KoaContextWithOIDC, + UnknownObject, +} from 'oidc-provider'; +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; +import { FoundHttpError } from '../../util/errors/FoundHttpError'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { readJsonStream } from '../../util/StreamUtil'; +import type { ProviderFactory } from '../configuration/ProviderFactory'; +import { BaseInteractionHandler } from './BaseInteractionHandler'; +import type { Interaction, InteractionHandlerInput } from './InteractionHandler'; + +type Grant = NonNullable; + +/** + * Handles the OIDC consent prompts where the user confirms they want to log in for the given client. + * + * Returns all the relevant Client metadata on GET requests. + */ +export class ConsentHandler extends BaseInteractionHandler { + private readonly providerFactory: ProviderFactory; + + public constructor(providerFactory: ProviderFactory) { + super({}); + this.providerFactory = providerFactory; + } + + public async canHandle(input: InteractionHandlerInput): Promise { + await super.canHandle(input); + if (input.operation.method === 'POST' && !input.oidcInteraction) { + throw new BadRequestHttpError( + 'This action can only be performed as part of an OIDC authentication flow.', + { errorCode: 'E0002' }, + ); + } + } + + protected async handleGet(input: Required): Promise { + const { operation, oidcInteraction } = input; + const provider = await this.providerFactory.getProvider(); + const client = await provider.Client.find(oidcInteraction.params.client_id as string); + const metadata: AllClientMetadata = client?.metadata() ?? {}; + + // Only extract specific fields to prevent leaking information + // Based on https://www.w3.org/ns/solid/oidc-context.jsonld + const keys = [ 'client_id', 'client_uri', 'logo_uri', 'policy_uri', + 'client_name', 'contacts', 'grant_types', 'scope' ]; + + const jsonLd = Object.fromEntries( + keys.filter((key): boolean => key in metadata) + .map((key): [ string, unknown ] => [ key, metadata[key] ]), + ); + jsonLd['@context'] = 'https://www.w3.org/ns/solid/oidc-context.jsonld'; + const json = { client: jsonLd }; + + return new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON); + } + + protected async handlePost({ operation, oidcInteraction }: InteractionHandlerInput): Promise { + const { remember } = await readJsonStream(operation.body.data); + + const grant = await this.getGrant(oidcInteraction!); + this.updateGrant(grant, oidcInteraction!.prompt.details, remember); + + const location = await this.updateInteraction(oidcInteraction!, grant); + + throw new FoundHttpError(location); + } + + /** + * Either returns the grant associated with the given interaction or creates a new one if it does not exist yet. + */ + private async getGrant(oidcInteraction: Interaction): Promise { + if (!oidcInteraction.session) { + throw new NotImplementedHttpError('Only interactions with a valid session are supported.'); + } + + const { params, session: { accountId }, grantId } = oidcInteraction; + const provider = await this.providerFactory.getProvider(); + let grant: Grant; + if (grantId) { + grant = (await provider.Grant.find(grantId))!; + } else { + grant = new provider.Grant({ + accountId, + clientId: params.client_id as string, + }); + } + return grant; + } + + /** + * Updates the grant with all the missing scopes and claims requested by the interaction. + * + * Will reject the `offline_access` scope if `remember` is false. + */ + private updateGrant(grant: Grant, details: UnknownObject, remember: boolean): void { + // Reject the offline_access scope if the user does not want to be remembered + if (!remember) { + grant.rejectOIDCScope('offline_access'); + } + + // Grant all the requested scopes and claims + if (details.missingOIDCScope) { + grant.addOIDCScope((details.missingOIDCScope as string[]).join(' ')); + } + if (details.missingOIDCClaims) { + grant.addOIDCClaims(details.missingOIDCClaims as string[]); + } + if (details.missingResourceScopes) { + for (const [ indicator, scopes ] of Object.entries(details.missingResourceScopes as Record)) { + grant.addResourceScope(indicator, scopes.join(' ')); + } + } + } + + /** + * Updates the interaction with the new grant and returns the resulting redirect URL. + */ + private async updateInteraction(oidcInteraction: Interaction, grant: Grant): Promise { + const grantId = await grant.save(); + + const consent: InteractionResults['consent'] = {}; + // Only need to update the grantId if it is new + if (!oidcInteraction.grantId) { + consent.grantId = grantId; + } + + const result: InteractionResults = { consent }; + + // Need to merge with previous submission + oidcInteraction.result = { ...oidcInteraction.lastSubmission, ...result }; + await oidcInteraction.save(oidcInteraction.exp - Math.floor(Date.now() / 1000)); + + return oidcInteraction.returnTo; + } +} diff --git a/src/identity/interaction/ControlHandler.ts b/src/identity/interaction/ControlHandler.ts new file mode 100644 index 000000000..34eb78500 --- /dev/null +++ b/src/identity/interaction/ControlHandler.ts @@ -0,0 +1,43 @@ +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import { InternalServerError } from '../../util/errors/InternalServerError'; +import { readJsonStream } from '../../util/StreamUtil'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import { InteractionHandler } from './InteractionHandler'; +import type { InteractionRoute } from './routing/InteractionRoute'; + +const INTERNAL_API_VERSION = '0.3'; + +/** + * Adds `controls` and `apiVersion` fields to the output of its source handler, + * such that clients can predictably find their way to other resources. + * Control paths are determined by the input routes. + */ +export class ControlHandler extends InteractionHandler { + private readonly source: InteractionHandler; + private readonly controls: Record; + + public constructor(source: InteractionHandler, controls: Record) { + super(); + this.source = source; + this.controls = Object.fromEntries( + Object.entries(controls).map(([ control, route ]): [ string, string ] => [ control, route.getPath() ]), + ); + } + + public async canHandle(input: InteractionHandlerInput): Promise { + await this.source.canHandle(input); + } + + public async handle(input: InteractionHandlerInput): Promise { + const result = await this.source.handle(input); + if (result.metadata.contentType !== APPLICATION_JSON) { + throw new InternalServerError('Source handler should return application/json.'); + } + const json = await readJsonStream(result.data); + json.controls = this.controls; + json.apiVersion = INTERNAL_API_VERSION; + return new BasicRepresentation(JSON.stringify(json), result.metadata); + } +} diff --git a/src/identity/interaction/FixedInteractionHandler.ts b/src/identity/interaction/FixedInteractionHandler.ts new file mode 100644 index 000000000..c3deecf3c --- /dev/null +++ b/src/identity/interaction/FixedInteractionHandler.ts @@ -0,0 +1,26 @@ +/* eslint-disable tsdoc/syntax */ +// tsdoc/syntax cannot handle `@range` +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import { InteractionHandler } from './InteractionHandler'; + +/** + * An {@link InteractionHandler} that always returns the same JSON response on all requests. + */ +export class FixedInteractionHandler extends InteractionHandler { + private readonly response: string; + + /** + * @param response - @range {json} + */ + public constructor(response: unknown) { + super(); + this.response = JSON.stringify(response); + } + + public async handle({ operation }: InteractionHandlerInput): Promise { + return new BasicRepresentation(this.response, operation.target, APPLICATION_JSON); + } +} diff --git a/src/identity/interaction/HtmlViewHandler.ts b/src/identity/interaction/HtmlViewHandler.ts new file mode 100644 index 000000000..7861f9fcf --- /dev/null +++ b/src/identity/interaction/HtmlViewHandler.ts @@ -0,0 +1,61 @@ +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { cleanPreferences, getTypeWeight } from '../../storage/conversion/ConversionUtil'; +import { APPLICATION_JSON, TEXT_HTML } from '../../util/ContentTypes'; +import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError'; +import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import type { TemplateEngine } from '../../util/templates/TemplateEngine'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import { InteractionHandler } from './InteractionHandler'; +import type { InteractionRoute } from './routing/InteractionRoute'; + +/** + * Stores the HTML templates associated with specific InteractionRoutes. + * Template keys should be file paths to the templates, + * values should be the corresponding routes. + * + * Will only handle GET operations for which there is a matching template if HTML is more preferred than JSON. + * Reason for doing it like this instead of a standard content negotiation flow + * is because we only want to return the HTML pages on GET requests. * + * + * Templates will receive the parameter `idpIndex` in their context pointing to the root index URL of the IDP API + * and an `authenticating` parameter indicating if this is an active OIDC interaction. + */ +export class HtmlViewHandler extends InteractionHandler { + private readonly idpIndex: string; + private readonly templateEngine: TemplateEngine; + private readonly templates: Record; + + public constructor(index: InteractionRoute, templateEngine: TemplateEngine, + templates: Record) { + super(); + this.idpIndex = index.getPath(); + this.templateEngine = templateEngine; + this.templates = Object.fromEntries( + Object.entries(templates).map(([ template, route ]): [ string, string ] => [ route.getPath(), template ]), + ); + } + + public async canHandle({ operation }: InteractionHandlerInput): Promise { + if (operation.method !== 'GET') { + throw new MethodNotAllowedHttpError(); + } + if (!this.templates[operation.target.path]) { + throw new NotFoundHttpError(); + } + const preferences = cleanPreferences(operation.preferences.type); + const htmlWeight = getTypeWeight(TEXT_HTML, preferences); + const jsonWeight = getTypeWeight(APPLICATION_JSON, preferences); + if (jsonWeight >= htmlWeight) { + throw new NotImplementedHttpError('HTML views are only returned when they are preferred.'); + } + } + + public async handle({ operation, oidcInteraction }: InteractionHandlerInput): Promise { + const template = this.templates[operation.target.path]; + const contents = { idpIndex: this.idpIndex, authenticating: Boolean(oidcInteraction) }; + const result = await this.templateEngine.render(contents, { templateFile: template }); + return new BasicRepresentation(result, operation.target, TEXT_HTML); + } +} diff --git a/src/identity/interaction/InteractionHandler.ts b/src/identity/interaction/InteractionHandler.ts new file mode 100644 index 000000000..3bef6f451 --- /dev/null +++ b/src/identity/interaction/InteractionHandler.ts @@ -0,0 +1,34 @@ +import type { KoaContextWithOIDC } from 'oidc-provider'; +import type { Operation } from '../../http/Operation'; +import type { Representation } from '../../http/representation/Representation'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { AsyncHandler } from '../../util/handlers/AsyncHandler'; + +// OIDC library does not directly export the Interaction type +export type Interaction = NonNullable; + +export interface InteractionHandlerInput { + /** + * The operation to execute. + */ + operation: Operation; + /** + * Will be defined if the OIDC library expects us to resolve an interaction it can't handle itself, + * such as logging a user in. + */ + oidcInteraction?: Interaction; +} + +/** + * Handler used for IDP interactions. + * Only supports JSON data. + */ +export abstract class InteractionHandler extends AsyncHandler { + public async canHandle({ operation }: InteractionHandlerInput): Promise { + const { contentType } = operation.body.metadata; + if (contentType && contentType !== APPLICATION_JSON) { + throw new NotImplementedHttpError('Only application/json data is supported.'); + } + } +} diff --git a/src/identity/interaction/LocationInteractionHandler.ts b/src/identity/interaction/LocationInteractionHandler.ts new file mode 100644 index 000000000..392307139 --- /dev/null +++ b/src/identity/interaction/LocationInteractionHandler.ts @@ -0,0 +1,48 @@ +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import { RedirectHttpError } from '../../util/errors/RedirectHttpError'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import { InteractionHandler } from './InteractionHandler'; + +/** + * Transforms an HTTP redirect into a hypermedia document with a redirection link, + * such that scripts running in a browser can redirect the user to the next page. + * + * This handler addresses the situation where: + * - the user visits a first page + * - this first page contains a script that performs interactions with a JSON API + * - as a result of a certain interaction, the user needs to be redirected to a second page + * + * Regular HTTP redirects are performed via responses with 3xx status codes. + * However, since the consumer of the API in this case is a browser script, + * a 3xx response would only reach that script and not move the page for the user. + * + * Therefore, this handler changes a 3xx response into a 200 response + * with an explicit link to the next page, + * enabling the script to move the user to the next page. + */ +export class LocationInteractionHandler extends InteractionHandler { + private readonly source: InteractionHandler; + + public constructor(source: InteractionHandler) { + super(); + this.source = source; + } + + public async canHandle(input: InteractionHandlerInput): Promise { + await this.source.canHandle(input); + } + + public async handle(input: InteractionHandlerInput): Promise { + try { + return await this.source.handle(input); + } catch (error: unknown) { + if (RedirectHttpError.isInstance(error)) { + const body = JSON.stringify({ location: error.location }); + return new BasicRepresentation(body, input.operation.target, APPLICATION_JSON); + } + throw error; + } + } +} diff --git a/src/identity/interaction/PromptHandler.ts b/src/identity/interaction/PromptHandler.ts new file mode 100644 index 000000000..1eb838204 --- /dev/null +++ b/src/identity/interaction/PromptHandler.ts @@ -0,0 +1,28 @@ +import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; +import { FoundHttpError } from '../../util/errors/FoundHttpError'; +import { InteractionHandler } from './InteractionHandler'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import type { InteractionRoute } from './routing/InteractionRoute'; + +/** + * Redirects requests based on the OIDC Interaction prompt. + * Errors in case no match was found. + */ +export class PromptHandler extends InteractionHandler { + private readonly promptRoutes: Record; + + public constructor(promptRoutes: Record) { + super(); + this.promptRoutes = promptRoutes; + } + + public async handle({ oidcInteraction }: InteractionHandlerInput): Promise { + // We also want to redirect on GET so no method check is needed + const prompt = oidcInteraction?.prompt.name; + if (prompt && this.promptRoutes[prompt]) { + const location = this.promptRoutes[prompt].getPath(); + throw new FoundHttpError(location); + } + throw new BadRequestHttpError(`Unsupported prompt: ${prompt}`); + } +} diff --git a/src/identity/interaction/SessionHttpHandler.ts b/src/identity/interaction/SessionHttpHandler.ts deleted file mode 100644 index 3633ba6c9..000000000 --- a/src/identity/interaction/SessionHttpHandler.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; -import { readJsonStream } from '../../util/StreamUtil'; -import { InteractionHandler } from './email-password/handler/InteractionHandler'; -import type { InteractionCompleteResult, InteractionHandlerInput } from './email-password/handler/InteractionHandler'; - -/** - * Simple InteractionHttpHandler that sends the session accountId to the InteractionCompleter as webId. - */ -export class SessionHttpHandler extends InteractionHandler { - public async handle({ operation, oidcInteraction }: InteractionHandlerInput): Promise { - if (!oidcInteraction?.session) { - throw new NotImplementedHttpError('Only interactions with a valid session are supported.'); - } - - const { remember } = await readJsonStream(operation.body.data); - return { - type: 'complete', - details: { webId: oidcInteraction.session.accountId, shouldRemember: Boolean(remember) }, - }; - } -} diff --git a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts index 17e68c99a..500691273 100644 --- a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts @@ -1,49 +1,55 @@ import assert from 'assert'; +import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; +import type { Representation } from '../../../../http/representation/Representation'; import { getLoggerFor } from '../../../../logging/LogUtil'; -import { ensureTrailingSlash, joinUrl } from '../../../../util/PathUtil'; +import { APPLICATION_JSON } from '../../../../util/ContentTypes'; import { readJsonStream } from '../../../../util/StreamUtil'; import type { TemplateEngine } from '../../../../util/templates/TemplateEngine'; -import type { EmailSender } from '../../util/EmailSender'; +import { BaseInteractionHandler } from '../../BaseInteractionHandler'; +import type { InteractionHandlerInput } from '../../InteractionHandler'; +import type { InteractionRoute } from '../../routing/InteractionRoute'; import type { AccountStore } from '../storage/AccountStore'; -import { InteractionHandler } from './InteractionHandler'; -import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler'; +import type { EmailSender } from '../util/EmailSender'; + +const forgotPasswordView = { + required: { + email: 'string', + }, +} as const; export interface ForgotPasswordHandlerArgs { accountStore: AccountStore; - baseUrl: string; - idpPath: string; templateEngine: TemplateEngine<{ resetLink: string }>; emailSender: EmailSender; + resetRoute: InteractionRoute; } /** * Handles the submission of the ForgotPassword form */ -export class ForgotPasswordHandler extends InteractionHandler { +export class ForgotPasswordHandler extends BaseInteractionHandler { protected readonly logger = getLoggerFor(this); private readonly accountStore: AccountStore; - private readonly baseUrl: string; - private readonly idpPath: string; private readonly templateEngine: TemplateEngine<{ resetLink: string }>; private readonly emailSender: EmailSender; + private readonly resetRoute: InteractionRoute; public constructor(args: ForgotPasswordHandlerArgs) { - super(); + super(forgotPasswordView); this.accountStore = args.accountStore; - this.baseUrl = ensureTrailingSlash(args.baseUrl); - this.idpPath = args.idpPath; this.templateEngine = args.templateEngine; this.emailSender = args.emailSender; + this.resetRoute = args.resetRoute; } - public async handle({ operation }: InteractionHandlerInput): Promise> { + public async handlePost({ operation }: InteractionHandlerInput): Promise { // Validate incoming data const { email } = await readJsonStream(operation.body.data); assert(typeof email === 'string' && email.length > 0, 'Email required'); await this.resetPassword(email); - return { type: 'response', details: { email }}; + return new BasicRepresentation(JSON.stringify({ email }), operation.target, APPLICATION_JSON); } /** @@ -68,7 +74,7 @@ export class ForgotPasswordHandler extends InteractionHandler { */ private async sendResetMail(recordId: string, email: string): Promise { this.logger.info(`Sending password reset to ${email}`); - const resetLink = joinUrl(this.baseUrl, this.idpPath, `resetpassword/${recordId}`); + const resetLink = `${this.resetRoute.getPath()}?rid=${encodeURIComponent(recordId)}`; const renderedEmail = await this.templateEngine.render({ resetLink }); await this.emailSender.handleSafe({ recipient: email, diff --git a/src/identity/interaction/email-password/handler/InteractionHandler.ts b/src/identity/interaction/email-password/handler/InteractionHandler.ts deleted file mode 100644 index d9654e0c4..000000000 --- a/src/identity/interaction/email-password/handler/InteractionHandler.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { KoaContextWithOIDC } from 'oidc-provider'; -import type { Operation } from '../../../../http/Operation'; -import { APPLICATION_JSON } from '../../../../util/ContentTypes'; -import { NotImplementedHttpError } from '../../../../util/errors/NotImplementedHttpError'; -import { AsyncHandler } from '../../../../util/handlers/AsyncHandler'; -import type { InteractionCompleterParams } from '../../util/InteractionCompleter'; - -// OIDC library does not directly export the Interaction type -export type Interaction = KoaContextWithOIDC['oidc']['entities']['Interaction']; - -export interface InteractionHandlerInput { - /** - * The operation to execute - */ - operation: Operation; - /** - * Will be defined if the OIDC library expects us to resolve an interaction it can't handle itself, - * such as logging a user in. - */ - oidcInteraction?: Interaction; -} - -export type InteractionHandlerResult = InteractionResponseResult | InteractionCompleteResult | InteractionErrorResult; - -export interface InteractionResponseResult> { - type: 'response'; - details?: T; -} - -export interface InteractionCompleteResult { - type: 'complete'; - details: InteractionCompleterParams; -} - -export interface InteractionErrorResult { - type: 'error'; - error: Error; -} - -/** - * Handler used for IDP interactions. - * Only supports JSON data. - */ -export abstract class InteractionHandler extends AsyncHandler { - public async canHandle({ operation }: InteractionHandlerInput): Promise { - if (operation.body?.metadata.contentType !== APPLICATION_JSON) { - throw new NotImplementedHttpError('Only application/json data is supported.'); - } - } -} diff --git a/src/identity/interaction/email-password/handler/LoginHandler.ts b/src/identity/interaction/email-password/handler/LoginHandler.ts index 3be6c0a59..6b6a9c1c3 100644 --- a/src/identity/interaction/email-password/handler/LoginHandler.ts +++ b/src/identity/interaction/email-password/handler/LoginHandler.ts @@ -1,26 +1,53 @@ import assert from 'assert'; +import type { InteractionResults } from 'oidc-provider'; import type { Operation } from '../../../../http/Operation'; import { getLoggerFor } from '../../../../logging/LogUtil'; import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; +import { FoundHttpError } from '../../../../util/errors/FoundHttpError'; import { readJsonStream } from '../../../../util/StreamUtil'; +import { BaseInteractionHandler } from '../../BaseInteractionHandler'; +import type { InteractionHandlerInput } from '../../InteractionHandler'; import type { AccountStore } from '../storage/AccountStore'; -import { InteractionHandler } from './InteractionHandler'; -import type { InteractionCompleteResult, InteractionHandlerInput } from './InteractionHandler'; + +const loginView = { + required: { + email: 'string', + password: 'string', + remember: 'boolean', + }, +} as const; + +interface LoginInput { + email: string; + password: string; + remember: boolean; +} /** * Handles the submission of the Login Form and logs the user in. + * Will throw a RedirectHttpError on success. */ -export class LoginHandler extends InteractionHandler { +export class LoginHandler extends BaseInteractionHandler { protected readonly logger = getLoggerFor(this); private readonly accountStore: AccountStore; public constructor(accountStore: AccountStore) { - super(); + super(loginView); this.accountStore = accountStore; } - public async handle({ operation }: InteractionHandlerInput): Promise { + public async canHandle(input: InteractionHandlerInput): Promise { + await super.canHandle(input); + if (input.operation.method === 'POST' && !input.oidcInteraction) { + throw new BadRequestHttpError( + 'This action can only be performed as part of an OIDC authentication flow.', + { errorCode: 'E0002' }, + ); + } + } + + public async handlePost({ operation, oidcInteraction }: InteractionHandlerInput): Promise { const { email, password, remember } = await this.parseInput(operation); // Try to log in, will error if email/password combination is invalid const webId = await this.accountStore.authenticate(email, password); @@ -30,22 +57,25 @@ export class LoginHandler extends InteractionHandler { throw new BadRequestHttpError('This server is not an identity provider for this account.'); } this.logger.debug(`Logging in user ${email}`); - return { - type: 'complete', - details: { webId, shouldRemember: remember }, + + // Update the interaction to get the redirect URL + const login: InteractionResults['login'] = { + accountId: webId, + remember, }; + oidcInteraction!.result = { login }; + await oidcInteraction!.save(oidcInteraction!.exp - Math.floor(Date.now() / 1000)); + + throw new FoundHttpError(oidcInteraction!.returnTo); } /** - * Parses and validates the input form data. + * Validates the input data. Also makes sure remember is a boolean. * Will throw an error in case something is wrong. - * All relevant data that was correct up to that point will be prefilled. */ - private async parseInput(operation: Operation): Promise<{ email: string; password: string; remember: boolean }> { - const prefilled: Record = {}; + private async parseInput(operation: Operation): Promise { const { email, password, remember } = await readJsonStream(operation.body.data); assert(typeof email === 'string' && email.length > 0, 'Email required'); - prefilled.email = email; assert(typeof password === 'string' && password.length > 0, 'Password required'); return { email, password, remember: Boolean(remember) }; } diff --git a/src/identity/interaction/email-password/handler/RegistrationHandler.ts b/src/identity/interaction/email-password/handler/RegistrationHandler.ts index e91251606..a8ae66b5c 100644 --- a/src/identity/interaction/email-password/handler/RegistrationHandler.ts +++ b/src/identity/interaction/email-password/handler/RegistrationHandler.ts @@ -1,27 +1,46 @@ +import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; +import type { Representation } from '../../../../http/representation/Representation'; import { getLoggerFor } from '../../../../logging/LogUtil'; +import { APPLICATION_JSON } from '../../../../util/ContentTypes'; import { readJsonStream } from '../../../../util/StreamUtil'; -import type { RegistrationManager, RegistrationResponse } from '../util/RegistrationManager'; -import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler'; -import { InteractionHandler } from './InteractionHandler'; +import { BaseInteractionHandler } from '../../BaseInteractionHandler'; +import type { InteractionHandlerInput } from '../../InteractionHandler'; +import type { RegistrationManager } from '../util/RegistrationManager'; + +const registrationView = { + required: { + email: 'string', + password: 'string', + confirmPassword: 'string', + createWebId: 'boolean', + register: 'boolean', + createPod: 'boolean', + rootPod: 'boolean', + }, + optional: { + webId: 'string', + podName: 'string', + template: 'string', + }, +} as const; /** * Supports registration based on the `RegistrationManager` behaviour. */ -export class RegistrationHandler extends InteractionHandler { +export class RegistrationHandler extends BaseInteractionHandler { protected readonly logger = getLoggerFor(this); private readonly registrationManager: RegistrationManager; public constructor(registrationManager: RegistrationManager) { - super(); + super(registrationView); this.registrationManager = registrationManager; } - public async handle({ operation }: InteractionHandlerInput): - Promise> { + public async handlePost({ operation }: InteractionHandlerInput): Promise { const data = await readJsonStream(operation.body.data); const validated = this.registrationManager.validateInput(data, false); const details = await this.registrationManager.register(validated, false); - return { type: 'response', details }; + return new BasicRepresentation(JSON.stringify(details), operation.target, APPLICATION_JSON); } } diff --git a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts index 07f721a5d..5f9fe520e 100644 --- a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts @@ -1,30 +1,39 @@ import assert from 'assert'; +import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; +import type { Representation } from '../../../../http/representation/Representation'; import { getLoggerFor } from '../../../../logging/LogUtil'; +import { APPLICATION_JSON } from '../../../../util/ContentTypes'; import { readJsonStream } from '../../../../util/StreamUtil'; +import { BaseInteractionHandler } from '../../BaseInteractionHandler'; +import type { InteractionHandlerInput } from '../../InteractionHandler'; import { assertPassword } from '../EmailPasswordUtil'; import type { AccountStore } from '../storage/AccountStore'; -import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler'; -import { InteractionHandler } from './InteractionHandler'; + +const resetPasswordView = { + required: { + password: 'string', + confirmPassword: 'string', + recordId: 'string', + }, +} as const; /** - * Handles the submission of the ResetPassword form: - * this is the form that is linked in the reset password email. + * Resets a password if a valid `recordId` is provided, + * which should have been generated by a different handler. */ -export class ResetPasswordHandler extends InteractionHandler { +export class ResetPasswordHandler extends BaseInteractionHandler { protected readonly logger = getLoggerFor(this); private readonly accountStore: AccountStore; public constructor(accountStore: AccountStore) { - super(); + super(resetPasswordView); this.accountStore = accountStore; } - public async handle({ operation }: InteractionHandlerInput): Promise { - // Extract record ID from request URL - const recordId = /\/([^/]+)$/u.exec(operation.target.path)?.[1]; + public async handlePost({ operation }: InteractionHandlerInput): Promise { // Validate input data - const { password, confirmPassword } = await readJsonStream(operation.body.data); + const { password, confirmPassword, recordId } = await readJsonStream(operation.body.data); assert( typeof recordId === 'string' && recordId.length > 0, 'Invalid request. Open the link from your email again', @@ -32,7 +41,7 @@ export class ResetPasswordHandler extends InteractionHandler { assertPassword(password, confirmPassword); await this.resetPassword(recordId, password); - return { type: 'response' }; + return new BasicRepresentation(JSON.stringify({}), operation.target, APPLICATION_JSON); } /** diff --git a/src/identity/interaction/email-password/storage/BaseAccountStore.ts b/src/identity/interaction/email-password/storage/BaseAccountStore.ts index 4cfc04d0b..2d990f86e 100644 --- a/src/identity/interaction/email-password/storage/BaseAccountStore.ts +++ b/src/identity/interaction/email-password/storage/BaseAccountStore.ts @@ -1,6 +1,7 @@ import assert from 'assert'; import { hash, compare } from 'bcrypt'; import { v4 } from 'uuid'; +import type { ExpiringStorage } from '../../../../storage/keyvalue/ExpiringStorage'; import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage'; import type { AccountSettings, AccountStore } from './AccountStore'; @@ -26,15 +27,25 @@ export interface ForgotPasswordPayload { export type EmailPasswordData = AccountPayload | ForgotPasswordPayload | AccountSettings; /** - * A EmailPasswordStore that uses a KeyValueStorage - * to persist its information. + * A EmailPasswordStore that uses a KeyValueStorage to persist its information and an + * ExpiringStorage to persist ForgotPassword records. + * + * `forgotPasswordExpiration` parameter is how long the ForgotPassword record should be + * stored in minutes. *(defaults to 15 minutes)* */ export class BaseAccountStore implements AccountStore { private readonly storage: KeyValueStorage; + private readonly forgotPasswordStorage: ExpiringStorage; private readonly saltRounds: number; + private readonly forgotPasswordExpiration: number; - public constructor(storage: KeyValueStorage, saltRounds: number) { + public constructor(storage: KeyValueStorage, + forgotPasswordStorage: ExpiringStorage, + saltRounds: number, + forgotPasswordExpiration = 15) { this.storage = storage; + this.forgotPasswordStorage = forgotPasswordStorage; + this.forgotPasswordExpiration = forgotPasswordExpiration * 60 * 1000; this.saltRounds = saltRounds; } @@ -130,20 +141,21 @@ export class BaseAccountStore implements AccountStore { public async generateForgotPasswordRecord(email: string): Promise { const recordId = v4(); await this.getAccountPayload(email, true); - await this.storage.set( + await this.forgotPasswordStorage.set( this.getForgotPasswordRecordResourceIdentifier(recordId), { recordId, email }, + this.forgotPasswordExpiration, ); return recordId; } public async getForgotPasswordRecord(recordId: string): Promise { const identifier = this.getForgotPasswordRecordResourceIdentifier(recordId); - const forgotPasswordRecord = await this.storage.get(identifier) as ForgotPasswordPayload | undefined; + const forgotPasswordRecord = await this.forgotPasswordStorage.get(identifier) as ForgotPasswordPayload | undefined; return forgotPasswordRecord?.email; } public async deleteForgotPasswordRecord(recordId: string): Promise { - await this.storage.delete(this.getForgotPasswordRecordResourceIdentifier(recordId)); + await this.forgotPasswordStorage.delete(this.getForgotPasswordRecordResourceIdentifier(recordId)); } } diff --git a/src/identity/interaction/util/BaseEmailSender.ts b/src/identity/interaction/email-password/util/BaseEmailSender.ts similarity index 100% rename from src/identity/interaction/util/BaseEmailSender.ts rename to src/identity/interaction/email-password/util/BaseEmailSender.ts diff --git a/src/identity/interaction/util/EmailSender.ts b/src/identity/interaction/email-password/util/EmailSender.ts similarity index 75% rename from src/identity/interaction/util/EmailSender.ts rename to src/identity/interaction/email-password/util/EmailSender.ts index 30a9a748d..ac6198e54 100644 --- a/src/identity/interaction/util/EmailSender.ts +++ b/src/identity/interaction/email-password/util/EmailSender.ts @@ -1,4 +1,4 @@ -import { AsyncHandler } from '../../../util/handlers/AsyncHandler'; +import { AsyncHandler } from '../../../../util/handlers/AsyncHandler'; export interface EmailArgs { recipient: string; diff --git a/src/identity/interaction/routing/AbsolutePathInteractionRoute.ts b/src/identity/interaction/routing/AbsolutePathInteractionRoute.ts new file mode 100644 index 000000000..7a7e0d897 --- /dev/null +++ b/src/identity/interaction/routing/AbsolutePathInteractionRoute.ts @@ -0,0 +1,16 @@ +import type { InteractionRoute } from './InteractionRoute'; + +/** + * A route that returns the input string as path. + */ +export class AbsolutePathInteractionRoute implements InteractionRoute { + private readonly path: string; + + public constructor(path: string) { + this.path = path; + } + + public getPath(): string { + return this.path; + } +} diff --git a/src/identity/interaction/routing/BasicInteractionRoute.ts b/src/identity/interaction/routing/BasicInteractionRoute.ts deleted file mode 100644 index e0a555321..000000000 --- a/src/identity/interaction/routing/BasicInteractionRoute.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { Operation } from '../../../http/Operation'; -import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; -import { createErrorMessage, isError } from '../../../util/errors/ErrorUtil'; -import { InternalServerError } from '../../../util/errors/InternalServerError'; -import { trimTrailingSlashes } from '../../../util/PathUtil'; -import type { - InteractionHandler, - Interaction, -} from '../email-password/handler/InteractionHandler'; -import type { InteractionRoute, TemplatedInteractionResult } from './InteractionRoute'; - -/** - * Default implementation of the InteractionRoute. - * See function comments for specifics. - */ -export class BasicInteractionRoute implements InteractionRoute { - public readonly route: RegExp; - public readonly handler: InteractionHandler; - public readonly viewTemplates: Record; - public readonly prompt?: string; - public readonly responseTemplates: Record; - public readonly controls: Record; - - /** - * @param route - Regex to match this route. - * @param viewTemplates - Templates to render on GET requests. - * Keys are content-types, values paths to a template. - * @param handler - Handler to call on POST requests. - * @param prompt - In case of requests to the IDP entry point, the session prompt will be compared to this. - * @param responseTemplates - Templates to render as a response to POST requests when required. - * Keys are content-types, values paths to a template. - * @param controls - Controls to add to the response JSON. - * The keys will be copied and the values will be converted to full URLs. - */ - public constructor(route: string, - viewTemplates: Record, - handler: InteractionHandler, - prompt?: string, - responseTemplates: Record = {}, - controls: Record = {}) { - this.route = new RegExp(route, 'u'); - this.viewTemplates = viewTemplates; - this.handler = handler; - this.prompt = prompt; - this.responseTemplates = responseTemplates; - this.controls = controls; - } - - /** - * Returns the stored controls. - */ - public getControls(): Record { - return this.controls; - } - - /** - * Checks support by comparing the prompt if the path targets the base URL, - * and otherwise comparing with the stored route regular expression. - */ - public supportsPath(path: string, prompt?: string): boolean { - // In case the request targets the IDP entry point the prompt determines where to go - if (trimTrailingSlashes(path).length === 0 && prompt) { - return this.prompt === prompt; - } - return this.route.test(path); - } - - /** - * GET requests return a default response result. - * POST requests return the InteractionHandler result. - * InteractionHandler errors will be converted into response results. - * - * All results will be appended with the matching template paths. - * - * Will error for other methods - */ - public async handleOperation(operation: Operation, oidcInteraction?: Interaction): - Promise { - switch (operation.method) { - case 'GET': - return { type: 'response', templateFiles: this.viewTemplates }; - case 'POST': - try { - const result = await this.handler.handleSafe({ operation, oidcInteraction }); - return { ...result, templateFiles: this.responseTemplates }; - } catch (err: unknown) { - const error = isError(err) ? err : new InternalServerError(createErrorMessage(err)); - // Potentially render the error in the view - return { type: 'error', error, templateFiles: this.viewTemplates }; - } - default: - throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`); - } - } -} diff --git a/src/identity/interaction/routing/InteractionRoute.ts b/src/identity/interaction/routing/InteractionRoute.ts index 92e34bce7..3b0caccff 100644 --- a/src/identity/interaction/routing/InteractionRoute.ts +++ b/src/identity/interaction/routing/InteractionRoute.ts @@ -1,33 +1,9 @@ -import type { Operation } from '../../../http/Operation'; -import type { Interaction, InteractionHandlerResult } from '../email-password/handler/InteractionHandler'; - -export type TemplatedInteractionResult = T & { - templateFiles: Record; -}; - /** - * Handles the routing behaviour for IDP handlers. + * An object with a specific path. */ export interface InteractionRoute { /** - * Returns the control fields that should be added to response objects. - * Keys are control names, values are relative URL paths. + * @returns The absolute path of this route. */ - getControls: () => Record; - - /** - * If this route supports the given path. - * @param path - Relative URL path. - * @param prompt - Session prompt if there is one. - */ - supportsPath: (path: string, prompt?: string) => boolean; - - /** - * Handles the given operation. - * @param operation - Operation to handle. - * @param oidcInteraction - Interaction if there is one. - * - * @returns InteractionHandlerResult appended with relevant template files. - */ - handleOperation: (operation: Operation, oidcInteraction?: Interaction) => Promise; + getPath: () => string; } diff --git a/src/identity/interaction/routing/InteractionRouteHandler.ts b/src/identity/interaction/routing/InteractionRouteHandler.ts new file mode 100644 index 000000000..22573524d --- /dev/null +++ b/src/identity/interaction/routing/InteractionRouteHandler.ts @@ -0,0 +1,35 @@ +import type { Representation } from '../../../http/representation/Representation'; +import { NotFoundHttpError } from '../../../util/errors/NotFoundHttpError'; +import type { InteractionHandlerInput } from '../InteractionHandler'; +import { InteractionHandler } from '../InteractionHandler'; +import type { InteractionRoute } from './InteractionRoute'; + +/** + * InteractionHandler that only accepts operations with an expected path. + * + * Rejects operations that target a different path, + * otherwise the input parameters are passed to the source handler. + */ +export class InteractionRouteHandler extends InteractionHandler { + private readonly route: InteractionRoute; + private readonly source: InteractionHandler; + + public constructor(route: InteractionRoute, source: InteractionHandler) { + super(); + this.route = route; + this.source = source; + } + + public async canHandle(input: InteractionHandlerInput): Promise { + const { target } = input.operation; + const path = this.route.getPath(); + if (target.path !== path) { + throw new NotFoundHttpError(); + } + await this.source.canHandle(input); + } + + public async handle(input: InteractionHandlerInput): Promise { + return this.source.handle(input); + } +} diff --git a/src/identity/interaction/routing/RelativePathInteractionRoute.ts b/src/identity/interaction/routing/RelativePathInteractionRoute.ts new file mode 100644 index 000000000..70e7d0e93 --- /dev/null +++ b/src/identity/interaction/routing/RelativePathInteractionRoute.ts @@ -0,0 +1,16 @@ +import { joinUrl } from '../../../util/PathUtil'; +import { AbsolutePathInteractionRoute } from './AbsolutePathInteractionRoute'; +import type { InteractionRoute } from './InteractionRoute'; + +/** + * A route that is relative to another route. + * The relative path will be joined to the input base, + * which can either be an absolute URL or an InteractionRoute of which the path will be used. + */ +export class RelativePathInteractionRoute extends AbsolutePathInteractionRoute { + public constructor(base: InteractionRoute | string, relativePath: string) { + const url = typeof base === 'string' ? base : base.getPath(); + const path = joinUrl(url, relativePath); + super(path); + } +} diff --git a/src/identity/interaction/util/InteractionCompleter.ts b/src/identity/interaction/util/InteractionCompleter.ts deleted file mode 100644 index 37938d8d6..000000000 --- a/src/identity/interaction/util/InteractionCompleter.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ServerResponse } from 'http'; -import type { InteractionResults } from 'oidc-provider'; -import type { HttpRequest } from '../../../server/HttpRequest'; -import { AsyncHandler } from '../../../util/handlers/AsyncHandler'; -import type { ProviderFactory } from '../../configuration/ProviderFactory'; - -/** - * Parameters required to specify how the interaction should be completed. - */ -export interface InteractionCompleterParams { - webId: string; - shouldRemember?: boolean; -} - -export interface InteractionCompleterInput extends InteractionCompleterParams { - request: HttpRequest; -} - -/** - * Completes an IDP interaction, logging the user in. - * Returns the URL the request should be redirected to. - */ -export class InteractionCompleter extends AsyncHandler { - private readonly providerFactory: ProviderFactory; - - public constructor(providerFactory: ProviderFactory) { - super(); - this.providerFactory = providerFactory; - } - - public async handle(input: InteractionCompleterInput): Promise { - const provider = await this.providerFactory.getProvider(); - const result: InteractionResults = { - login: { - account: input.webId, - remember: input.shouldRemember, - ts: Math.floor(Date.now() / 1000), - }, - consent: { - rejectedScopes: input.shouldRemember ? [] : [ 'offline_access' ], - }, - }; - - // Response object is not actually needed here so we can just mock it like this - // to bypass the OIDC library checks. - // See https://github.com/panva/node-oidc-provider/discussions/1078 - return provider.interactionResult(input.request, Object.create(ServerResponse.prototype), result); - } -} diff --git a/src/identity/ownership/TokenOwnershipValidator.ts b/src/identity/ownership/TokenOwnershipValidator.ts index 4ba0ca501..3f0504d83 100644 --- a/src/identity/ownership/TokenOwnershipValidator.ts +++ b/src/identity/ownership/TokenOwnershipValidator.ts @@ -2,7 +2,6 @@ import type { Quad } from 'n3'; import { DataFactory } from 'n3'; import { v4 } from 'uuid'; import { getLoggerFor } from '../../logging/LogUtil'; -import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter'; import type { ExpiringStorage } from '../../storage/keyvalue/ExpiringStorage'; import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; import { fetchDataset } from '../../util/FetchUtil'; @@ -17,13 +16,11 @@ const { literal, namedNode, quad } = DataFactory; export class TokenOwnershipValidator extends OwnershipValidator { protected readonly logger = getLoggerFor(this); - private readonly converter: RepresentationConverter; private readonly storage: ExpiringStorage; private readonly expiration: number; - public constructor(converter: RepresentationConverter, storage: ExpiringStorage, expiration = 30) { + public constructor(storage: ExpiringStorage, expiration = 30) { super(); - this.converter = converter; this.storage = storage; // Convert minutes to milliseconds this.expiration = expiration * 60 * 1000; @@ -66,7 +63,7 @@ export class TokenOwnershipValidator extends OwnershipValidator { * Fetches data from the WebID to determine if the token is present. */ private async hasToken(webId: string, token: string): Promise { - const representation = await fetchDataset(webId, this.converter); + const representation = await fetchDataset(webId); const expectedQuad = quad(namedNode(webId), SOLID.terms.oidcIssuerRegistrationToken, literal(token)); for await (const data of representation.data) { const triple = data as Quad; diff --git a/src/identity/storage/WebIdAdapterFactory.ts b/src/identity/storage/WebIdAdapterFactory.ts index c29c334aa..734d0126e 100644 --- a/src/identity/storage/WebIdAdapterFactory.ts +++ b/src/identity/storage/WebIdAdapterFactory.ts @@ -5,7 +5,7 @@ import type { Adapter, AdapterPayload } from 'oidc-provider'; import { getLoggerFor } from '../../logging/LogUtil'; import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter'; import { createErrorMessage } from '../../util/errors/ErrorUtil'; -import { fetchDataset } from '../../util/FetchUtil'; +import { responseToDataset } from '../../util/FetchUtil'; import { OIDC } from '../../util/Vocabularies'; import type { AdapterFactory } from './AdapterFactory'; @@ -91,7 +91,7 @@ export class WebIdAdapter implements Adapter { * @param response - Response object from the request. */ private async parseRdfWebId(data: string, id: string, response: Response): Promise { - const representation = await fetchDataset(response, this.converter, data); + const representation = await responseToDataset(response, this.converter, data); // Find the valid redirect URIs const redirectUris: string[] = []; diff --git a/src/index.ts b/src/index.ts index 1dfdc182b..86f2cf068 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,13 +18,14 @@ export * from './authorization/access/AgentGroupAccessChecker'; export * from './authorization/permissions/Permissions'; export * from './authorization/permissions/ModesExtractor'; export * from './authorization/permissions/MethodModesExtractor'; -export * from './authorization/permissions/SparqlPatchModesExtractor'; +export * from './authorization/permissions/N3PatchModesExtractor'; +export * from './authorization/permissions/SparqlUpdateModesExtractor'; // Authorization -export * from './authorization/OwnerPermissionReader'; export * from './authorization/AllStaticReader'; export * from './authorization/Authorizer'; export * from './authorization/AuxiliaryReader'; +export * from './authorization/OwnerPermissionReader'; export * from './authorization/PathBasedReader'; export * from './authorization/PermissionBasedAuthorizer'; export * from './authorization/PermissionReader'; @@ -45,6 +46,7 @@ export * from './http/auxiliary/Validator'; // HTTP/Input/Body export * from './http/input/body/BodyParser'; +export * from './http/input/body/N3PatchBodyParser'; export * from './http/input/body/RawBodyParser'; export * from './http/input/body/SparqlUpdateBodyParser'; @@ -57,6 +59,7 @@ export * from './http/input/identifier/OriginalUrlExtractor'; export * from './http/input/identifier/TargetExtractor'; // HTTP/Input/Metadata +export * from './http/input/metadata/ContentLengthParser'; export * from './http/input/metadata/ContentTypeParser'; export * from './http/input/metadata/LinkRelParser'; export * from './http/input/metadata/MetadataParser'; @@ -86,6 +89,7 @@ export * from './http/ldp/PutOperationHandler'; // HTTP/Output/Error export * from './http/output/error/ConvertingErrorHandler'; export * from './http/output/error/ErrorHandler'; +export * from './http/output/error/RedirectingErrorHandler'; export * from './http/output/error/SafeErrorHandler'; // HTTP/Output/Metadata @@ -125,7 +129,6 @@ export * from './identity/configuration/IdentityProviderFactory'; export * from './identity/configuration/ProviderFactory'; // Identity/Interaction/Email-Password/Handler -export * from './identity/interaction/email-password/handler/InteractionHandler'; export * from './identity/interaction/email-password/handler/ForgotPasswordHandler'; export * from './identity/interaction/email-password/handler/LoginHandler'; export * from './identity/interaction/email-password/handler/RegistrationHandler'; @@ -136,22 +139,28 @@ export * from './identity/interaction/email-password/storage/AccountStore'; export * from './identity/interaction/email-password/storage/BaseAccountStore'; // Identity/Interaction/Email-Password/Util +export * from './identity/interaction/email-password/util/BaseEmailSender'; +export * from './identity/interaction/email-password/util/EmailSender'; export * from './identity/interaction/email-password/util/RegistrationManager'; // Identity/Interaction/Email-Password export * from './identity/interaction/email-password/EmailPasswordUtil'; // Identity/Interaction/Routing -export * from './identity/interaction/routing/BasicInteractionRoute'; +export * from './identity/interaction/routing/AbsolutePathInteractionRoute'; export * from './identity/interaction/routing/InteractionRoute'; - -// Identity/Interaction/Util -export * from './identity/interaction/util/BaseEmailSender'; -export * from './identity/interaction/util/EmailSender'; -export * from './identity/interaction/util/InteractionCompleter'; +export * from './identity/interaction/routing/InteractionRouteHandler'; +export * from './identity/interaction/routing/RelativePathInteractionRoute'; // Identity/Interaction -export * from './identity/interaction/SessionHttpHandler'; +export * from './identity/interaction/BaseInteractionHandler'; +export * from './identity/interaction/ConsentHandler'; +export * from './identity/interaction/ControlHandler'; +export * from './identity/interaction/FixedInteractionHandler'; +export * from './identity/interaction/HtmlViewHandler'; +export * from './identity/interaction/InteractionHandler'; +export * from './identity/interaction/LocationInteractionHandler'; +export * from './identity/interaction/PromptHandler'; // Identity/Ownership export * from './identity/ownership/NoCheckOwnershipValidator'; @@ -165,22 +174,41 @@ export * from './identity/storage/WebIdAdapterFactory'; // Identity export * from './identity/IdentityProviderHttpHandler'; +export * from './identity/OidcHttpHandler'; // Init/Final export * from './init/final/Finalizable'; export * from './init/final/ParallelFinalizer'; // Init/Setup +export * from './init/setup/SetupHandler'; export * from './init/setup/SetupHttpHandler'; +// Init/Cli +export * from './init/cli/CliExtractor'; +export * from './init/cli/YargsCliExtractor'; + +// Init/Variables/Extractors +export * from './init/variables/extractors/KeyExtractor'; +export * from './init/variables/extractors/AssetPathExtractor'; +export * from './init/variables/extractors/BaseUrlExtractor'; +export * from './init/variables/extractors/SettingsExtractor'; + +// Init/Variables +export * from './init/variables/CombinedSettingsResolver'; +export * from './init/variables/SettingsResolver'; + // Init export * from './init/App'; export * from './init/AppRunner'; +export * from './init/BaseUrlVerifier'; +export * from './init/CliResolver'; export * from './init/ConfigPodInitializer'; export * from './init/ContainerInitializer'; export * from './init/Initializer'; export * from './init/LoggerInitializer'; export * from './init/ServerInitializer'; +export * from './init/ModuleVersionVerifier'; // Logging export * from './logging/LazyLogger'; @@ -244,12 +272,17 @@ export * from './server/util/RedirectAllHttpHandler'; export * from './server/util/RouterHandler'; // Storage/Accessors +export * from './storage/accessors/AtomicDataAccessor'; +export * from './storage/accessors/AtomicFileDataAccessor'; export * from './storage/accessors/DataAccessor'; export * from './storage/accessors/FileDataAccessor'; export * from './storage/accessors/InMemoryDataAccessor'; +export * from './storage/accessors/PassthroughDataAccessor'; export * from './storage/accessors/SparqlDataAccessor'; +export * from './storage/accessors/ValidatingDataAccessor'; // Storage/Conversion +export * from './storage/conversion/BaseTypedRepresentationConverter'; export * from './storage/conversion/ChainedConverter'; export * from './storage/conversion/ConstantConverter'; export * from './storage/conversion/ContainerToTemplateConverter'; @@ -260,7 +293,6 @@ export * from './storage/conversion/ErrorToJsonConverter'; export * from './storage/conversion/ErrorToQuadConverter'; export * from './storage/conversion/ErrorToTemplateConverter'; export * from './storage/conversion/FormToJsonConverter'; -export * from './storage/conversion/IfNeededConverter'; export * from './storage/conversion/MarkdownToHtmlConverter'; export * from './storage/conversion/PassthroughConverter'; export * from './storage/conversion/QuadToRdfConverter'; @@ -286,11 +318,17 @@ export * from './storage/mapping/SubdomainExtensionBasedMapper'; // Storage/Patch export * from './storage/patch/ContainerPatcher'; export * from './storage/patch/ConvertingPatcher'; +export * from './storage/patch/N3Patcher'; export * from './storage/patch/PatchHandler'; export * from './storage/patch/RepresentationPatcher'; export * from './storage/patch/RepresentationPatchHandler'; export * from './storage/patch/SparqlUpdatePatcher'; +// Storage/Quota +export * from './storage/quota/GlobalQuotaStrategy'; +export * from './storage/quota/PodQuotaStrategy'; +export * from './storage/quota/QuotaStrategy'; + // Storage/Routing export * from './storage/routing/BaseUrlRouterRule'; export * from './storage/routing/ConvertingRouterRule'; @@ -298,6 +336,14 @@ export * from './storage/routing/PreferenceSupport'; export * from './storage/routing/RegexRouterRule'; export * from './storage/routing/RouterRule'; +// Storage/Size-Reporter +export * from './storage/size-reporter/FileSizeReporter'; +export * from './storage/size-reporter/Size'; +export * from './storage/size-reporter/SizeReporter'; + +// Storage/Validators +export * from './storage/validators/QuotaValidator'; + // Storage export * from './storage/AtomicResourceStore'; export * from './storage/BaseResourceStore'; @@ -319,13 +365,16 @@ export * from './util/errors/BadRequestHttpError'; export * from './util/errors/ConflictHttpError'; export * from './util/errors/ErrorUtil'; export * from './util/errors/ForbiddenHttpError'; +export * from './util/errors/FoundHttpError'; export * from './util/errors/HttpError'; export * from './util/errors/HttpErrorUtil'; export * from './util/errors/InternalServerError'; export * from './util/errors/MethodNotAllowedHttpError'; +export * from './util/errors/MovedPermanentlyHttpError'; export * from './util/errors/NotFoundHttpError'; export * from './util/errors/NotImplementedHttpError'; export * from './util/errors/PreconditionFailedHttpError'; +export * from './util/errors/RedirectHttpError'; export * from './util/errors/SystemError'; export * from './util/errors/UnauthorizedHttpError'; export * from './util/errors/UnsupportedMediaTypeHttpError'; @@ -334,9 +383,12 @@ export * from './util/errors/UnsupportedMediaTypeHttpError'; export * from './util/handlers/AsyncHandler'; export * from './util/handlers/BooleanHandler'; export * from './util/handlers/ConditionalHandler'; +export * from './util/handlers/HandlerUtil'; +export * from './util/handlers/MethodFilterHandler'; export * from './util/handlers/ParallelHandler'; export * from './util/handlers/SequenceHandler'; export * from './util/handlers/StaticHandler'; +export * from './util/handlers/StaticThrowHandler'; export * from './util/handlers/UnionHandler'; export * from './util/handlers/UnsupportedAsyncHandler'; export * from './util/handlers/WaterfallHandler'; @@ -356,6 +408,7 @@ export * from './util/locking/RedisResourceLocker'; export * from './util/locking/ResourceLocker'; export * from './util/locking/SingleThreadedResourceLocker'; export * from './util/locking/WrappedExpiringReadWriteLocker'; +export * from './util/locking/VoidLocker'; // Util/Templates export * from './util/templates/ChainedTemplateEngine'; diff --git a/src/init/AppRunner.ts b/src/init/AppRunner.ts index 87bda754e..5e2d77677 100644 --- a/src/init/AppRunner.ts +++ b/src/init/AppRunner.ts @@ -1,152 +1,199 @@ /* eslint-disable unicorn/no-process-exit */ - -import type { ReadStream, WriteStream } from 'tty'; -import type { IComponentsManagerBuilderOptions, LogLevel } from 'componentsjs'; +import type { WriteStream } from 'tty'; +import type { IComponentsManagerBuilderOptions } from 'componentsjs'; import { ComponentsManager } from 'componentsjs'; import yargs from 'yargs'; +import { LOG_LEVELS } from '../logging/LogLevel'; import { getLoggerFor } from '../logging/LogUtil'; -import { ensureTrailingSlash, resolveAssetPath, modulePathPlaceholder } from '../util/PathUtil'; +import { createErrorMessage, isError } from '../util/errors/ErrorUtil'; +import { resolveModulePath, resolveAssetPath } from '../util/PathUtil'; import type { App } from './App'; +import type { CliResolver } from './CliResolver'; +import type { CliArgv, VariableBindings } from './variables/Types'; -const defaultConfig = `${modulePathPlaceholder}config/default.json`; +const DEFAULT_CONFIG = resolveModulePath('config/default.json'); -export interface CliParams { - loggingLevel: string; - port: number; - baseUrl?: string; - rootFilePath?: string; - sparqlEndpoint?: string; - showStackTrace?: boolean; - podConfigJson?: string; -} +const DEFAULT_CLI_RESOLVER = 'urn:solid-server-app-setup:default:CliResolver'; +const DEFAULT_APP = 'urn:solid-server:default:App'; +const CORE_CLI_PARAMETERS = { + config: { type: 'string', alias: 'c', default: DEFAULT_CONFIG, requiresArg: true }, + loggingLevel: { type: 'string', alias: 'l', default: 'info', requiresArg: true, choices: LOG_LEVELS }, + mainModulePath: { type: 'string', alias: 'm', requiresArg: true }, +} as const; + +/** + * A class that can be used to instantiate and start a server based on a Component.js configuration. + */ export class AppRunner { private readonly logger = getLoggerFor(this); /** * Starts the server with a given config. * This method can be used to start the server from within another JavaScript application. + * Keys of the `variableBindings` object should be Components.js variables. + * E.g.: `{ 'urn:solid-server:default:variable:rootFilePath': '.data' }`. + * * @param loaderProperties - Components.js loader properties. * @param configFile - Path to the server config file. - * @param variableParams - Variables to pass into the config file. + * @param variableBindings - Parameters to pass into the VariableResolver. */ public async run( loaderProperties: IComponentsManagerBuilderOptions, configFile: string, - variableParams: CliParams, + variableBindings: VariableBindings, ): Promise { - const app = await this.createApp(loaderProperties, configFile, variableParams); + const app = await this.create(loaderProperties, configFile, variableBindings); await app.start(); } /** - * Starts the server as a command-line application. - * Made non-async to lower the risk of unhandled promise rejections. - * @param args - Command line arguments. - * @param stderr - Standard error stream. - */ - public runCli({ - argv = process.argv, - stderr = process.stderr, - }: { - argv?: string[]; - stdin?: ReadStream; - stdout?: WriteStream; - stderr?: WriteStream; - } = {}): void { - // Parse the command-line arguments - // eslint-disable-next-line no-sync - const params = yargs(argv.slice(2)) - .strict() - .usage('node ./bin/server.js [args]') - .check((args): boolean => { - if (args._.length > 0) { - throw new Error(`Unsupported positional arguments: "${args._.join('", "')}"`); - } - for (const key of Object.keys(args)) { - // We have no options that allow for arrays - const val = args[key]; - if (key !== '_' && Array.isArray(val)) { - throw new Error(`Multiple values were provided for: "${key}": "${val.join('", "')}"`); - } - } - return true; - }) - .options({ - baseUrl: { type: 'string', alias: 'b', requiresArg: true }, - config: { type: 'string', alias: 'c', default: defaultConfig, requiresArg: true }, - loggingLevel: { type: 'string', alias: 'l', default: 'info', requiresArg: true }, - mainModulePath: { type: 'string', alias: 'm', requiresArg: true }, - port: { type: 'number', alias: 'p', default: 3000, requiresArg: true }, - rootFilePath: { type: 'string', alias: 'f', default: './', requiresArg: true }, - showStackTrace: { type: 'boolean', alias: 't', default: false }, - sparqlEndpoint: { type: 'string', alias: 's', requiresArg: true }, - podConfigJson: { type: 'string', default: './pod-config.json', requiresArg: true }, - }) - .parseSync(); - - // Gather settings for instantiating the server - const loaderProperties: IComponentsManagerBuilderOptions = { - mainModulePath: resolveAssetPath(params.mainModulePath), - dumpErrorState: true, - logLevel: params.loggingLevel as LogLevel, - }; - const configFile = resolveAssetPath(params.config); - - // Create and execute the app - this.createApp(loaderProperties, configFile, params) - .then( - async(app): Promise => app.start(), - (error: Error): void => { - // Instantiation of components has failed, so there is no logger to use - stderr.write(`Error: could not instantiate server from ${configFile}\n`); - stderr.write(`${error.stack}\n`); - process.exit(1); - }, - ).catch((error): void => { - this.logger.error(`Could not start server: ${error}`, { error }); - process.exit(1); - }); - } - - /** - * Creates the main app object to start the server from a given config. + * Returns an App object, created with the given config, that can start and stop the Solid server. + * Keys of the `variableBindings` object should be Components.js variables. + * E.g.: `{ 'urn:solid-server:default:variable:rootFilePath': '.data' }`. + * * @param loaderProperties - Components.js loader properties. - * @param configFile - Path to a Components.js config file. - * @param variables - Variables to pass into the config file. + * @param configFile - Path to the server config file. + * @param variableBindings - Bindings of Components.js variables. */ - public async createApp( + public async create( loaderProperties: IComponentsManagerBuilderOptions, configFile: string, - variables: CliParams | Record, + variableBindings: VariableBindings, ): Promise { - // Translate command-line parameters if needed - if (typeof variables.loggingLevel === 'string') { - variables = this.createVariables(variables as CliParams); - } + // Create a resolver to translate (non-core) CLI parameters into values for variables + const componentsManager = await this.createComponentsManager(loaderProperties, configFile); - // Set up Components.js - const componentsManager = await ComponentsManager.build(loaderProperties); - await componentsManager.configRegistry.register(configFile); - - // Create the app - const app = 'urn:solid-server:default:App'; - return await componentsManager.instantiate(app, { variables }); + // Create the application using the translated variable values + return componentsManager.instantiate(DEFAULT_APP, { variables: variableBindings }); } /** - * Translates command-line parameters into Components.js variables. + * Starts the server as a command-line application. + * Will exit the process on failure. + * + * Made non-async to lower the risk of unhandled promise rejections. + * This is only relevant when this is used to start as a Node.js application on its own, + * if you use this as part of your code you probably want to use the async version. + * + * @param argv - Command line arguments. + * @param stderr - Stream that should be used to output errors before the logger is enabled. */ - protected createVariables(params: CliParams): Record { - return { - 'urn:solid-server:default:variable:baseUrl': - params.baseUrl ? ensureTrailingSlash(params.baseUrl) : `http://localhost:${params.port}/`, - 'urn:solid-server:default:variable:loggingLevel': params.loggingLevel, - 'urn:solid-server:default:variable:port': params.port, - 'urn:solid-server:default:variable:rootFilePath': resolveAssetPath(params.rootFilePath), - 'urn:solid-server:default:variable:sparqlEndpoint': params.sparqlEndpoint, - 'urn:solid-server:default:variable:showStackTrace': params.showStackTrace, - 'urn:solid-server:default:variable:podConfigJson': resolveAssetPath(params.podConfigJson), + public runCliSync({ argv, stderr = process.stderr }: { argv?: CliArgv; stderr?: WriteStream }): void { + this.runCli(argv).catch((error): never => { + stderr.write(createErrorMessage(error)); + process.exit(1); + }); + } + + /** + * Starts the server as a command-line application. + * @param argv - Command line arguments. + */ + public async runCli(argv?: CliArgv): Promise { + const app = await this.createCli(argv); + try { + await app.start(); + } catch (error: unknown) { + this.logger.error(`Could not start the server: ${createErrorMessage(error)}`); + this.resolveError('Could not start the server', error); + } + } + + /** + * Returns an App object, created by parsing the Command line arguments, that can start and stop the Solid server. + * Will exit the process on failure. + * + * @param argv - Command line arguments. + */ + public async createCli(argv: CliArgv = process.argv): Promise { + // Parse only the core CLI arguments needed to load the configuration + const yargv = yargs(argv.slice(2)) + .usage('node ./bin/server.js [args]') + .options(CORE_CLI_PARAMETERS) + // We disable help here as it would only show the core parameters + .help(false); + + const params = await yargv.parse(); + + const loaderProperties = { + mainModulePath: resolveAssetPath(params.mainModulePath), + dumpErrorState: true, + logLevel: params.loggingLevel, }; + + const config = resolveAssetPath(params.config); + + // Create the Components.js manager used to build components from the provided config + let componentsManager: ComponentsManager; + try { + componentsManager = await this.createComponentsManager(loaderProperties, config); + } catch (error: unknown) { + // Print help of the expected core CLI parameters + const help = await yargv.getHelp(); + this.resolveError(`${help}\n\nCould not build the config files from ${config}`, error); + } + + // Build the CLI components and use them to generate values for the Components.js variables + const variables = await this.resolveVariables(componentsManager, argv); + + // Build and start the actual server application using the generated variable values + return await this.createApp(componentsManager, variables); + } + + /** + * Creates the Components Manager that will be used for instantiating. + */ + public async createComponentsManager( + loaderProperties: IComponentsManagerBuilderOptions, + configFile: string, + ): Promise> { + const componentsManager = await ComponentsManager.build(loaderProperties); + await componentsManager.configRegistry.register(configFile); + return componentsManager; + } + + /** + * Handles the first Components.js instantiation, + * where CLI settings and variable mappings are created. + */ + private async resolveVariables(componentsManager: ComponentsManager, argv: string[]): + Promise { + try { + // Create a CliResolver, which combines a CliExtractor and a VariableResolver + const resolver = await componentsManager.instantiate(DEFAULT_CLI_RESOLVER, {}); + // Convert CLI args to CLI bindings + const cliValues = await resolver.cliExtractor.handleSafe(argv); + // Convert CLI bindings into variable bindings + return await resolver.settingsResolver.handleSafe(cliValues); + } catch (error: unknown) { + this.resolveError(`Could not load the config variables`, error); + } + } + + /** + * The second Components.js instantiation, + * where the App is created and started using the variable mappings. + */ + private async createApp(componentsManager: ComponentsManager, variables: Record): Promise { + try { + // Create the app + return await componentsManager.instantiate(DEFAULT_APP, { variables }); + } catch (error: unknown) { + this.resolveError(`Could not create the server`, error); + } + } + + /** + * Throws a new error that provides additional information through the extra message. + * Also appends the stack trace to the message. + * This is needed for errors that are thrown before the logger is created as we can't log those the standard way. + */ + private resolveError(message: string, error: unknown): never { + let errorMessage = `${message}\nCause: ${createErrorMessage(error)}\n`; + if (isError(error)) { + errorMessage += `${error.stack}\n`; + } + throw new Error(errorMessage); } } diff --git a/src/init/BaseUrlVerifier.ts b/src/init/BaseUrlVerifier.ts new file mode 100644 index 000000000..4b2b39b4d --- /dev/null +++ b/src/init/BaseUrlVerifier.ts @@ -0,0 +1,32 @@ +import { getLoggerFor } from '../logging/LogUtil'; +import type { KeyValueStorage } from '../storage/keyvalue/KeyValueStorage'; +import { Initializer } from './Initializer'; + +/** + * Stores the `baseUrl` value that was used to start the server + * and warns the user in case it differs from the previous one. + */ +export class BaseUrlVerifier extends Initializer { + private readonly baseUrl: string; + private readonly storageKey: string; + private readonly storage: KeyValueStorage; + + private readonly logger = getLoggerFor(this); + + public constructor(baseUrl: string, storageKey: string, storage: KeyValueStorage) { + super(); + this.baseUrl = baseUrl; + this.storageKey = storageKey; + this.storage = storage; + } + + public async handle(): Promise { + const previousValue = await this.storage.get(this.storageKey); + if (previousValue && this.baseUrl !== previousValue) { + this.logger.warn(`The server is being started with a base URL of ${this.baseUrl + } while it was previously started with ${previousValue + }. Resources generated with the previous server instance, such as a WebID, might no longer work correctly.`); + } + await this.storage.set(this.storageKey, this.baseUrl); + } +} diff --git a/src/init/CliResolver.ts b/src/init/CliResolver.ts new file mode 100644 index 000000000..3ecf1880e --- /dev/null +++ b/src/init/CliResolver.ts @@ -0,0 +1,16 @@ +import type { CliExtractor } from './cli/CliExtractor'; +import type { SettingsResolver } from './variables/SettingsResolver'; + +/** + * A class that combines a {@link CliExtractor} and a {@link SettingsResolver}. + * Mainly exists so both such classes can be generated in a single Components.js instance. + */ +export class CliResolver { + public readonly cliExtractor: CliExtractor; + public readonly settingsResolver: SettingsResolver; + + public constructor(cliExtractor: CliExtractor, settingsResolver: SettingsResolver) { + this.cliExtractor = cliExtractor; + this.settingsResolver = settingsResolver; + } +} diff --git a/src/init/ModuleVersionVerifier.ts b/src/init/ModuleVersionVerifier.ts new file mode 100644 index 000000000..dd17922de --- /dev/null +++ b/src/init/ModuleVersionVerifier.ts @@ -0,0 +1,28 @@ +import { readJson } from 'fs-extra'; +import type { KeyValueStorage } from '../storage/keyvalue/KeyValueStorage'; +import { resolveModulePath } from '../util/PathUtil'; +import { Initializer } from './Initializer'; + +const PACKAGE_JSON_PATH = resolveModulePath('package.json'); + +/** + * This initializer simply writes the version number of the server to the storage. + * This will be relevant in the future when we look into migration initializers. + * + * It automatically parses the version number from the `package.json`. + */ +export class ModuleVersionVerifier extends Initializer { + private readonly storageKey: string; + private readonly storage: KeyValueStorage; + + public constructor(storageKey: string, storage: KeyValueStorage) { + super(); + this.storageKey = storageKey; + this.storage = storage; + } + + public async handle(): Promise { + const pkg = await readJson(PACKAGE_JSON_PATH); + await this.storage.set(this.storageKey, pkg.version); + } +} diff --git a/src/init/cli/CliExtractor.ts b/src/init/cli/CliExtractor.ts new file mode 100644 index 000000000..b11a6b96d --- /dev/null +++ b/src/init/cli/CliExtractor.ts @@ -0,0 +1,18 @@ +import { AsyncHandler } from '../../util/handlers/AsyncHandler'; +import type { CliArgv, Settings } from '../variables/Types'; + +/** + * Converts the input CLI arguments into an easily parseable key/value object. + * + * Due to how the application is built, there are certain CLI parameters + * that need to be parsed before this class can be instantiated. + * These can be ignored by this class as they will have been handled before it is called, + * but that does mean that this class should not error if they are present, + * e.g., by being strict throwing an error on these unexpected parameters. + * + * The following core CLI parameters are mandatory: + * - -c / \--config + * - -m / \--mainModulePath + * - -l / \--loggingLevel + */ +export abstract class CliExtractor extends AsyncHandler {} diff --git a/src/init/cli/YargsCliExtractor.ts b/src/init/cli/YargsCliExtractor.ts new file mode 100644 index 000000000..2330174e2 --- /dev/null +++ b/src/init/cli/YargsCliExtractor.ts @@ -0,0 +1,61 @@ +/* eslint-disable tsdoc/syntax */ +import type { Arguments, Argv, Options } from 'yargs'; +import yargs from 'yargs'; +import { CliExtractor } from './CliExtractor'; + +export type YargsArgOptions = Record; + +export interface CliOptions { + // Usage string printed in case of CLI errors + usage?: string; + // Errors on unknown CLI parameters when enabled. + // @see https://yargs.js.org/docs/#api-reference-strictenabledtrue + strictMode?: boolean; + // Loads CLI args from environment variables when enabled. + // @see http://yargs.js.org/docs/#api-reference-envprefix + loadFromEnv?: boolean; + // Prefix to be used when `loadFromEnv` is enabled. + // @see http://yargs.js.org/docs/#api-reference-envprefix + envVarPrefix?: string; +} + +/** + * Parses CLI args using the yargs library. + * Specific settings can be enabled through the provided options. + */ +export class YargsCliExtractor extends CliExtractor { + protected readonly yargsArgOptions: YargsArgOptions; + protected readonly yargvOptions: CliOptions; + + /** + * @param parameters - Parameters that should be parsed from the CLI. @range {json} + * Format details can be found at https://yargs.js.org/docs/#api-reference-optionskey-opt + * @param options - Additional options to configure yargs. @range {json} + */ + public constructor(parameters: YargsArgOptions = {}, options: CliOptions = {}) { + super(); + this.yargsArgOptions = parameters; + this.yargvOptions = options; + } + + public async handle(argv: readonly string[]): Promise { + return this.createYArgv(argv).parse(); + } + + /** + * Creates the yargs Argv object based on the input CLI argv. + */ + private createYArgv(argv: readonly string[]): Argv { + let yArgv = yargs(argv.slice(2)); + if (this.yargvOptions.usage !== undefined) { + yArgv = yArgv.usage(this.yargvOptions.usage); + } + if (this.yargvOptions.strictMode) { + yArgv = yArgv.strict(); + } + if (this.yargvOptions.loadFromEnv) { + yArgv = yArgv.env(this.yargvOptions.envVarPrefix ?? ''); + } + return yArgv.options(this.yargsArgOptions); + } +} diff --git a/src/init/setup/SetupHandler.ts b/src/init/setup/SetupHandler.ts new file mode 100644 index 000000000..9d7f798f4 --- /dev/null +++ b/src/init/setup/SetupHandler.ts @@ -0,0 +1,83 @@ +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { BaseInteractionHandler } from '../../identity/interaction/BaseInteractionHandler'; +import type { RegistrationManager } from '../../identity/interaction/email-password/util/RegistrationManager'; +import type { InteractionHandlerInput } from '../../identity/interaction/InteractionHandler'; +import { getLoggerFor } from '../../logging/LogUtil'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { readJsonStream } from '../../util/StreamUtil'; +import type { Initializer } from '../Initializer'; + +export interface SetupHandlerArgs { + /** + * Used for registering a pod during setup. + */ + registrationManager?: RegistrationManager; + /** + * Initializer to call in case no registration procedure needs to happen. + * This Initializer should make sure the necessary resources are there so the server can work correctly. + */ + initializer?: Initializer; +} + +/** + * On POST requests, runs an initializer and/or performs a registration step, both optional. + */ +export class SetupHandler extends BaseInteractionHandler { + protected readonly logger = getLoggerFor(this); + + private readonly registrationManager?: RegistrationManager; + private readonly initializer?: Initializer; + + public constructor(args: SetupHandlerArgs) { + super({}); + this.registrationManager = args.registrationManager; + this.initializer = args.initializer; + } + + protected async handlePost({ operation }: InteractionHandlerInput): Promise { + const json = operation.body.isEmpty ? {} : await readJsonStream(operation.body.data); + + const output: Record = { initialize: false, registration: false }; + if (json.registration) { + Object.assign(output, await this.register(json)); + output.registration = true; + } else if (json.initialize) { + // We only want to initialize if no registration happened + await this.initialize(); + output.initialize = true; + } + + this.logger.debug(`Output: ${JSON.stringify(output)}`); + + return new BasicRepresentation(JSON.stringify(output), APPLICATION_JSON); + } + + /** + * Call the initializer. + * Errors if no initializer was defined. + */ + private async initialize(): Promise { + if (!this.initializer) { + throw new NotImplementedHttpError('This server is not configured with a setup initializer.'); + } + await this.initializer.handleSafe(); + } + + /** + * Register a user based on the given input. + * Errors if no registration manager is defined. + */ + private async register(json: NodeJS.Dict): Promise> { + if (!this.registrationManager) { + throw new NotImplementedHttpError('This server is not configured to support registration during setup.'); + } + // Validate the input JSON + const validated = this.registrationManager.validateInput(json, true); + this.logger.debug(`Validated input: ${JSON.stringify(validated)}`); + + // Register and/or create a pod as requested. Potentially does nothing if all booleans are false. + return this.registrationManager.register(validated, true); + } +} diff --git a/src/init/setup/SetupHttpHandler.ts b/src/init/setup/SetupHttpHandler.ts index ffb3a1c79..ebc7730d9 100644 --- a/src/init/setup/SetupHttpHandler.ts +++ b/src/init/setup/SetupHttpHandler.ts @@ -1,55 +1,26 @@ import type { Operation } from '../../http/Operation'; -import type { ErrorHandler } from '../../http/output/error/ErrorHandler'; -import { ResponseDescription } from '../../http/output/response/ResponseDescription'; +import { OkResponseDescription } from '../../http/output/response/OkResponseDescription'; +import type { ResponseDescription } from '../../http/output/response/ResponseDescription'; import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; -import type { RegistrationParams, - RegistrationManager } from '../../identity/interaction/email-password/util/RegistrationManager'; +import type { InteractionHandler } from '../../identity/interaction/InteractionHandler'; import { getLoggerFor } from '../../logging/LogUtil'; import type { OperationHttpHandlerInput } from '../../server/OperationHttpHandler'; import { OperationHttpHandler } from '../../server/OperationHttpHandler'; import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter'; import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage'; import { APPLICATION_JSON, TEXT_HTML } from '../../util/ContentTypes'; -import { createErrorMessage } from '../../util/errors/ErrorUtil'; -import { HttpError } from '../../util/errors/HttpError'; -import { InternalServerError } from '../../util/errors/InternalServerError'; import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError'; -import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; -import { addTemplateMetadata } from '../../util/ResourceUtil'; -import { readJsonStream } from '../../util/StreamUtil'; -import type { Initializer } from '../Initializer'; - -/** - * Input parameters expected in calls to the handler. - * Will be sent to the RegistrationManager for validation and registration. - * The reason this is a flat object and does not have a specific field for all the registration parameters - * is so we can also support form data. - */ -export interface SetupInput extends Record{ - /** - * Indicates if the initializer should be executed. Ignored if `registration` is true. - */ - initialize?: boolean; - /** - * Indicates if the registration procedure should be done for IDP registration and/or pod provisioning. - */ - registration?: boolean; -} +import type { TemplateEngine } from '../../util/templates/TemplateEngine'; export interface SetupHttpHandlerArgs { /** - * Used for registering a pod during setup. - */ - registrationManager?: RegistrationManager; - /** - * Initializer to call in case no registration procedure needs to happen. - * This Initializer should make sure the necessary resources are there so the server can work correctly. - */ - initializer?: Initializer; - /** - * Used for content negotiation. + * Used for converting the input data. */ converter: RepresentationConverter; + /** + * Handles the requests. + */ + handler: InteractionHandler; /** * Key that is used to store the boolean in the storage indicating setup is finished. */ @@ -59,17 +30,9 @@ export interface SetupHttpHandlerArgs { */ storage: KeyValueStorage; /** - * Template to use for GET requests. + * Renders the main view. */ - viewTemplate: string; - /** - * Template to show when setup was completed successfully. - */ - responseTemplate: string; - /** - * Used for converting output errors. - */ - errorHandler: ErrorHandler; + templateEngine: TemplateEngine; } /** @@ -78,128 +41,68 @@ export interface SetupHttpHandlerArgs { * this to prevent accidentally running unsafe servers. * * GET requests will return the view template which should contain the setup information for the user. - * POST requests will run an initializer and/or perform a registration step, both optional. + * POST requests will be sent to the InteractionHandler. * After successfully completing a POST request this handler will disable itself and become unreachable. * All other methods will be rejected. */ export class SetupHttpHandler extends OperationHttpHandler { protected readonly logger = getLoggerFor(this); - private readonly registrationManager?: RegistrationManager; - private readonly initializer?: Initializer; + private readonly handler: InteractionHandler; private readonly converter: RepresentationConverter; private readonly storageKey: string; private readonly storage: KeyValueStorage; - private readonly viewTemplate: string; - private readonly responseTemplate: string; - private readonly errorHandler: ErrorHandler; - - private finished: boolean; + private readonly templateEngine: TemplateEngine; public constructor(args: SetupHttpHandlerArgs) { super(); - this.finished = false; - this.registrationManager = args.registrationManager; - this.initializer = args.initializer; + this.handler = args.handler; this.converter = args.converter; this.storageKey = args.storageKey; this.storage = args.storage; - this.viewTemplate = args.viewTemplate; - this.responseTemplate = args.responseTemplate; - this.errorHandler = args.errorHandler; + this.templateEngine = args.templateEngine; } public async handle({ operation }: OperationHttpHandlerInput): Promise { - let json: Record; - let template: string; - let success = false; - let statusCode = 200; - try { - ({ json, template } = await this.getJsonResult(operation)); - success = true; - } catch (err: unknown) { - // We want to show the errors on the original page in case of HTML interactions, so we can't just throw them here - const error = HttpError.isInstance(err) ? err : new InternalServerError(createErrorMessage(err)); - ({ statusCode } = error); - this.logger.warn(error.message); - const response = await this.errorHandler.handleSafe({ error, preferences: { type: { [APPLICATION_JSON]: 1 }}}); - json = await readJsonStream(response.data!); - template = this.viewTemplate; + switch (operation.method) { + case 'GET': return this.handleGet(operation); + case 'POST': return this.handlePost(operation); + default: throw new MethodNotAllowedHttpError(); } - - // Convert the response JSON to the required format - const representation = new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON); - addTemplateMetadata(representation.metadata, template, TEXT_HTML); - const result = await this.converter.handleSafe( - { representation, identifier: operation.target, preferences: operation.preferences }, - ); - - // Make sure this setup handler is never used again after a successful POST request - if (success && operation.method === 'POST') { - this.finished = true; - await this.storage.set(this.storageKey, true); - } - - return new ResponseDescription(statusCode, result.metadata, result.data); } /** - * Creates a JSON object representing the result of executing the given operation, - * together with the template it should be applied to. + * Returns the HTML representation of the setup page. */ - private async getJsonResult(operation: Operation): Promise<{ json: Record; template: string }> { - if (operation.method === 'GET') { - // Return the initial setup page - return { json: {}, template: this.viewTemplate }; - } - if (operation.method !== 'POST') { - throw new MethodNotAllowedHttpError(); - } + private async handleGet(operation: Operation): Promise { + const result = await this.templateEngine.render({}); + const representation = new BasicRepresentation(result, operation.target, TEXT_HTML); + return new OkResponseDescription(representation.metadata, representation.data); + } - // Registration manager expects JSON data - let json: SetupInput = {}; - if (!operation.body.isEmpty) { + /** + * Converts the input data to JSON and calls the setup handler. + * On success `true` will be written to the storage key. + */ + private async handlePost(operation: Operation): Promise { + // Convert input data to JSON + // Allows us to still support form data + if (operation.body.metadata.contentType) { const args = { representation: operation.body, preferences: { type: { [APPLICATION_JSON]: 1 }}, identifier: operation.target, }; - const converted = await this.converter.handleSafe(args); - json = await readJsonStream(converted.data); - this.logger.debug(`Input JSON: ${JSON.stringify(json)}`); + operation = { + ...operation, + body: await this.converter.handleSafe(args), + }; } - // We want to initialize after the input has been validated, but before (potentially) writing a pod - // since that might overwrite the initializer result - if (json.initialize && !json.registration) { - if (!this.initializer) { - throw new NotImplementedHttpError('This server is not configured with a setup initializer.'); - } - await this.initializer.handleSafe(); - } + const representation = await this.handler.handleSafe({ operation }); + await this.storage.set(this.storageKey, true); - let output: Record = {}; - // We only call the RegistrationManager when getting registration input. - // This way it is also possible to set up a server without requiring registration parameters. - let validated: RegistrationParams | undefined; - if (json.registration) { - if (!this.registrationManager) { - throw new NotImplementedHttpError('This server is not configured to support registration during setup.'); - } - // Validate the input JSON - validated = this.registrationManager.validateInput(json, true); - this.logger.debug(`Validated input: ${JSON.stringify(validated)}`); - - // Register and/or create a pod as requested. Potentially does nothing if all booleans are false. - output = await this.registrationManager.register(validated, true); - } - - // Add extra setup metadata - output.initialize = Boolean(json.initialize); - output.registration = Boolean(json.registration); - this.logger.debug(`Output: ${JSON.stringify(output)}`); - - return { json: output, template: this.responseTemplate }; + return new OkResponseDescription(representation.metadata, representation.data); } } diff --git a/src/init/variables/CombinedSettingsResolver.ts b/src/init/variables/CombinedSettingsResolver.ts new file mode 100644 index 000000000..9fe2a9df7 --- /dev/null +++ b/src/init/variables/CombinedSettingsResolver.ts @@ -0,0 +1,27 @@ +import { createErrorMessage } from '../../util/errors/ErrorUtil'; +import type { SettingsExtractor } from './extractors/SettingsExtractor'; +import { SettingsResolver } from './SettingsResolver'; + +/** + * Generates variable values by running a set of {@link SettingsExtractor}s on the input. + */ +export class CombinedSettingsResolver extends SettingsResolver { + public readonly computers: Record; + + public constructor(computers: Record) { + super(); + this.computers = computers; + } + + public async handle(input: Record): Promise> { + const vars: Record = {}; + for (const [ name, computer ] of Object.entries(this.computers)) { + try { + vars[name] = await computer.handleSafe(input); + } catch (err: unknown) { + throw new Error(`Error in computing value for variable ${name}: ${createErrorMessage(err)}`); + } + } + return vars; + } +} diff --git a/src/init/variables/SettingsResolver.ts b/src/init/variables/SettingsResolver.ts new file mode 100644 index 000000000..c2f2fa985 --- /dev/null +++ b/src/init/variables/SettingsResolver.ts @@ -0,0 +1,9 @@ +import { AsyncHandler } from '../../util/handlers/AsyncHandler'; +import type { Settings, VariableBindings } from './Types'; + +/** + * Converts a key/value object, extracted from the CLI or passed as a parameter, + * into a new key/value object where the keys are variables defined in the Components.js configuration. + * The resulting values are the values that should be assigned to those variables. + */ +export abstract class SettingsResolver extends AsyncHandler {} diff --git a/src/init/variables/Types.ts b/src/init/variables/Types.ts new file mode 100644 index 000000000..4cb155d27 --- /dev/null +++ b/src/init/variables/Types.ts @@ -0,0 +1,16 @@ +// These types are used to clarify what is expected for the CLI-related handlers + +/** + * A list of command line arguments provided to the process. + */ +export type CliArgv = string[]; + +/** + * A key/value mapping of parsed command line arguments. + */ +export type Settings = Record; + +/** + * A key/value mapping of Components.js variables. + */ +export type VariableBindings = Record; diff --git a/src/init/variables/extractors/AssetPathExtractor.ts b/src/init/variables/extractors/AssetPathExtractor.ts new file mode 100644 index 000000000..7c14e5760 --- /dev/null +++ b/src/init/variables/extractors/AssetPathExtractor.ts @@ -0,0 +1,26 @@ +import { resolveAssetPath } from '../../../util/PathUtil'; +import type { Settings } from '../Types'; +import { SettingsExtractor } from './SettingsExtractor'; + +/** + * A {@link SettingsExtractor} that converts a path value to an absolute asset path by making use of `resolveAssetPath`. + * Returns the default path in case it is defined and no path was found in the map. + */ +export class AssetPathExtractor extends SettingsExtractor { + private readonly key: string; + private readonly defaultPath?: string; + + public constructor(key: string, defaultPath?: string) { + super(); + this.key = key; + this.defaultPath = defaultPath; + } + + public async handle(args: Settings): Promise { + const path = args[this.key] ?? this.defaultPath; + if (typeof path !== 'string') { + throw new Error(`Invalid ${this.key} argument`); + } + return resolveAssetPath(path); + } +} diff --git a/src/init/variables/extractors/BaseUrlExtractor.ts b/src/init/variables/extractors/BaseUrlExtractor.ts new file mode 100644 index 000000000..3081e1c79 --- /dev/null +++ b/src/init/variables/extractors/BaseUrlExtractor.ts @@ -0,0 +1,24 @@ +import { ensureTrailingSlash } from '../../../util/PathUtil'; +import type { Settings } from '../Types'; +import { SettingsExtractor } from './SettingsExtractor'; + +/** + * A {@link SettingsExtractor} that that generates the base URL based on the input `baseUrl` value, + * or by using the port if the first isn't provided. + */ +export class BaseUrlExtractor extends SettingsExtractor { + private readonly defaultPort: number; + + public constructor(defaultPort = 3000) { + super(); + this.defaultPort = defaultPort; + } + + public async handle(args: Settings): Promise { + if (typeof args.baseUrl === 'string') { + return ensureTrailingSlash(args.baseUrl); + } + const port = args.port ?? this.defaultPort; + return `http://localhost:${port}/`; + } +} diff --git a/src/init/variables/extractors/KeyExtractor.ts b/src/init/variables/extractors/KeyExtractor.ts new file mode 100644 index 000000000..e97129db8 --- /dev/null +++ b/src/init/variables/extractors/KeyExtractor.ts @@ -0,0 +1,21 @@ +import type { Settings } from '../Types'; +import { SettingsExtractor } from './SettingsExtractor'; + +/** + * A simple {@link SettingsExtractor} that extracts a single value from the input map. + * Returns the default value if it was defined in case no value was found in the map. + */ +export class KeyExtractor extends SettingsExtractor { + private readonly key: string; + private readonly defaultValue: unknown; + + public constructor(key: string, defaultValue?: unknown) { + super(); + this.key = key; + this.defaultValue = defaultValue; + } + + public async handle(args: Settings): Promise { + return typeof args[this.key] === 'undefined' ? this.defaultValue : args[this.key]; + } +} diff --git a/src/init/variables/extractors/SettingsExtractor.ts b/src/init/variables/extractors/SettingsExtractor.ts new file mode 100644 index 000000000..c1a7a3c95 --- /dev/null +++ b/src/init/variables/extractors/SettingsExtractor.ts @@ -0,0 +1,7 @@ +import { AsyncHandler } from '../../../util/handlers/AsyncHandler'; +import type { Settings } from '../Types'; + +/** + * A handler that computes a specific value from a given map of values. + */ +export abstract class SettingsExtractor extends AsyncHandler {} diff --git a/src/logging/LogLevel.ts b/src/logging/LogLevel.ts index e67f9755e..136944833 100644 --- a/src/logging/LogLevel.ts +++ b/src/logging/LogLevel.ts @@ -1,4 +1,6 @@ +export const LOG_LEVELS = [ 'error', 'warn', 'info', 'verbose', 'debug', 'silly' ] as const; + /** * Different log levels, from most important to least important. */ -export type LogLevel = 'error' | 'warn' | 'info' | 'verbose' | 'debug' | 'silly'; +export type LogLevel = typeof LOG_LEVELS[number]; diff --git a/src/server/AuthorizingHttpHandler.ts b/src/server/AuthorizingHttpHandler.ts index 450d0026a..af8b56aff 100644 --- a/src/server/AuthorizingHttpHandler.ts +++ b/src/server/AuthorizingHttpHandler.ts @@ -58,7 +58,7 @@ export class AuthorizingHttpHandler extends OperationHttpHandler { this.operationHandler = args.operationHandler; } - public async handle(input: OperationHttpHandlerInput): Promise { + public async handle(input: OperationHttpHandlerInput): Promise { const { request, operation } = input; const credentials: CredentialSet = await this.credentialsExtractor.handleSafe(request); this.logger.verbose(`Extracted credentials: ${JSON.stringify(credentials)}`); diff --git a/src/server/OperationHttpHandler.ts b/src/server/OperationHttpHandler.ts index 8f685be35..31b4b1ddc 100644 --- a/src/server/OperationHttpHandler.ts +++ b/src/server/OperationHttpHandler.ts @@ -9,8 +9,6 @@ export interface OperationHttpHandlerInput extends HttpHandlerInput { /** * An HTTP handler that makes use of an already parsed Operation. - * Can either return a ResponseDescription to be resolved by the calling class, - * or undefined if this class handles the response itself. */ export abstract class OperationHttpHandler - extends AsyncHandler {} + extends AsyncHandler {} diff --git a/src/server/util/RedirectAllHttpHandler.ts b/src/server/util/RedirectAllHttpHandler.ts index 612eb2522..5a5f5a1a2 100644 --- a/src/server/util/RedirectAllHttpHandler.ts +++ b/src/server/util/RedirectAllHttpHandler.ts @@ -1,6 +1,7 @@ import type { TargetExtractor } from '../../http/input/identifier/TargetExtractor'; import { RedirectResponseDescription } from '../../http/output/response/RedirectResponseDescription'; import type { ResponseWriter } from '../../http/output/ResponseWriter'; +import { FoundHttpError } from '../../util/errors/FoundHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { getRelativeUrl, joinUrl } from '../../util/PathUtil'; import type { HttpHandlerInput } from '../HttpHandler'; @@ -40,7 +41,7 @@ export class RedirectAllHttpHandler extends HttpHandler { } public async handle({ response }: HttpHandlerInput): Promise { - const result = new RedirectResponseDescription(joinUrl(this.baseUrl, this.target)); + const result = new RedirectResponseDescription(new FoundHttpError(joinUrl(this.baseUrl, this.target))); await this.responseWriter.handleSafe({ response, result }); } } diff --git a/src/storage/accessors/AtomicDataAccessor.ts b/src/storage/accessors/AtomicDataAccessor.ts new file mode 100644 index 000000000..3184167ec --- /dev/null +++ b/src/storage/accessors/AtomicDataAccessor.ts @@ -0,0 +1,10 @@ +import type { DataAccessor } from './DataAccessor'; + +/** + * The AtomicDataAccessor interface has identical function signatures as + * the DataAccessor, with the additional constraint that every function call + * must be atomic in its effect: either the call fully succeeds, reaching the + * desired new state; or it fails, upon which the resulting state remains + * identical to the one before the call. + */ +export interface AtomicDataAccessor extends DataAccessor { } diff --git a/src/storage/accessors/AtomicFileDataAccessor.ts b/src/storage/accessors/AtomicFileDataAccessor.ts new file mode 100644 index 000000000..6eb5f4ac5 --- /dev/null +++ b/src/storage/accessors/AtomicFileDataAccessor.ts @@ -0,0 +1,62 @@ +import { mkdirSync, promises as fsPromises } from 'fs'; +import type { Readable } from 'stream'; +import { v4 } from 'uuid'; +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import type { Guarded } from '../../util/GuardedStream'; +import { joinFilePath } from '../../util/PathUtil'; +import type { FileIdentifierMapper } from '../mapping/FileIdentifierMapper'; +import type { AtomicDataAccessor } from './AtomicDataAccessor'; +import { FileDataAccessor } from './FileDataAccessor'; + +/** + * AtomicDataAccessor that uses the file system to store documents as files and containers as folders. + * Data will first be written to a temporary location and only if no errors occur + * will the data be written to the desired location. + */ +export class AtomicFileDataAccessor extends FileDataAccessor implements AtomicDataAccessor { + private readonly tempFilePath: string; + + public constructor(resourceMapper: FileIdentifierMapper, rootFilePath: string, tempFilePath: string) { + super(resourceMapper); + this.tempFilePath = joinFilePath(rootFilePath, tempFilePath); + // Cannot use fsPromises in constructor + mkdirSync(this.tempFilePath, { recursive: true }); + } + + /** + * Writes the given data as a file (and potential metadata as additional file). + * Data will first be written to a temporary file and if no errors occur only then the + * file will be moved to desired destination. + * If the stream errors it is made sure the temporary file will be deleted. + * The metadata file will only be written if the data was written successfully. + */ + public async writeDocument(identifier: ResourceIdentifier, data: Guarded, metadata: RepresentationMetadata): + Promise { + const link = await this.resourceMapper.mapUrlToFilePath(identifier, false, metadata.contentType); + + // Generate temporary file name + const tempFilePath = joinFilePath(this.tempFilePath, `temp-${v4()}.txt`); + + try { + await this.writeDataFile(tempFilePath, data); + + // Check if we already have a corresponding file with a different extension + await this.verifyExistingExtension(link); + + // When no quota errors occur move the file to its desired location + await fsPromises.rename(tempFilePath, link.filePath); + } catch (error: unknown) { + // Delete the data already written + try { + if ((await this.getStats(tempFilePath)).isFile()) { + await fsPromises.unlink(tempFilePath); + } + } catch { + throw error; + } + throw error; + } + await this.writeMetadata(link, metadata); + } +} diff --git a/src/storage/accessors/FileDataAccessor.ts b/src/storage/accessors/FileDataAccessor.ts index 9efde8d3b..fd6cebc56 100644 --- a/src/storage/accessors/FileDataAccessor.ts +++ b/src/storage/accessors/FileDataAccessor.ts @@ -22,7 +22,7 @@ import type { DataAccessor } from './DataAccessor'; * DataAccessor that uses the file system to store documents as files and containers as folders. */ export class FileDataAccessor implements DataAccessor { - private readonly resourceMapper: FileIdentifierMapper; + protected readonly resourceMapper: FileIdentifierMapper; public constructor(resourceMapper: FileIdentifierMapper) { this.resourceMapper = resourceMapper; @@ -149,7 +149,7 @@ export class FileDataAccessor implements DataAccessor { * @throws NotFoundHttpError * If the file/folder doesn't exist. */ - private async getStats(path: string): Promise { + protected async getStats(path: string): Promise { try { return await fsPromises.stat(path); } catch (error: unknown) { @@ -192,7 +192,7 @@ export class FileDataAccessor implements DataAccessor { * * @returns True if data was written to a file. */ - private async writeMetadata(link: ResourceLink, metadata: RepresentationMetadata): Promise { + protected async writeMetadata(link: ResourceLink, metadata: RepresentationMetadata): Promise { // These are stored by file system conventions metadata.remove(RDF.terms.type, LDP.terms.Resource); metadata.remove(RDF.terms.type, LDP.terms.Container); @@ -327,7 +327,7 @@ export class FileDataAccessor implements DataAccessor { * * @param link - ResourceLink corresponding to the new resource data. */ - private async verifyExistingExtension(link: ResourceLink): Promise { + protected async verifyExistingExtension(link: ResourceLink): Promise { try { // Delete the old file with the (now) wrong extension const oldLink = await this.resourceMapper.mapUrlToFilePath(link.identifier, false); @@ -347,11 +347,14 @@ export class FileDataAccessor implements DataAccessor { * @param path - The filepath of the file to be created. * @param data - The data to be put in the file. */ - private async writeDataFile(path: string, data: Readable): Promise { + protected async writeDataFile(path: string, data: Readable): Promise { return new Promise((resolve, reject): any => { const writeStream = createWriteStream(path); data.pipe(writeStream); - data.on('error', reject); + data.on('error', (error): void => { + reject(error); + writeStream.end(); + }); writeStream.on('error', reject); writeStream.on('finish', resolve); diff --git a/src/storage/accessors/PassthroughDataAccessor.ts b/src/storage/accessors/PassthroughDataAccessor.ts new file mode 100644 index 000000000..1af6eb333 --- /dev/null +++ b/src/storage/accessors/PassthroughDataAccessor.ts @@ -0,0 +1,49 @@ +import type { Readable } from 'stream'; +import type { Representation } from '../../http/representation/Representation'; +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import type { Guarded } from '../../util/GuardedStream'; +import type { AtomicDataAccessor } from './AtomicDataAccessor'; +import type { DataAccessor } from './DataAccessor'; + +/** + * DataAccessor that calls the corresponding functions of the source DataAccessor. + * Can be extended by data accessors that do not want to override all functions + * by implementing a decorator pattern. + */ +export class PassthroughDataAccessor implements DataAccessor { + protected readonly accessor: AtomicDataAccessor; + + public constructor(accessor: DataAccessor) { + this.accessor = accessor; + } + + public async writeDocument(identifier: ResourceIdentifier, data: Guarded, metadata: RepresentationMetadata): + Promise { + return this.accessor.writeDocument(identifier, data, metadata); + } + + public async writeContainer(identifier: ResourceIdentifier, metadata: RepresentationMetadata): Promise { + return this.accessor.writeContainer(identifier, metadata); + } + + public async canHandle(representation: Representation): Promise { + return this.accessor.canHandle(representation); + } + + public async getData(identifier: ResourceIdentifier): Promise> { + return this.accessor.getData(identifier); + } + + public async getMetadata(identifier: ResourceIdentifier): Promise { + return this.accessor.getMetadata(identifier); + } + + public getChildren(identifier: ResourceIdentifier): AsyncIterableIterator { + return this.accessor.getChildren(identifier); + } + + public async deleteResource(identifier: ResourceIdentifier): Promise { + return this.accessor.deleteResource(identifier); + } +} diff --git a/src/storage/accessors/ValidatingDataAccessor.ts b/src/storage/accessors/ValidatingDataAccessor.ts new file mode 100644 index 000000000..394b4c7cb --- /dev/null +++ b/src/storage/accessors/ValidatingDataAccessor.ts @@ -0,0 +1,40 @@ +import type { Readable } from 'stream'; +import type { Validator } from '../../http/auxiliary/Validator'; +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import type { Guarded } from '../../util/GuardedStream'; +import type { DataAccessor } from './DataAccessor'; +import { PassthroughDataAccessor } from './PassthroughDataAccessor'; + +/** + * A ValidatingDataAccessor wraps a DataAccessor such that the data stream is validated while being written. + * An AtomicDataAccessor can be used to prevent data being written in case validation fails. + */ +export class ValidatingDataAccessor extends PassthroughDataAccessor { + private readonly validator: Validator; + + public constructor(accessor: DataAccessor, validator: Validator) { + super(accessor); + this.validator = validator; + } + + public async writeDocument( + identifier: ResourceIdentifier, + data: Guarded, + metadata: RepresentationMetadata, + ): Promise { + const pipedRep = await this.validator.handleSafe({ + representation: new BasicRepresentation(data, metadata), + identifier, + }); + return this.accessor.writeDocument(identifier, pipedRep.data, metadata); + } + + public async writeContainer(identifier: ResourceIdentifier, metadata: RepresentationMetadata): Promise { + // A container's data mainly resides in its metadata, + // of which we can't calculate the disk size of at this point in the code. + // Extra info can be found here: https://github.com/solid/community-server/pull/973#discussion_r723376888 + return this.accessor.writeContainer(identifier, metadata); + } +} diff --git a/src/storage/conversion/BaseTypedRepresentationConverter.ts b/src/storage/conversion/BaseTypedRepresentationConverter.ts new file mode 100644 index 000000000..e5e3c40b6 --- /dev/null +++ b/src/storage/conversion/BaseTypedRepresentationConverter.ts @@ -0,0 +1,82 @@ +import type { ValuePreferences } from '../../http/representation/RepresentationPreferences'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { getConversionTarget, getTypeWeight, preferencesToString } from './ConversionUtil'; +import type { RepresentationConverterArgs } from './RepresentationConverter'; +import { TypedRepresentationConverter } from './TypedRepresentationConverter'; + +type PromiseOrValue = T | Promise; +type ValuePreferencesArg = + PromiseOrValue | + PromiseOrValue | + PromiseOrValue; + +async function toValuePreferences(arg: ValuePreferencesArg): Promise { + const resolved = await arg; + if (typeof resolved === 'string') { + return { [resolved]: 1 }; + } + if (Array.isArray(resolved)) { + return Object.fromEntries(resolved.map((type): [string, number] => [ type, 1 ])); + } + return resolved; +} + +/** + * A base {@link TypedRepresentationConverter} implementation for converters + * that can convert from all its input types to all its output types. + * + * This base class handles the `canHandle` call by comparing the input content type to the stored input types + * and the output preferences to the stored output types. + * + * Output weights are determined by multiplying all stored output weights with the weight of the input type. + */ +export abstract class BaseTypedRepresentationConverter extends TypedRepresentationConverter { + protected inputTypes: Promise; + protected outputTypes: Promise; + + public constructor(inputTypes: ValuePreferencesArg, outputTypes: ValuePreferencesArg) { + super(); + this.inputTypes = toValuePreferences(inputTypes); + this.outputTypes = toValuePreferences(outputTypes); + } + + /** + * Matches all inputs to all outputs. + */ + public async getOutputTypes(contentType: string): Promise { + const weight = getTypeWeight(contentType, await this.inputTypes); + if (weight > 0) { + const outputTypes = { ...await this.outputTypes }; + for (const [ key, value ] of Object.entries(outputTypes)) { + outputTypes[key] = value * weight; + } + return outputTypes; + } + return {}; + } + + /** + * Determines whether the given conversion request is supported, + * given the available content type conversions: + * - Checks if there is a content type for the input. + * - Checks if the input type is supported by the parser. + * - Checks if the parser can produce one of the preferred output types. + * Throws an error with details if conversion is not possible. + */ + public async canHandle(args: RepresentationConverterArgs): Promise { + const { contentType } = args.representation.metadata; + + if (!contentType) { + throw new NotImplementedHttpError('Can not convert data without a Content-Type.'); + } + + const outputTypes = await this.getOutputTypes(contentType); + const outputPreferences = args.preferences.type ?? {}; + if (!getConversionTarget(outputTypes, outputPreferences)) { + throw new NotImplementedHttpError( + `Cannot convert from ${contentType} to ${preferencesToString(outputPreferences) + }, only to ${preferencesToString(outputTypes)}.`, + ); + } + } +} diff --git a/src/storage/conversion/ChainedConverter.ts b/src/storage/conversion/ChainedConverter.ts index f1c765d24..4704385d1 100644 --- a/src/storage/conversion/ChainedConverter.ts +++ b/src/storage/conversion/ChainedConverter.ts @@ -1,34 +1,50 @@ +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; import type { Representation } from '../../http/representation/Representation'; -import type { ValuePreference, ValuePreferences } from '../../http/representation/RepresentationPreferences'; +import { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ValuePreferences } from '../../http/representation/RepresentationPreferences'; import { getLoggerFor } from '../../logging/LogUtil'; import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; -import { cleanPreferences, getBestPreference, getTypeWeight } from './ConversionUtil'; +import { cleanPreferences, getBestPreference, getTypeWeight, preferencesToString } from './ConversionUtil'; import type { RepresentationConverterArgs } from './RepresentationConverter'; import { RepresentationConverter } from './RepresentationConverter'; import type { TypedRepresentationConverter } from './TypedRepresentationConverter'; -type ConverterPreference = ValuePreference & { converter: TypedRepresentationConverter }; - -/** - * A chain of converters that can go from `inTypes` to `outTypes`. - * `intermediateTypes` contains the exact types that have the highest weight when going from converter i to i+1. - */ -type ConversionPath = { - converters: TypedRepresentationConverter[]; - intermediateTypes: string[]; - inTypes: ValuePreferences; +type ConverterPreference = { + converter: TypedRepresentationConverter; + inType: string; outTypes: ValuePreferences; }; -/** - * The result of applying a `ConversionPath` to a specific input. - */ -type MatchedPath = { - path: ConversionPath; +type ConversionPath = { + /** + * List of converters used in the path. + */ + converters: TypedRepresentationConverter[]; + /** + * The intermediate conversion types when going from converter i to i+1. + * Length is one less than the list of converters. + */ + intermediateTypes: string[]; + /** + * The type on which this conversion path starts. + */ inType: string; - outType: string; + /** + * The types this path can generate. + * Weights indicate the quality of transforming to that specific type. + */ + outTypes: ValuePreferences; + /** + * The weight of the path matched against the output preferences. + * Will be 0 if the path does not match any of those preferences. + */ weight: number; + /** + * The output type for which this path has the highest weight. + * Value is irrelevant if weight is 0. + */ + outType: string; }; /** @@ -49,6 +65,9 @@ type MatchedPath = { * - The algorithm could start on both ends of a possible path and work towards the middle. * - When creating a path, store the list of unused converters instead of checking every step. * - Caching: https://github.com/solid/community-server/issues/832 + * - Making sure each intermediate type is only used once. + * - The TypedRepresentationConverter interface could potentially be updated + * so paths only differing in intermediate types can be combined. */ export class ChainedConverter extends RepresentationConverter { protected readonly logger = getLoggerFor(this); @@ -73,43 +92,25 @@ export class ChainedConverter extends RepresentationConverter { public async handle(input: RepresentationConverterArgs): Promise { const match = await this.findPath(input); - // No conversion needed - if (!this.isMatchedPath(match)) { - return input.representation; - } - - const { path, inType, outType } = match; - this.logger.debug(`Converting ${inType} -> ${[ ...path.intermediateTypes, outType ].join(' -> ')}.`); + this.logger.debug(`Converting ${match.inType} -> ${[ ...match.intermediateTypes, match.outType ].join(' -> ')}.`); const args = { ...input }; - for (let i = 0; i < path.converters.length - 1; ++i) { - const type = path.intermediateTypes[i]; - args.preferences = { type: { [type]: 1 }}; - args.representation = await path.converters[i].handle(args); + const outTypes = [ ...match.intermediateTypes, match.outType ]; + for (let i = 0; i < match.converters.length; ++i) { + args.preferences = { type: { [outTypes[i]]: 1 }}; + args.representation = await match.converters[i].handle(args); } - // For the last converter we set the preferences to the best output type - args.preferences = { type: { [outType]: 1 }}; - return path.converters.slice(-1)[0].handle(args); - } - - private isMatchedPath(path: unknown): path is MatchedPath { - return typeof (path as MatchedPath).path === 'object'; + return args.representation; } /** * Finds a conversion path that can handle the given input. */ - private async findPath(input: RepresentationConverterArgs): Promise { + private async findPath(input: RepresentationConverterArgs): Promise { const type = input.representation.metadata.contentType!; const preferences = cleanPreferences(input.preferences.type); - const weight = getTypeWeight(type, preferences); - if (weight > 0) { - this.logger.debug(`No conversion required: ${type} already matches ${Object.keys(preferences)}`); - return { value: type, weight }; - } - - return this.generatePath(type, preferences); + return this.generatePath(type, preferences, input.representation.metadata); } /** @@ -118,61 +119,71 @@ export class ChainedConverter extends RepresentationConverter { * * Errors if such a path does not exist. */ - private async generatePath(inType: string, outPreferences: ValuePreferences): Promise { - // Generate paths from all converters that match the input type - let paths = await this.converters.reduce(async(matches: Promise, converter): - Promise => { - const inTypes = await converter.getInputTypes(); - if (getTypeWeight(inType, inTypes) > 0) { - (await matches).push({ - converters: [ converter ], - intermediateTypes: [], - inTypes, - outTypes: await converter.getOutputTypes(), - }); - } - return matches; - }, Promise.resolve([])); + private async generatePath(inType: string, outPreferences: ValuePreferences, metadata: RepresentationMetadata): + Promise { + // + const weight = getTypeWeight(inType, outPreferences); + let paths: ConversionPath[] = [{ + converters: [], + intermediateTypes: [], + inType, + outTypes: { [inType]: 1 }, + weight, + outType: inType, + }]; // It's impossible for a path to have a higher weight than this value const maxWeight = Math.max(...Object.values(outPreferences)); - let bestPath = this.findBest(inType, outPreferences, paths); - paths = this.removeBadPaths(paths, maxWeight, inType, bestPath); + // This metadata will be used to simulate canHandle checks + const metadataPlaceholder = new RepresentationMetadata(metadata); + + let bestPath = this.findBest(paths); // This will always stop at some point since paths can't have the same converter twice while (paths.length > 0) { // For every path, find all the paths that can be made by adding 1 more converter - const promises = paths.map(async(path): Promise => this.takeStep(path)); + const promises = paths.map(async(path): Promise => this.takeStep(path, metadataPlaceholder)); paths = (await Promise.all(promises)).flat(); - const newBest = this.findBest(inType, outPreferences, paths); + this.updatePathWeights(paths, outPreferences); + const newBest = this.findBest(paths); if (newBest && (!bestPath || newBest.weight > bestPath.weight)) { bestPath = newBest; } - paths = this.removeBadPaths(paths, maxWeight, inType, bestPath); + paths = this.removeBadPaths(paths, maxWeight, bestPath); } if (!bestPath) { - this.logger.warn(`No conversion path could be made from ${inType} to ${Object.keys(outPreferences)}.`); + this.logger.warn(`No conversion path could be made from ${inType} to ${preferencesToString(outPreferences)}.`); throw new NotImplementedHttpError( - `No conversion path could be made from ${inType} to ${Object.keys(outPreferences)}.`, + `No conversion path could be made from ${inType} to ${preferencesToString(outPreferences)}.`, ); } return bestPath; } /** - * Finds the path from the given list that can convert the given type to the given preferences. + * Checks if a path can match the requested preferences and updates the type and weight if it can. + */ + private updatePathWeights(paths: ConversionPath[], outPreferences: ValuePreferences): void { + for (const path of paths) { + const outMatch = getBestPreference(path.outTypes, outPreferences); + if (outMatch && outMatch.weight > 0) { + path.weight = outMatch.weight; + path.outType = outMatch.value; + } + } + } + + /** + * Finds the path from the given list that can convert to the given preferences. * If there are multiple matches the one with the highest result weight gets chosen. * Will return undefined if there are no matches. */ - private findBest(type: string, preferences: ValuePreferences, paths: ConversionPath[]): MatchedPath | undefined { + private findBest(paths: ConversionPath[]): ConversionPath | undefined { // Need to use null instead of undefined so `reduce` doesn't take the first element of the array as `best` - return paths.reduce((best: MatchedPath | null, path): MatchedPath | null => { - const outMatch = getBestPreference(path.outTypes, preferences); - if (outMatch && !(best && best.weight >= outMatch.weight)) { - // Create new MatchedPath, using the output match above - const inWeight = getTypeWeight(type, path.inTypes); - return { path, inType: type, outType: outMatch.value, weight: inWeight * outMatch.weight }; + return paths.reduce((best: ConversionPath | null, path): ConversionPath | null => { + if (path.weight > 0 && !(best && best.weight >= path.weight)) { + return path; } return best; }, null) ?? undefined; @@ -184,11 +195,9 @@ export class ChainedConverter extends RepresentationConverter { * * @param paths - Paths to filter. * @param maxWeight - The maximum weight in the output preferences. - * @param inType - The input type. * @param bestMatch - The current best path. */ - private removeBadPaths(paths: ConversionPath[], maxWeight: number, inType: string, bestMatch?: MatchedPath): - ConversionPath[] { + private removeBadPaths(paths: ConversionPath[], maxWeight: number, bestMatch?: ConversionPath): ConversionPath[] { // All paths are still good if there is no best match yet if (!bestMatch) { return paths; @@ -200,9 +209,7 @@ export class ChainedConverter extends RepresentationConverter { // Only return paths that can potentially improve upon bestPath return paths.filter((path): boolean => { - const optimisticWeight = getTypeWeight(inType, path.inTypes) * - Math.max(...Object.values(path.outTypes)) * - maxWeight; + const optimisticWeight = Math.max(...Object.values(path.outTypes)) * maxWeight; return optimisticWeight > bestMatch.weight; }); } @@ -211,16 +218,19 @@ export class ChainedConverter extends RepresentationConverter { * Finds all converters that could take the output of the given path as input. * For each of these converters a new path gets created which is the input path appended by the converter. */ - private async takeStep(path: ConversionPath): Promise { + private async takeStep(path: ConversionPath, metadata: RepresentationMetadata): Promise { const unusedConverters = this.converters.filter((converter): boolean => !path.converters.includes(converter)); - const nextConverters = await this.supportedConverters(path.outTypes, unusedConverters); + const nextConverters = await this.supportedConverters(path.outTypes, metadata, unusedConverters); // Create a new path for every converter that can be appended return Promise.all(nextConverters.map(async(pref): Promise => ({ converters: [ ...path.converters, pref.converter ], - intermediateTypes: [ ...path.intermediateTypes, pref.value ], - inTypes: path.inTypes, - outTypes: this.modifyTypeWeights(pref.weight, await pref.converter.getOutputTypes()), + intermediateTypes: path.converters.length > 0 ? [ ...path.intermediateTypes, pref.inType ] : [], + inType: path.inType, + outTypes: pref.outTypes, + // These will be updated later + weight: 0, + outType: 'invalid', }))); } @@ -234,16 +244,43 @@ export class ChainedConverter extends RepresentationConverter { /** * Finds all converters in the given list that support taking any of the given types as input. + * Filters out converters that would produce an already seen type. */ - private async supportedConverters(types: ValuePreferences, converters: TypedRepresentationConverter[]): - Promise { - const promises = converters.map(async(converter): Promise => { - const inputTypes = await converter.getInputTypes(); - const match = getBestPreference(types, inputTypes); - if (match) { - return { ...match, converter }; + private async supportedConverters(types: ValuePreferences, metadata: RepresentationMetadata, + converters: TypedRepresentationConverter[]): Promise { + const typeEntries = Object.entries(types); + const results: ConverterPreference[] = []; + for (const converter of converters) { + for (const [ inType, weight ] of typeEntries) { + // This metadata object is only used internally so changing the content-type is fine + metadata.contentType = inType; + const preference = await this.findConverterPreference(inType, weight, metadata, converter); + if (preference) { + results.push(preference); + } } - }); - return (await Promise.all(promises)).filter(Boolean) as ConverterPreference[]; + } + return results; + } + + /** + * Returns a ConverterPreference if the given converter supports the given type. + * All types that have already been used will be removed from the output types. + */ + private async findConverterPreference(inType: string, weight: number, metadata: RepresentationMetadata, + converter: TypedRepresentationConverter): Promise { + const representation = new BasicRepresentation([], metadata); + try { + const identifier = { path: representation.metadata.identifier.value }; + // Internal types get ignored when trying to match everything, so they need to be specified to also match. + await converter.canHandle({ representation, identifier, preferences: { type: { '*/*': 1, 'internal/*': 1 }}}); + } catch { + // Skip converters that fail the canHandle test + return; + } + + let outTypes = await converter.getOutputTypes(inType); + outTypes = this.modifyTypeWeights(weight, outTypes); + return { converter, inType, outTypes }; } } diff --git a/src/storage/conversion/ContainerToTemplateConverter.ts b/src/storage/conversion/ContainerToTemplateConverter.ts index d4e0f01e7..8dbafeaf1 100644 --- a/src/storage/conversion/ContainerToTemplateConverter.ts +++ b/src/storage/conversion/ContainerToTemplateConverter.ts @@ -11,8 +11,8 @@ import { isContainerIdentifier, isContainerPath } from '../../util/PathUtil'; import { endOfStream } from '../../util/StreamUtil'; import type { TemplateEngine } from '../../util/templates/TemplateEngine'; import { LDP } from '../../util/Vocabularies'; +import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { TypedRepresentationConverter } from './TypedRepresentationConverter'; interface ResourceDetails { name: string; @@ -23,7 +23,7 @@ interface ResourceDetails { /** * A {@link RepresentationConverter} that creates a templated representation of a container. */ -export class ContainerToTemplateConverter extends TypedRepresentationConverter { +export class ContainerToTemplateConverter extends BaseTypedRepresentationConverter { private readonly identifierStrategy: IdentifierStrategy; private readonly templateEngine: TemplateEngine; private readonly contentType: string; diff --git a/src/storage/conversion/ContentTypeReplacer.ts b/src/storage/conversion/ContentTypeReplacer.ts index 1e82f4b0c..52952f26d 100644 --- a/src/storage/conversion/ContentTypeReplacer.ts +++ b/src/storage/conversion/ContentTypeReplacer.ts @@ -4,7 +4,7 @@ import type { ValuePreferences } from '../../http/representation/RepresentationP import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { matchesMediaType, getConversionTarget } from './ConversionUtil'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { RepresentationConverter } from './RepresentationConverter'; +import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * A {@link RepresentationConverter} that changes the content type @@ -13,7 +13,7 @@ import { RepresentationConverter } from './RepresentationConverter'; * Useful for when a content type is binary-compatible with another one; * for instance, all JSON-LD files are valid JSON files. */ -export class ContentTypeReplacer extends RepresentationConverter { +export class ContentTypeReplacer extends TypedRepresentationConverter { private readonly contentTypeMap: Record = {}; /** @@ -40,15 +40,22 @@ export class ContentTypeReplacer extends RepresentationConverter { } } + public async getOutputTypes(contentType: string): Promise { + const supported = Object.keys(this.contentTypeMap) + .filter((type): boolean => matchesMediaType(contentType, type)) + .map((type): ValuePreferences => this.contentTypeMap[type]); + return Object.assign({} as ValuePreferences, ...supported); + } + public async canHandle({ representation, preferences }: RepresentationConverterArgs): Promise { - this.getReplacementType(representation.metadata.contentType, preferences.type); + await this.getReplacementType(representation.metadata.contentType, preferences.type); } /** * Changes the content type on the representation. */ public async handle({ representation, preferences }: RepresentationConverterArgs): Promise { - const contentType = this.getReplacementType(representation.metadata.contentType, preferences.type); + const contentType = await this.getReplacementType(representation.metadata.contentType, preferences.type); const metadata = new RepresentationMetadata(representation.metadata, contentType); return { ...representation, metadata }; } @@ -61,11 +68,9 @@ export class ContentTypeReplacer extends RepresentationConverter { * Find a replacement content type that matches the preferences, * or throws an error if none was found. */ - private getReplacementType(contentType = 'unknown', preferred: ValuePreferences = {}): string { - const supported = Object.keys(this.contentTypeMap) - .filter((type): boolean => matchesMediaType(contentType, type)) - .map((type): ValuePreferences => this.contentTypeMap[type]); - const match = getConversionTarget(Object.assign({} as ValuePreferences, ...supported), preferred); + private async getReplacementType(contentType = 'unknown', preferred: ValuePreferences = {}): Promise { + const supported = await this.getOutputTypes(contentType); + const match = getConversionTarget(supported, preferred); if (!match) { throw new NotImplementedHttpError(`Cannot convert from ${contentType} to ${Object.keys(preferred)}`); } diff --git a/src/storage/conversion/ConversionUtil.ts b/src/storage/conversion/ConversionUtil.ts index 1ed97dc9f..c237061fe 100644 --- a/src/storage/conversion/ConversionUtil.ts +++ b/src/storage/conversion/ConversionUtil.ts @@ -164,3 +164,13 @@ export function matchesMediaType(mediaA: string, mediaB: string): boolean { export function isInternalContentType(contentType?: string): boolean { return typeof contentType !== 'undefined' && matchesMediaType(contentType, INTERNAL_ALL); } + +/** + * Serializes a preferences object to a string for display purposes. + * @param preferences - Preferences to serialize + */ +export function preferencesToString(preferences: ValuePreferences): string { + return Object.entries(preferences) + .map(([ type, weight ]): string => `${type}:${weight}`) + .join(','); +} diff --git a/src/storage/conversion/DynamicJsonToTemplateConverter.ts b/src/storage/conversion/DynamicJsonToTemplateConverter.ts index 975c34106..7fe90afbb 100644 --- a/src/storage/conversion/DynamicJsonToTemplateConverter.ts +++ b/src/storage/conversion/DynamicJsonToTemplateConverter.ts @@ -24,6 +24,8 @@ import type { RepresentationConverterArgs } from './RepresentationConverter'; * describing the content-type of that template. * * The output of the result depends on the content-type matched with the template. + * In case JSON is the most preferred output type, + * the input representation will be returned unless a JSON template is defined. */ export class DynamicJsonToTemplateConverter extends RepresentationConverter { private readonly templateEngine: TemplateEngine; @@ -51,6 +53,11 @@ export class DynamicJsonToTemplateConverter extends RepresentationConverter { const typeMap = this.constructTypeMap(identifier, representation); const type = this.findType(typeMap, preferences.type); + // No conversion needed if JSON is requested and there is no specific JSON template + if (type === APPLICATION_JSON && typeMap[APPLICATION_JSON].length === 0) { + return representation; + } + const json = JSON.parse(await readableToString(representation.data)); const rendered = await this.templateEngine.render(json, { templateFile: typeMap[type] }); @@ -69,6 +76,11 @@ export class DynamicJsonToTemplateConverter extends RepresentationConverter { .map((quad): Term => quad.object) .filter((term: Term): boolean => term.termType === 'NamedNode') as NamedNode[]; + // This handler should only cover cases where templates are defined + if (templates.length === 0) { + throw new NotImplementedHttpError('No templates found.'); + } + // Maps all content-types to their template const typeMap: Record = {}; for (const template of templates) { @@ -77,6 +89,12 @@ export class DynamicJsonToTemplateConverter extends RepresentationConverter { typeMap[type] = template.value; } } + + // Not using a template is always an option unless there is a specific JSON template + if (!typeMap[APPLICATION_JSON]) { + typeMap[APPLICATION_JSON] = ''; + } + return typeMap; } diff --git a/src/storage/conversion/ErrorToJsonConverter.ts b/src/storage/conversion/ErrorToJsonConverter.ts index a2f7c9c7f..2f33c44f8 100644 --- a/src/storage/conversion/ErrorToJsonConverter.ts +++ b/src/storage/conversion/ErrorToJsonConverter.ts @@ -3,13 +3,13 @@ import type { Representation } from '../../http/representation/Representation'; import { APPLICATION_JSON, INTERNAL_ERROR } from '../../util/ContentTypes'; import { HttpError } from '../../util/errors/HttpError'; import { getSingleItem } from '../../util/StreamUtil'; +import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * Converts an Error object to JSON by copying its fields. */ -export class ErrorToJsonConverter extends TypedRepresentationConverter { +export class ErrorToJsonConverter extends BaseTypedRepresentationConverter { public constructor() { super(INTERNAL_ERROR, APPLICATION_JSON); } diff --git a/src/storage/conversion/ErrorToQuadConverter.ts b/src/storage/conversion/ErrorToQuadConverter.ts index aace74b99..5ebdfb789 100644 --- a/src/storage/conversion/ErrorToQuadConverter.ts +++ b/src/storage/conversion/ErrorToQuadConverter.ts @@ -4,13 +4,13 @@ import { RepresentationMetadata } from '../../http/representation/Representation import { INTERNAL_ERROR, INTERNAL_QUADS } from '../../util/ContentTypes'; import { getSingleItem } from '../../util/StreamUtil'; import { DC, SOLID_ERROR } from '../../util/Vocabularies'; +import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * Converts an error object into quads by creating a triple for each of name/message/stack. */ -export class ErrorToQuadConverter extends TypedRepresentationConverter { +export class ErrorToQuadConverter extends BaseTypedRepresentationConverter { public constructor() { super(INTERNAL_ERROR, INTERNAL_QUADS); } diff --git a/src/storage/conversion/ErrorToTemplateConverter.ts b/src/storage/conversion/ErrorToTemplateConverter.ts index a72ec4456..48aa23357 100644 --- a/src/storage/conversion/ErrorToTemplateConverter.ts +++ b/src/storage/conversion/ErrorToTemplateConverter.ts @@ -3,11 +3,11 @@ import { BasicRepresentation } from '../../http/representation/BasicRepresentati import type { Representation } from '../../http/representation/Representation'; import { INTERNAL_ERROR } from '../../util/ContentTypes'; import { HttpError } from '../../util/errors/HttpError'; -import { modulePathPlaceholder } from '../../util/PathUtil'; +import { resolveModulePath } from '../../util/PathUtil'; import { getSingleItem } from '../../util/StreamUtil'; import type { TemplateEngine } from '../../util/templates/TemplateEngine'; +import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { TypedRepresentationConverter } from './TypedRepresentationConverter'; // Fields optional due to https://github.com/LinkedSoftwareDependencies/Components.js/issues/20 export interface TemplateOptions { @@ -18,8 +18,8 @@ export interface TemplateOptions { } const DEFAULT_TEMPLATE_OPTIONS: TemplateOptions = { - mainTemplatePath: `${modulePathPlaceholder}templates/error/main.md.hbs`, - codeTemplatesPath: `${modulePathPlaceholder}templates/error/descriptions/`, + mainTemplatePath: resolveModulePath('templates/error/main.md.hbs'), + codeTemplatesPath: resolveModulePath('templates/error/descriptions/'), extension: '.md.hbs', contentType: 'text/markdown', }; @@ -35,7 +35,7 @@ const DEFAULT_TEMPLATE_OPTIONS: TemplateOptions = { * That result will be passed as an additional parameter to the main templating call, * using the variable `codeMessage`. */ -export class ErrorToTemplateConverter extends TypedRepresentationConverter { +export class ErrorToTemplateConverter extends BaseTypedRepresentationConverter { private readonly templateEngine: TemplateEngine; private readonly mainTemplatePath: string; private readonly codeTemplatesPath: string; @@ -43,7 +43,7 @@ export class ErrorToTemplateConverter extends TypedRepresentationConverter { private readonly contentType: string; public constructor(templateEngine: TemplateEngine, templateOptions?: TemplateOptions) { - super(INTERNAL_ERROR, templateOptions?.contentType ?? DEFAULT_TEMPLATE_OPTIONS.contentType); + super(INTERNAL_ERROR, templateOptions?.contentType ?? DEFAULT_TEMPLATE_OPTIONS.contentType!); // Workaround for https://github.com/LinkedSoftwareDependencies/Components.js/issues/20 if (!templateOptions || Object.keys(templateOptions).length === 0) { templateOptions = DEFAULT_TEMPLATE_OPTIONS; diff --git a/src/storage/conversion/FormToJsonConverter.ts b/src/storage/conversion/FormToJsonConverter.ts index a964d5cc9..48e8c8f4b 100644 --- a/src/storage/conversion/FormToJsonConverter.ts +++ b/src/storage/conversion/FormToJsonConverter.ts @@ -5,14 +5,14 @@ import { RepresentationMetadata } from '../../http/representation/Representation import { APPLICATION_JSON, APPLICATION_X_WWW_FORM_URLENCODED } from '../../util/ContentTypes'; import { readableToString } from '../../util/StreamUtil'; import { CONTENT_TYPE } from '../../util/Vocabularies'; +import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * Converts application/x-www-form-urlencoded data to application/json. * Due to the nature of form data, the result will be a simple key/value JSON object. */ -export class FormToJsonConverter extends TypedRepresentationConverter { +export class FormToJsonConverter extends BaseTypedRepresentationConverter { public constructor() { super(APPLICATION_X_WWW_FORM_URLENCODED, APPLICATION_JSON); } diff --git a/src/storage/conversion/IfNeededConverter.ts b/src/storage/conversion/IfNeededConverter.ts deleted file mode 100644 index 547cfa67b..000000000 --- a/src/storage/conversion/IfNeededConverter.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Representation } from '../../http/representation/Representation'; -import { getLoggerFor } from '../../logging/LogUtil'; -import { InternalServerError } from '../../util/errors/InternalServerError'; -import { UnsupportedAsyncHandler } from '../../util/handlers/UnsupportedAsyncHandler'; -import { matchesMediaPreferences } from './ConversionUtil'; -import { RepresentationConverter } from './RepresentationConverter'; -import type { RepresentationConverterArgs } from './RepresentationConverter'; - -const EMPTY_CONVERTER = new UnsupportedAsyncHandler('The content type does not match the preferences'); - -/** - * A {@link RepresentationConverter} that only converts representations - * that are not compatible with the preferences. - */ -export class IfNeededConverter extends RepresentationConverter { - private readonly converter: RepresentationConverter; - protected readonly logger = getLoggerFor(this); - - public constructor(converter: RepresentationConverter = EMPTY_CONVERTER) { - super(); - this.converter = converter; - } - - public async canHandle(args: RepresentationConverterArgs): Promise { - if (this.needsConversion(args)) { - await this.converter.canHandle(args); - } - } - - public async handle(args: RepresentationConverterArgs): Promise { - return !this.needsConversion(args) ? args.representation : this.convert(args, false); - } - - public async handleSafe(args: RepresentationConverterArgs): Promise { - return !this.needsConversion(args) ? args.representation : this.convert(args, true); - } - - protected needsConversion({ identifier, representation, preferences }: RepresentationConverterArgs): boolean { - // No conversion is needed if there are any matches for the provided content type - const { contentType } = representation.metadata; - if (!contentType) { - throw new InternalServerError('Content-Type is required for data conversion.'); - } - const noMatchingMediaType = !matchesMediaPreferences(contentType, preferences.type); - if (noMatchingMediaType) { - this.logger.debug(`Conversion needed for ${identifier - .path} from ${contentType} to satisfy ${!preferences.type ? - '""' : - Object.entries(preferences.type).map(([ value, weight ]): string => `${value};q=${weight}`).join(', ')}`); - } - return noMatchingMediaType; - } - - protected async convert(args: RepresentationConverterArgs, safely: boolean): Promise { - const converted = await (safely ? this.converter.handleSafe(args) : this.converter.handle(args)); - this.logger.info(`Converted representation for ${args.identifier - .path} from ${args.representation.metadata.contentType} to ${converted.metadata.contentType}`); - return converted; - } -} diff --git a/src/storage/conversion/MarkdownToHtmlConverter.ts b/src/storage/conversion/MarkdownToHtmlConverter.ts index 73c8da5bb..a06f10e8d 100644 --- a/src/storage/conversion/MarkdownToHtmlConverter.ts +++ b/src/storage/conversion/MarkdownToHtmlConverter.ts @@ -4,8 +4,8 @@ import type { Representation } from '../../http/representation/Representation'; import { TEXT_HTML, TEXT_MARKDOWN } from '../../util/ContentTypes'; import { readableToString } from '../../util/StreamUtil'; import type { TemplateEngine } from '../../util/templates/TemplateEngine'; +import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * Converts Markdown data to HTML. @@ -13,7 +13,7 @@ import { TypedRepresentationConverter } from './TypedRepresentationConverter'; * A standard Markdown string will be converted to a

tag, so html and body tags should be part of the template. * In case the Markdown body starts with a header (#), that value will also be used as `title` parameter. */ -export class MarkdownToHtmlConverter extends TypedRepresentationConverter { +export class MarkdownToHtmlConverter extends BaseTypedRepresentationConverter { private readonly templateEngine: TemplateEngine; public constructor(templateEngine: TemplateEngine) { diff --git a/src/storage/conversion/QuadToRdfConverter.ts b/src/storage/conversion/QuadToRdfConverter.ts index 512832b44..6432c6555 100644 --- a/src/storage/conversion/QuadToRdfConverter.ts +++ b/src/storage/conversion/QuadToRdfConverter.ts @@ -7,14 +7,14 @@ import type { ValuePreferences } from '../../http/representation/RepresentationP import { INTERNAL_QUADS } from '../../util/ContentTypes'; import { pipeSafely } from '../../util/StreamUtil'; import { PREFERRED_PREFIX_TERM } from '../../util/Vocabularies'; +import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter'; import { getConversionTarget } from './ConversionUtil'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * Converts `internal/quads` to most major RDF serializations. */ -export class QuadToRdfConverter extends TypedRepresentationConverter { +export class QuadToRdfConverter extends BaseTypedRepresentationConverter { private readonly outputPreferences?: ValuePreferences; public constructor(options: { outputPreferences?: Record } = {}) { @@ -27,7 +27,7 @@ export class QuadToRdfConverter extends TypedRepresentationConverter { public async handle({ identifier, representation: quads, preferences }: RepresentationConverterArgs): Promise { // Can not be undefined if the `canHandle` call passed - const contentType = getConversionTarget(await this.getOutputTypes(), preferences.type)!; + const contentType = getConversionTarget(await this.getOutputTypes(INTERNAL_QUADS), preferences.type)!; let data: Readable; // Use prefixes if possible (see https://github.com/rubensworks/rdf-serialize.js/issues/1) @@ -36,7 +36,7 @@ export class QuadToRdfConverter extends TypedRepresentationConverter { .map(({ subject, object }): [string, string] => [ object.value, subject.value ])); const options = { format: contentType, baseIRI: identifier.path, prefixes }; data = pipeSafely(quads.data, new StreamWriter(options)); - // Otherwise, write without prefixes + // Otherwise, write without prefixes } else { data = rdfSerializer.serialize(quads.data, { contentType }) as Readable; } diff --git a/src/storage/conversion/RdfToQuadConverter.ts b/src/storage/conversion/RdfToQuadConverter.ts index 521cd4865..07f8f3974 100644 --- a/src/storage/conversion/RdfToQuadConverter.ts +++ b/src/storage/conversion/RdfToQuadConverter.ts @@ -5,15 +5,15 @@ import type { Representation } from '../../http/representation/Representation'; import { INTERNAL_QUADS } from '../../util/ContentTypes'; import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; import { pipeSafely } from '../../util/StreamUtil'; +import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * Converts most major RDF serializations to `internal/quads`. */ -export class RdfToQuadConverter extends TypedRepresentationConverter { +export class RdfToQuadConverter extends BaseTypedRepresentationConverter { public constructor() { - super(rdfParser.getContentTypesPrioritized(), INTERNAL_QUADS); + super(rdfParser.getContentTypes(), INTERNAL_QUADS); } public async handle({ representation, identifier }: RepresentationConverterArgs): Promise { diff --git a/src/storage/conversion/TypedRepresentationConverter.ts b/src/storage/conversion/TypedRepresentationConverter.ts index 75eb7e00f..295664c9c 100644 --- a/src/storage/conversion/TypedRepresentationConverter.ts +++ b/src/storage/conversion/TypedRepresentationConverter.ts @@ -1,76 +1,12 @@ import type { ValuePreferences } from '../../http/representation/RepresentationPreferences'; -import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; -import { getConversionTarget, getTypeWeight } from './ConversionUtil'; import { RepresentationConverter } from './RepresentationConverter'; -import type { RepresentationConverterArgs } from './RepresentationConverter'; - -type PromiseOrValue = T | Promise; -type ValuePreferencesArg = - PromiseOrValue | - PromiseOrValue | - PromiseOrValue; - -async function toValuePreferences(arg: ValuePreferencesArg): Promise { - const resolved = await arg; - if (typeof resolved === 'string') { - return { [resolved]: 1 }; - } - if (Array.isArray(resolved)) { - return Object.fromEntries(resolved.map((type): [string, number] => [ type, 1 ])); - } - return resolved; -} /** * A {@link RepresentationConverter} that allows requesting the supported types. */ export abstract class TypedRepresentationConverter extends RepresentationConverter { - protected inputTypes: Promise; - protected outputTypes: Promise; - - public constructor(inputTypes: ValuePreferencesArg = {}, outputTypes: ValuePreferencesArg = {}) { - super(); - this.inputTypes = toValuePreferences(inputTypes); - this.outputTypes = toValuePreferences(outputTypes); - } - /** - * Gets the supported input content types for this converter, mapped to a numerical priority. + * Gets the output content types this converter can convert the input type to, mapped to a numerical priority. */ - public async getInputTypes(): Promise { - return this.inputTypes; - } - - /** - * Gets the supported output content types for this converter, mapped to a numerical quality. - */ - public async getOutputTypes(): Promise { - return this.outputTypes; - } - - /** - * Determines whether the given conversion request is supported, - * given the available content type conversions: - * - Checks if there is a content type for the input. - * - Checks if the input type is supported by the parser. - * - Checks if the parser can produce one of the preferred output types. - * Throws an error with details if conversion is not possible. - */ - public async canHandle(args: RepresentationConverterArgs): Promise { - const types = [ this.getInputTypes(), this.getOutputTypes() ]; - const { contentType } = args.representation.metadata; - - if (!contentType) { - throw new NotImplementedHttpError('Can not convert data without a Content-Type.'); - } - - const [ inputTypes, outputTypes ] = await Promise.all(types); - const outputPreferences = args.preferences.type ?? {}; - if (getTypeWeight(contentType, inputTypes) === 0 || !getConversionTarget(outputTypes, outputPreferences)) { - throw new NotImplementedHttpError( - `Cannot convert from ${contentType} to ${Object.keys(outputPreferences) - }, only from ${Object.keys(inputTypes)} to ${Object.keys(outputTypes)}.`, - ); - } - } + public abstract getOutputTypes(contentType: string): Promise; } diff --git a/src/storage/patch/N3Patcher.ts b/src/storage/patch/N3Patcher.ts new file mode 100644 index 000000000..ac4f0a9df --- /dev/null +++ b/src/storage/patch/N3Patcher.ts @@ -0,0 +1,160 @@ +import type { Readable } from 'stream'; +import { newEngine } from '@comunica/actor-init-sparql'; +import type { ActorInitSparql } from '@comunica/actor-init-sparql'; +import type { IQueryResultBindings } from '@comunica/actor-init-sparql/lib/ActorInitSparql-browser'; +import { Store } from 'n3'; +import type { Quad, Term } from 'rdf-js'; +import { mapTerms } from 'rdf-terms'; +import { Generator, Wildcard } from 'sparqljs'; +import type { SparqlGenerator } from 'sparqljs'; +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import { isN3Patch } from '../../http/representation/N3Patch'; +import type { N3Patch } from '../../http/representation/N3Patch'; +import type { Representation } from '../../http/representation/Representation'; +import { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import { getLoggerFor } from '../../logging/LogUtil'; +import { INTERNAL_QUADS } from '../../util/ContentTypes'; +import { ConflictHttpError } from '../../util/errors/ConflictHttpError'; +import { InternalServerError } from '../../util/errors/InternalServerError'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { uniqueQuads } from '../../util/QuadUtil'; +import { readableToQuads } from '../../util/StreamUtil'; +import type { RepresentationPatcherInput } from './RepresentationPatcher'; +import { RepresentationPatcher } from './RepresentationPatcher'; + +/** + * Applies an N3 Patch to a representation, or creates a new one if required. + * Follows all the steps from Solid, §5.3.1: https://solid.github.io/specification/protocol#n3-patch + */ +export class N3Patcher extends RepresentationPatcher { + protected readonly logger = getLoggerFor(this); + + private readonly engine: ActorInitSparql; + private readonly generator: SparqlGenerator; + + public constructor() { + super(); + this.engine = newEngine(); + this.generator = new Generator(); + } + + public async canHandle({ patch }: RepresentationPatcherInput): Promise { + if (!isN3Patch(patch)) { + throw new NotImplementedHttpError('Only N3 Patch updates are supported'); + } + } + + public async handle(input: RepresentationPatcherInput): Promise { + const patch = input.patch as N3Patch; + + // No work to be done if the patch is empty + if (patch.deletes.length === 0 && patch.inserts.length === 0 && patch.conditions.length === 0) { + this.logger.debug('Empty patch, returning input.'); + return input.representation ?? new BasicRepresentation([], input.identifier, INTERNAL_QUADS, false); + } + + if (input.representation && input.representation.metadata.contentType !== INTERNAL_QUADS) { + this.logger.error('Received non-quad data. This should not happen so there is probably a configuration error.'); + throw new InternalServerError('Quad stream was expected for patching.'); + } + + return this.patch(input); + } + + /** + * Applies the given N3Patch to the representation. + * First the conditions are applied to find the necessary bindings, + * which are then applied to generate the triples that need to be deleted and inserted. + * After that the delete and insert operations are applied. + */ + private async patch({ identifier, patch, representation }: RepresentationPatcherInput): Promise { + const result = representation ? await readableToQuads(representation.data) : new Store(); + this.logger.debug(`${result.size} quads in ${identifier.path}.`); + + const { deletes, inserts } = await this.applyConditions(patch as N3Patch, identifier, result); + + // Apply deletes + if (deletes.length > 0) { + // There could potentially be duplicates after applying conditions, + // which would result in an incorrect count. + const uniqueDeletes = uniqueQuads(deletes); + // Solid, §5.3.1: "The triples resulting from ?deletions are to be removed from the RDF dataset." + const oldSize = result.size; + result.removeQuads(uniqueDeletes); + + // Solid, §5.3.1: "If the set of triples resulting from ?deletions is non-empty and the dataset + // does not contain all of these triples, the server MUST respond with a 409 status code." + if (oldSize - result.size !== uniqueDeletes.length) { + throw new ConflictHttpError( + 'The document does not contain all triples the N3 Patch requests to delete, which is required for patching.', + ); + } + this.logger.debug(`Deleted ${oldSize - result.size} quads from ${identifier.path}.`); + } + + // Solid, §5.3.1: "The triples resulting from ?insertions are to be added to the RDF dataset, + // with each blank node from ?insertions resulting in a newly created blank node." + result.addQuads(inserts); + + this.logger.debug(`${result.size} total quads after patching ${identifier.path}.`); + + const metadata = representation?.metadata ?? new RepresentationMetadata(identifier, INTERNAL_QUADS); + return new BasicRepresentation(result.match() as unknown as Readable, metadata, false); + } + + /** + * Creates a new N3Patch where the conditions of the provided patch parameter are applied to its deletes and inserts. + * Also does the necessary checks to make sure the conditions are valid for the given dataset. + */ + private async applyConditions(patch: N3Patch, identifier: ResourceIdentifier, source: Store): Promise { + const { conditions } = patch; + let { deletes, inserts } = patch; + + if (conditions.length > 0) { + // Solid, §5.3.1: "If ?conditions is non-empty, find all (possibly empty) variable mappings + // such that all of the resulting triples occur in the dataset." + const sparql = this.generator.stringify({ + type: 'query', + queryType: 'SELECT', + variables: [ new Wildcard() ], + prefixes: {}, + where: [{ + type: 'bgp', + triples: conditions, + }], + }); + this.logger.debug(`Finding bindings using SPARQL query ${sparql}`); + const query = await this.engine.query(sparql, + { sources: [ source ], baseIRI: identifier.path }) as IQueryResultBindings; + const bindings = await query.bindings(); + + // Solid, §5.3.1: "If no such mapping exists, or if multiple mappings exist, + // the server MUST respond with a 409 status code." + if (bindings.length === 0) { + throw new ConflictHttpError( + 'The document does not contain any matches for the N3 Patch solid:where condition.', + ); + } + if (bindings.length > 1) { + throw new ConflictHttpError( + 'The document contains multiple matches for the N3 Patch solid:where condition, which is not allowed.', + ); + } + + // Apply bindings to deletes/inserts + // Note that Comunica binding keys start with a `?` while Variable terms omit that in their value + deletes = deletes.map((quad): Quad => mapTerms(quad, (term): Term => + term.termType === 'Variable' ? bindings[0].get(`?${term.value}`) : term)); + inserts = inserts.map((quad): Quad => mapTerms(quad, (term): Term => + term.termType === 'Variable' ? bindings[0].get(`?${term.value}`) : term)); + } + + return { + ...patch, + deletes, + inserts, + conditions: [], + }; + } +} diff --git a/src/storage/patch/SparqlUpdatePatcher.ts b/src/storage/patch/SparqlUpdatePatcher.ts index 08bcea85c..482f61479 100644 --- a/src/storage/patch/SparqlUpdatePatcher.ts +++ b/src/storage/patch/SparqlUpdatePatcher.ts @@ -3,7 +3,6 @@ import type { ActorInitSparql } from '@comunica/actor-init-sparql'; import { newEngine } from '@comunica/actor-init-sparql'; import type { IQueryResultUpdate } from '@comunica/actor-init-sparql/lib/ActorInitSparql-browser'; import { DataFactory, Store } from 'n3'; -import type { BaseQuad } from 'rdf-js'; import { Algebra } from 'sparqlalgebrajs'; import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; import type { Patch } from '../../http/representation/Patch'; @@ -49,6 +48,10 @@ export class SparqlUpdatePatcher extends RepresentationPatcher { return representation ?? new BasicRepresentation([], identifier, INTERNAL_QUADS, false); } + if (representation && representation.metadata.contentType !== INTERNAL_QUADS) { + throw new InternalServerError('Quad stream was expected for patching.'); + } + this.validateUpdate(op); return this.patch(input); @@ -115,20 +118,8 @@ export class SparqlUpdatePatcher extends RepresentationPatcher { * Apply the given algebra operation to the given identifier. */ private async patch({ identifier, patch, representation }: RepresentationPatcherInput): Promise { - let result: Store; - let metadata: RepresentationMetadata; - - if (representation) { - ({ metadata } = representation); - if (metadata.contentType !== INTERNAL_QUADS) { - throw new InternalServerError('Quad stream was expected for patching.'); - } - result = await readableToQuads(representation.data); - this.logger.debug(`${result.size} quads in ${identifier.path}.`); - } else { - metadata = new RepresentationMetadata(identifier, INTERNAL_QUADS); - result = new Store(); - } + const result = representation ? await readableToQuads(representation.data) : new Store(); + this.logger.debug(`${result.size} quads in ${identifier.path}.`); // Run the query through Comunica const sparql = await readableToString(patch.data); @@ -138,6 +129,7 @@ export class SparqlUpdatePatcher extends RepresentationPatcher { this.logger.debug(`${result.size} quads will be stored to ${identifier.path}.`); + const metadata = representation?.metadata ?? new RepresentationMetadata(identifier, INTERNAL_QUADS); return new BasicRepresentation(result.match() as unknown as Readable, metadata, false); } } diff --git a/src/storage/quota/GlobalQuotaStrategy.ts b/src/storage/quota/GlobalQuotaStrategy.ts new file mode 100644 index 000000000..0800cbb3d --- /dev/null +++ b/src/storage/quota/GlobalQuotaStrategy.ts @@ -0,0 +1,19 @@ +import type { Size } from '../size-reporter/Size'; +import type { SizeReporter } from '../size-reporter/SizeReporter'; +import { QuotaStrategy } from './QuotaStrategy'; + +/** + * The GlobalQuotaStrategy sets a limit on the amount of data stored on the server globally. + */ +export class GlobalQuotaStrategy extends QuotaStrategy { + private readonly base: string; + + public constructor(limit: Size, reporter: SizeReporter, base: string) { + super(reporter, limit); + this.base = base; + } + + protected async getTotalSpaceUsed(): Promise { + return this.reporter.getSize({ path: this.base }); + } +} diff --git a/src/storage/quota/PodQuotaStrategy.ts b/src/storage/quota/PodQuotaStrategy.ts new file mode 100644 index 000000000..803d59501 --- /dev/null +++ b/src/storage/quota/PodQuotaStrategy.ts @@ -0,0 +1,66 @@ +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; +import type { IdentifierStrategy } from '../../util/identifiers/IdentifierStrategy'; +import { RDF, PIM } from '../../util/Vocabularies'; +import type { DataAccessor } from '../accessors/DataAccessor'; +import type { Size } from '../size-reporter/Size'; +import type { SizeReporter } from '../size-reporter/SizeReporter'; +import { QuotaStrategy } from './QuotaStrategy'; + +/** + * The PodQuotaStrategy sets a limit on the amount of data stored on a per pod basis + */ +export class PodQuotaStrategy extends QuotaStrategy { + private readonly identifierStrategy: IdentifierStrategy; + private readonly accessor: DataAccessor; + + public constructor( + limit: Size, + reporter: SizeReporter, + identifierStrategy: IdentifierStrategy, + accessor: DataAccessor, + ) { + super(reporter, limit); + this.identifierStrategy = identifierStrategy; + this.accessor = accessor; + } + + protected async getTotalSpaceUsed(identifier: ResourceIdentifier): Promise { + const pimStorage = await this.searchPimStorage(identifier); + + // No storage was found containing this identifier, so we assume this identifier points to an internal location. + // Quota does not apply here so there is always available space. + if (!pimStorage) { + return { amount: Number.MAX_SAFE_INTEGER, unit: this.limit.unit }; + } + + return this.reporter.getSize(pimStorage); + } + + /** Finds the closest parent container that has pim:storage as metadata */ + private async searchPimStorage(identifier: ResourceIdentifier): Promise { + if (this.identifierStrategy.isRootContainer(identifier)) { + return; + } + + let metadata: RepresentationMetadata; + const parent = this.identifierStrategy.getParentContainer(identifier); + + try { + metadata = await this.accessor.getMetadata(identifier); + } catch (error: unknown) { + if (error instanceof NotFoundHttpError) { + // Resource and/or its metadata do not exist + return this.searchPimStorage(parent); + } + throw error; + } + + const hasPimStorageMetadata = metadata!.getAll(RDF.type) + .some((term): boolean => term.value === PIM.Storage); + + return hasPimStorageMetadata ? identifier : this.searchPimStorage(parent); + } +} + diff --git a/src/storage/quota/QuotaStrategy.ts b/src/storage/quota/QuotaStrategy.ts new file mode 100644 index 000000000..2877cdd24 --- /dev/null +++ b/src/storage/quota/QuotaStrategy.ts @@ -0,0 +1,105 @@ +// These two eslint lines are needed to store 'this' in a variable so it can be used +// in the PassThrough of createQuotaGuard +/* eslint-disable @typescript-eslint/no-this-alias */ +/* eslint-disable consistent-this */ +import { PassThrough } from 'stream'; +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import { PayloadHttpError } from '../../util/errors/PayloadHttpError'; +import type { Guarded } from '../../util/GuardedStream'; +import { guardStream } from '../../util/GuardedStream'; +import type { Size } from '../size-reporter/Size'; +import type { SizeReporter } from '../size-reporter/SizeReporter'; + +/** + * A QuotaStrategy is used when we want to set a limit to the amount of data that can be + * stored on the server. + * This can range from a limit for the whole server to a limit on a per pod basis. + * The way the size of a resource is calculated is implemented by the implementing classes. + * This can be bytes, quads, file count, ... + */ +export abstract class QuotaStrategy { + public readonly reporter: SizeReporter; + public readonly limit: Size; + + public constructor(reporter: SizeReporter, limit: Size) { + this.reporter = reporter; + this.limit = limit; + } + + /** + * Get the available space when writing data to the given identifier. + * If the given resource already exists it will deduct the already taken up + * space by that resource since it is going to be overwritten and thus counts + * as available space. + * + * @param identifier - the identifier of the resource of which you want the available space + * @returns the available space and the unit of the space as a Size object + */ + public async getAvailableSpace(identifier: ResourceIdentifier): Promise { + const totalUsed = await this.getTotalSpaceUsed(identifier); + + // Ignore identifiers where quota does not apply + if (totalUsed.amount === Number.MAX_SAFE_INTEGER) { + return totalUsed; + } + + // When a file is overwritten the space the file takes up right now should also + // be counted as available space as it will disappear/be overwritten + totalUsed.amount -= (await this.reporter.getSize(identifier)).amount; + + return { + amount: this.limit.amount - totalUsed.amount, + unit: this.limit.unit, + }; + } + + /** + * Get the currently used/occupied space. + * + * @param identifier - the identifier that should be used to calculate the total + * @returns a Size object containing the requested value. + * If quota is not relevant for this identifier, Size.amount should be Number.MAX_SAFE_INTEGER + */ + protected abstract getTotalSpaceUsed(identifier: ResourceIdentifier): Promise; + + /** + * Get an estimated size of the resource + * + * @param metadata - the metadata that might include the size + * @returns a Size object containing the estimated size and unit of the resource + */ + public async estimateSize(metadata: RepresentationMetadata): Promise { + const estimate = await this.reporter.estimateSize(metadata); + return estimate ? { unit: this.limit.unit, amount: estimate } : undefined; + } + + /** + * Get a Passthrough stream that will keep track of the available space. + * If the quota is exceeded the stream will emit an error and destroy itself. + * Like other Passthrough instances this will simply pass on the chunks, when the quota isn't exceeded. + * + * @param identifier - the identifier of the resource in question + * @returns a Passthrough instance that errors when quota is exceeded + */ + public async createQuotaGuard(identifier: ResourceIdentifier): Promise> { + let total = 0; + const strategy = this; + const { reporter } = this; + + return guardStream(new PassThrough({ + async transform(this, chunk: any, enc: string, done: () => void): Promise { + total += await reporter.calculateChunkSize(chunk); + const availableSpace = await strategy.getAvailableSpace(identifier); + if (availableSpace.amount < total) { + this.destroy(new PayloadHttpError( + `Quota exceeded by ${total - availableSpace.amount} ${availableSpace.unit} during write`, + )); + } + + this.push(chunk); + done(); + }, + })); + } +} diff --git a/src/storage/size-reporter/FileSizeReporter.ts b/src/storage/size-reporter/FileSizeReporter.ts new file mode 100644 index 000000000..153168677 --- /dev/null +++ b/src/storage/size-reporter/FileSizeReporter.ts @@ -0,0 +1,87 @@ +import type { Stats } from 'fs'; +import { promises as fsPromises } from 'fs'; +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import { joinFilePath, normalizeFilePath, trimTrailingSlashes } from '../../util/PathUtil'; +import type { FileIdentifierMapper } from '../mapping/FileIdentifierMapper'; +import type { Size } from './Size'; +import { UNIT_BYTES } from './Size'; +import type { SizeReporter } from './SizeReporter'; + +/** + * SizeReporter that is used to calculate sizes of resources for a file based system. + */ +export class FileSizeReporter implements SizeReporter { + private readonly fileIdentifierMapper: FileIdentifierMapper; + private readonly ignoreFolders: RegExp[]; + private readonly rootFilePath: string; + + public constructor(fileIdentifierMapper: FileIdentifierMapper, rootFilePath: string, ignoreFolders?: string[]) { + this.fileIdentifierMapper = fileIdentifierMapper; + this.ignoreFolders = ignoreFolders ? ignoreFolders.map((folder: string): RegExp => new RegExp(folder, 'u')) : []; + this.rootFilePath = normalizeFilePath(rootFilePath); + } + + /** The FileSizeReporter will always return data in the form of bytes */ + public getUnit(): string { + return UNIT_BYTES; + } + + /** + * Returns the size of the given resource ( and its children ) in bytes + */ + public async getSize(identifier: ResourceIdentifier): Promise { + const fileLocation = (await this.fileIdentifierMapper.mapUrlToFilePath(identifier, false)).filePath; + + return { unit: this.getUnit(), amount: await this.getTotalSize(fileLocation) }; + } + + public async calculateChunkSize(chunk: string): Promise { + return chunk.length; + } + + /** The estimated size of a resource in this reporter is simply the content-length header */ + public async estimateSize(metadata: RepresentationMetadata): Promise { + return metadata.contentLength; + } + + /** + * Get the total size of a resource and its children if present + * + * @param fileLocation - the resource of which you want the total size of ( on disk ) + * @returns a number specifying how many bytes are used by the resource + */ + private async getTotalSize(fileLocation: string): Promise { + let stat: Stats; + + // Check if the file exists + try { + stat = await fsPromises.stat(fileLocation); + } catch { + return 0; + } + + // If the file's location points to a file, simply return the file's size + if (stat.isFile()) { + return stat.size; + } + + // If the location DOES exist and is NOT a file it should be a directory + // recursively add all sizes of children to the total + const childFiles = await fsPromises.readdir(fileLocation); + const rootFilePathLength = trimTrailingSlashes(this.rootFilePath).length; + + return await childFiles.reduce(async(acc: Promise, current): Promise => { + const childFileLocation = normalizeFilePath(joinFilePath(fileLocation, current)); + let result = await acc; + + // Exclude internal files + if (!this.ignoreFolders.some((folder: RegExp): boolean => + folder.test(childFileLocation.slice(rootFilePathLength)))) { + result += await this.getTotalSize(childFileLocation); + } + + return result; + }, Promise.resolve(stat.size)); + } +} diff --git a/src/storage/size-reporter/Size.ts b/src/storage/size-reporter/Size.ts new file mode 100644 index 000000000..26987179d --- /dev/null +++ b/src/storage/size-reporter/Size.ts @@ -0,0 +1,9 @@ +/** + * Describes the size of something by stating how much of a certain unit is present. + */ +export interface Size { + unit: string; + amount: number; +} + +export const UNIT_BYTES = 'bytes'; diff --git a/src/storage/size-reporter/SizeReporter.ts b/src/storage/size-reporter/SizeReporter.ts new file mode 100644 index 000000000..30ec5d59b --- /dev/null +++ b/src/storage/size-reporter/SizeReporter.ts @@ -0,0 +1,44 @@ +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import type { Size } from './Size'; + +/** + * A SizeReporter's only purpose (at the moment) is to calculate the size + * of a resource. How the size is calculated or what unit it is in is defined by + * the class implementing this interface. + * One might use the amount of bytes and another might use the amount of triples + * stored in a resource. + */ +export interface SizeReporter { + + /** + * Get the unit as a string in which a SizeReporter returns data + */ + getUnit: () => string; + + /** + * Get the size of a given resource + * + * @param identifier - the resource of which you want the size + * @returns The size of the resource as a Size object calculated recursively + * if the identifier leads to a container + */ + getSize: (identifier: ResourceIdentifier) => Promise; + + /** + * Calculate the size of a chunk based on which SizeReporter is being used + * + * @param chunk - the chunk of which you want the size + * @returns the size of the passed chunk as a number + */ + calculateChunkSize: (chunk: T) => Promise; + + /** + * Estimate the size of a body / request by looking at its metadata + * + * @param metadata - the metadata of the resource you want an estimated size of + * @returns the estimated size of the body / request or undefined if no + * meaningful estimation can be made + */ + estimateSize: (metadata: RepresentationMetadata) => Promise; +} diff --git a/src/storage/validators/QuotaValidator.ts b/src/storage/validators/QuotaValidator.ts new file mode 100644 index 000000000..f0993a0ed --- /dev/null +++ b/src/storage/validators/QuotaValidator.ts @@ -0,0 +1,61 @@ +import { Readable, PassThrough } from 'stream'; +import { Validator } from '../../http/auxiliary/Validator'; +import type { ValidatorInput } from '../../http/auxiliary/Validator'; +import type { Representation } from '../../http/representation/Representation'; +import { PayloadHttpError } from '../../util/errors/PayloadHttpError'; +import type { Guarded } from '../../util/GuardedStream'; +import { guardStream } from '../../util/GuardedStream'; +import { pipeSafely } from '../../util/StreamUtil'; +import type { QuotaStrategy } from '../quota/QuotaStrategy'; + +/** + * The QuotaValidator validates data streams by making sure they would not exceed the limits of a QuotaStrategy. + */ +export class QuotaValidator extends Validator { + private readonly strategy: QuotaStrategy; + + public constructor(strategy: QuotaStrategy) { + super(); + this.strategy = strategy; + } + + public async handle({ representation, identifier }: ValidatorInput): Promise { + const { data, metadata } = representation; + + // 1. Get the available size + const availableSize = await this.strategy.getAvailableSpace(identifier); + + // 2. Check if the estimated size is bigger then the available size + const estimatedSize = await this.strategy.estimateSize(metadata); + + if (estimatedSize && availableSize.amount < estimatedSize.amount) { + return { + ...representation, + data: guardStream(new Readable({ + read(this): void { + this.destroy(new PayloadHttpError( + `Quota exceeded: Advertised Content-Length is ${estimatedSize.amount} ${estimatedSize.unit} ` + + `and only ${availableSize.amount} ${availableSize.unit} is available`, + )); + }, + })), + }; + } + + // 3. Track if quota is exceeded during writing + const tracking: Guarded = await this.strategy.createQuotaGuard(identifier); + + // 4. Double check quota is not exceeded after write (concurrent writing possible) + const afterWrite = new PassThrough({ + flush: async(done): Promise => { + const availableSpace = (await this.strategy.getAvailableSpace(identifier)).amount; + done(availableSpace < 0 ? new PayloadHttpError('Quota exceeded after write completed') : undefined); + }, + }); + + return { + ...representation, + data: pipeSafely(pipeSafely(data, tracking), afterWrite), + }; + } +} diff --git a/src/util/ContentTypes.ts b/src/util/ContentTypes.ts index e45cccca8..47481171d 100644 --- a/src/util/ContentTypes.ts +++ b/src/util/ContentTypes.ts @@ -5,6 +5,7 @@ export const APPLICATION_SPARQL_UPDATE = 'application/sparql-update'; export const APPLICATION_X_WWW_FORM_URLENCODED = 'application/x-www-form-urlencoded'; export const TEXT_HTML = 'text/html'; export const TEXT_MARKDOWN = 'text/markdown'; +export const TEXT_N3 = 'text/n3'; export const TEXT_TURTLE = 'text/turtle'; // Internal content types (not exposed over HTTP) diff --git a/src/util/FetchUtil.ts b/src/util/FetchUtil.ts index 9d1c317a3..5398dede5 100644 --- a/src/util/FetchUtil.ts +++ b/src/util/FetchUtil.ts @@ -1,5 +1,8 @@ +import type { Readable } from 'stream'; +import type { Quad } from '@rdfjs/types'; +import arrayifyStream from 'arrayify-stream'; import type { Response } from 'cross-fetch'; -import { fetch } from 'cross-fetch'; +import rdfDereferencer from 'rdf-dereference'; import { BasicRepresentation } from '../http/representation/BasicRepresentation'; import type { Representation } from '../http/representation/Representation'; import { getLoggerFor } from '../logging/LogUtil'; @@ -12,24 +15,32 @@ const logger = getLoggerFor('FetchUtil'); /** * Fetches an RDF dataset from the given URL. - * Input can also be a Response if the request was already made. + * + * Response will be a Representation with content-type internal/quads. + */ +export async function fetchDataset(url: string): Promise { + // Try content negotiation to parse quads from the URL + return (async(): Promise => { + try { + const quadStream = (await rdfDereferencer.dereference(url)).quads as Readable; + const quadArray = await arrayifyStream(quadStream) as Quad[]; + return new BasicRepresentation(quadArray, { path: url }, INTERNAL_QUADS, false); + } catch { + throw new BadRequestHttpError(`Could not parse resource at URL (${url})!`); + } + })(); +} + +/** + * Converts a given Response (from a request that was already made) to an RDF dataset. * In case the given Response object was already parsed its body can be passed along as a string. * * The converter will be used to convert the response body to RDF. * * Response will be a Representation with content-type internal/quads. */ -export async function fetchDataset(url: string, converter: RepresentationConverter): Promise; -export async function fetchDataset(response: Response, converter: RepresentationConverter, body?: string): -Promise; -export async function fetchDataset(input: string | Response, converter: RepresentationConverter, body?: string): +export async function responseToDataset(response: Response, converter: RepresentationConverter, body?: string): Promise { - let response: Response; - if (typeof input === 'string') { - response = await fetch(input); - } else { - response = input; - } if (!body) { body = await response.text(); } diff --git a/src/util/PathUtil.ts b/src/util/PathUtil.ts index c5078e4eb..29a1520d8 100644 --- a/src/util/PathUtil.ts +++ b/src/util/PathUtil.ts @@ -197,18 +197,33 @@ export function getModuleRoot(): string { /** * A placeholder for the path to the `@solid/community-server` module root. - * The resolveAssetPath function will replace this string with the actual path. + * The `resolveAssetPath` function will replace this string with the actual path. */ export const modulePathPlaceholder = '@css:'; +/** + * Creates a path starting from the `@solid/community-server` module root, + * to be resolved by the `resolveAssetPath` function. + */ +export function modulePath(relativePath = ''): string { + return `${modulePathPlaceholder}${relativePath}`; +} + +/** + * Creates an absolute path starting from the `@solid/community-server` module root. + */ +export function resolveModulePath(relativePath = ''): string { + return joinFilePath(getModuleRoot(), relativePath); +} + /** * Converts file path inputs into absolute paths. * Works similar to `absoluteFilePath` but paths that start with the `modulePathPlaceholder` * will be relative to the module directory instead of the cwd. */ -export function resolveAssetPath(path: string = modulePathPlaceholder): string { +export function resolveAssetPath(path = modulePathPlaceholder): string { if (path.startsWith(modulePathPlaceholder)) { - return joinFilePath(getModuleRoot(), path.slice(modulePathPlaceholder.length)); + return resolveModulePath(path.slice(modulePathPlaceholder.length)); } return absoluteFilePath(path); } diff --git a/src/util/QuadUtil.ts b/src/util/QuadUtil.ts index eb9b81f9d..9f50029a7 100644 --- a/src/util/QuadUtil.ts +++ b/src/util/QuadUtil.ts @@ -6,6 +6,13 @@ import type { Quad } from 'rdf-js'; import type { Guarded } from './GuardedStream'; import { guardedStreamFrom, pipeSafely } from './StreamUtil'; +/** + * Helper function for serializing an array of quads, with as result a Readable object. + * @param quads - The array of quads. + * @param contentType - The content-type to serialize to. + * + * @returns The Readable object. + */ export function serializeQuads(quads: Quad[], contentType?: string): Guarded { return pipeSafely(guardedStreamFrom(quads), new StreamWriter({ format: contentType })); } @@ -20,3 +27,18 @@ export function serializeQuads(quads: Quad[], contentType?: string): Guarded, options: ParserOptions = {}): Promise { return arrayifyStream(pipeSafely(readable, new StreamParser(options))); } + +/** + * Filter out duplicate quads from an array. + * @param quads - Quads to filter. + * + * @returns A new array containing the unique quads. + */ +export function uniqueQuads(quads: Quad[]): Quad[] { + return quads.reduce((result, quad): Quad[] => { + if (!result.some((item): boolean => quad.equals(item))) { + result.push(quad); + } + return result; + }, []); +} diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index 826680099..d4515fc26 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -86,6 +86,10 @@ export const FOAF = createUriAndTermNamespace('http://xmlns.com/foaf/0.1/', 'Agent', ); +export const HH = createUriAndTermNamespace('http://www.w3.org/2011/http-headers#', + 'content-length', +); + export const HTTP = createUriAndTermNamespace('http://www.w3.org/2011/http#', 'statusCodeNumber', ); @@ -120,9 +124,14 @@ export const RDF = createUriAndTermNamespace('http://www.w3.org/1999/02/22-rdf-s ); export const SOLID = createUriAndTermNamespace('http://www.w3.org/ns/solid/terms#', + 'deletes', + 'inserts', 'oidcIssuer', 'oidcIssuerRegistrationToken', 'oidcRegistration', + 'where', + + 'InsertDeletePatch', ); export const SOLID_ERROR = createUriAndTermNamespace('urn:npm:solid:community-server:error:', @@ -155,6 +164,7 @@ export const XSD = createUriAndTermNamespace('http://www.w3.org/2001/XMLSchema#' ); // Alias for commonly used types +export const CONTENT_LENGTH_TERM = HH.terms['content-length']; export const CONTENT_TYPE = MA.format; export const CONTENT_TYPE_TERM = MA.terms.format; export const PREFERRED_PREFIX = VANN.preferredNamespacePrefix; diff --git a/src/util/errors/FoundHttpError.ts b/src/util/errors/FoundHttpError.ts new file mode 100644 index 000000000..9e33035d1 --- /dev/null +++ b/src/util/errors/FoundHttpError.ts @@ -0,0 +1,15 @@ +import type { HttpErrorOptions } from './HttpError'; +import { RedirectHttpError } from './RedirectHttpError'; + +/** + * Error used for resources that have been moved temporarily. + */ +export class FoundHttpError extends RedirectHttpError { + public constructor(location: string, message?: string, options?: HttpErrorOptions) { + super(302, location, 'FoundHttpError', message, options); + } + + public static isInstance(error: any): error is FoundHttpError { + return RedirectHttpError.isInstance(error) && error.statusCode === 302; + } +} diff --git a/src/util/errors/MovedPermanentlyHttpError.ts b/src/util/errors/MovedPermanentlyHttpError.ts new file mode 100644 index 000000000..70f88f243 --- /dev/null +++ b/src/util/errors/MovedPermanentlyHttpError.ts @@ -0,0 +1,15 @@ +import type { HttpErrorOptions } from './HttpError'; +import { RedirectHttpError } from './RedirectHttpError'; + +/** + * Error used for resources that have been moved permanently. + */ +export class MovedPermanentlyHttpError extends RedirectHttpError { + public constructor(location: string, message?: string, options?: HttpErrorOptions) { + super(301, location, 'MovedPermanentlyHttpError', message, options); + } + + public static isInstance(error: any): error is MovedPermanentlyHttpError { + return RedirectHttpError.isInstance(error) && error.statusCode === 301; + } +} diff --git a/src/util/errors/PayloadHttpError.ts b/src/util/errors/PayloadHttpError.ts new file mode 100644 index 000000000..b8fad8b5f --- /dev/null +++ b/src/util/errors/PayloadHttpError.ts @@ -0,0 +1,23 @@ +import type { HttpErrorOptions } from './HttpError'; +import { HttpError } from './HttpError'; + +/** + * An error thrown when data exceeded the pre configured quota + */ +export class PayloadHttpError extends HttpError { + /** + * Default message is 'Storage quota was exceeded.'. + * @param message - Optional, more specific, message. + * @param options - Optional error options. + */ + public constructor(message?: string, options?: HttpErrorOptions) { + super(413, + 'PayloadHttpError', + message ?? 'Storage quota was exceeded.', + options); + } + + public static isInstance(error: any): error is PayloadHttpError { + return HttpError.isInstance(error) && error.statusCode === 413; + } +} diff --git a/src/util/errors/RedirectHttpError.ts b/src/util/errors/RedirectHttpError.ts new file mode 100644 index 000000000..d9012b774 --- /dev/null +++ b/src/util/errors/RedirectHttpError.ts @@ -0,0 +1,19 @@ +import type { HttpErrorOptions } from './HttpError'; +import { HttpError } from './HttpError'; + +/** + * Abstract class representing a 3xx redirect. + */ +export abstract class RedirectHttpError extends HttpError { + public readonly location: string; + + protected constructor(statusCode: number, location: string, name: string, message?: string, + options?: HttpErrorOptions) { + super(statusCode, name, message, options); + this.location = location; + } + + public static isInstance(error: any): error is RedirectHttpError { + return HttpError.isInstance(error) && typeof (error as any).location === 'string'; + } +} diff --git a/src/util/errors/UnprocessableEntityHttpError.ts b/src/util/errors/UnprocessableEntityHttpError.ts new file mode 100644 index 000000000..1ff8c039a --- /dev/null +++ b/src/util/errors/UnprocessableEntityHttpError.ts @@ -0,0 +1,15 @@ +import type { HttpErrorOptions } from './HttpError'; +import { HttpError } from './HttpError'; + +/** + * An error thrown when the server understands the content-type but can't process the instructions. + */ +export class UnprocessableEntityHttpError extends HttpError { + public constructor(message?: string, options?: HttpErrorOptions) { + super(422, 'UnprocessableEntityHttpError', message, options); + } + + public static isInstance(error: any): error is UnprocessableEntityHttpError { + return HttpError.isInstance(error) && error.statusCode === 422; + } +} diff --git a/src/util/handlers/MethodFilterHandler.ts b/src/util/handlers/MethodFilterHandler.ts new file mode 100644 index 000000000..e0c5814c6 --- /dev/null +++ b/src/util/handlers/MethodFilterHandler.ts @@ -0,0 +1,51 @@ +import { NotImplementedHttpError } from '../errors/NotImplementedHttpError'; +import { AsyncHandler } from './AsyncHandler'; + +// The formats from which we can detect the method +type InType = { method: string } | { request: { method: string }} | { operation: { method: string }}; + +/** + * Only accepts requests where the input has a (possibly nested) `method` field + * that matches any one of the given methods. + * In case of a match, the input will be sent to the source handler. + */ +export class MethodFilterHandler extends AsyncHandler { + private readonly methods: string[]; + private readonly source: AsyncHandler; + + public constructor(methods: string[], source: AsyncHandler) { + super(); + this.methods = methods; + this.source = source; + } + + public async canHandle(input: TIn): Promise { + const method = this.findMethod(input); + if (!this.methods.includes(method)) { + throw new NotImplementedHttpError( + `Cannot determine permissions of ${method}, only ${this.methods.join(',')}.`, + ); + } + await this.source.canHandle(input); + } + + public async handle(input: TIn): Promise { + return this.source.handle(input); + } + + /** + * Finds the correct method in the input object. + */ + private findMethod(input: InType): string { + if ('method' in input) { + return input.method; + } + if ('request' in input) { + return this.findMethod(input.request); + } + if ('operation' in input) { + return this.findMethod(input.operation); + } + throw new NotImplementedHttpError('Could not find method in input object.'); + } +} diff --git a/src/util/handlers/StaticThrowHandler.ts b/src/util/handlers/StaticThrowHandler.ts new file mode 100644 index 000000000..4df8a3f1d --- /dev/null +++ b/src/util/handlers/StaticThrowHandler.ts @@ -0,0 +1,18 @@ +import type { HttpError } from '../errors/HttpError'; +import { AsyncHandler } from './AsyncHandler'; + +/** + * Utility handler that can handle all input and always throws the given error. + */ +export class StaticThrowHandler extends AsyncHandler { + private readonly error: HttpError; + + public constructor(error: HttpError) { + super(); + this.error = error; + } + + public async handle(): Promise { + throw this.error; + } +} diff --git a/src/util/locking/VoidLocker.ts b/src/util/locking/VoidLocker.ts new file mode 100644 index 000000000..bc79f792e --- /dev/null +++ b/src/util/locking/VoidLocker.ts @@ -0,0 +1,34 @@ +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import { getLoggerFor } from '../../logging/LogUtil'; +import type { ExpiringReadWriteLocker } from './ExpiringReadWriteLocker'; + +/** + * This locker will execute the whileLocked function without any locking mechanism + * + * Do not use this locker in combination with storages that doesn't handle concurrent read/writes gracefully + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-function +function noop(): void {} + +export class VoidLocker implements ExpiringReadWriteLocker { + protected readonly logger = getLoggerFor(this); + + public constructor() { + this.logger.warn('Locking mechanism disabled; data integrity during parallel requests not guaranteed.'); + } + + public async withReadLock( + identifier: ResourceIdentifier, + whileLocked: (maintainLock: () => void) => T | Promise, + ): Promise { + return whileLocked(noop); + } + + public async withWriteLock( + identifier: ResourceIdentifier, + whileLocked: (maintainLock: () => void) => T | Promise, + ): Promise { + return whileLocked(noop); + } +} diff --git a/templates/config/defaults.json b/templates/config/defaults.json index e187cd83c..a3b041e0f 100644 --- a/templates/config/defaults.json +++ b/templates/config/defaults.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/util/auxiliary/acl.json", "files-scs:config/util/index/default.json", diff --git a/templates/config/filesystem.json b/templates/config/filesystem.json index 14a5cd5a3..655a9f003 100644 --- a/templates/config/filesystem.json +++ b/templates/config/filesystem.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:templates/config/defaults.json" ], diff --git a/templates/config/memory.json b/templates/config/memory.json index debc1c4a6..42cc1ebeb 100644 --- a/templates/config/memory.json +++ b/templates/config/memory.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:templates/config/defaults.json" ], diff --git a/templates/identity/email-password/confirm.html.ejs b/templates/identity/email-password/confirm.html.ejs deleted file mode 100644 index 01797c1c7..000000000 --- a/templates/identity/email-password/confirm.html.ejs +++ /dev/null @@ -1,17 +0,0 @@ -

Authorize

-

You are authorizing an application to access your Pod.

-
- <% if (locals.message) { %> -

<%= message %>

- <% } %> - -
-
    -
  1. - -
  2. -
-
- -

-
diff --git a/templates/identity/email-password/consent.html.ejs b/templates/identity/email-password/consent.html.ejs new file mode 100644 index 000000000..750f3999c --- /dev/null +++ b/templates/identity/email-password/consent.html.ejs @@ -0,0 +1,41 @@ +

Authorize

+

The following client wants to do authorized requests in your name:

+
    +
+
+

+ +
+
    +
  1. + +
  2. +
+
+ +

+
+ + diff --git a/templates/identity/email-password/forgot-password-response.html.ejs b/templates/identity/email-password/forgot-password-response.html.ejs deleted file mode 100644 index dddc23bf6..000000000 --- a/templates/identity/email-password/forgot-password-response.html.ejs +++ /dev/null @@ -1,13 +0,0 @@ -

Email sent

-
-

If your account exists, an email has been sent with a link to reset your password.

-

If you do not receive your email in a couple of minutes, check your spam folder or click the link below to send another email.

- - - -

Back to Log In

- -

- -

-
diff --git a/templates/identity/email-password/forgot-password.html.ejs b/templates/identity/email-password/forgot-password.html.ejs index 5ed4412cd..84789e516 100644 --- a/templates/identity/email-password/forgot-password.html.ejs +++ b/templates/identity/email-password/forgot-password.html.ejs @@ -1,19 +1,45 @@ -

Forgot password

-
- <% if (locals.message) { %> -

<%= message %>

- <% } %> +
+

Forgot password

+ +

-
-
    -
  1. - - -
  2. -
-
+
+
    +
  1. + + +
  2. +
+
-

+

-

Log in

- +

Log in

+ +
+
+

Email sent

+

If your account exists, an email has been sent with a link to reset your password.

+

If you do not receive your email in a couple of minutes, check your spam folder or try sending another email.

+ + +
+ + diff --git a/templates/identity/email-password/login.html.ejs b/templates/identity/email-password/login.html.ejs index 1d28d70a4..e527daa42 100644 --- a/templates/identity/email-password/login.html.ejs +++ b/templates/identity/email-password/login.html.ejs @@ -1,32 +1,56 @@ -

Log in

-
- <% prefilled = locals.prefilled || {}; %> +
+

Log in

+ +

- <% if (locals.message) { %> -

<%= message %>

- <% } %> +
+ Your account +
    +
  1. + + +
  2. +
  3. + + +
  4. +
  5. + +
  6. +
+
-
- Your account -
    -
  1. - - -
  2. -
  3. - - -
  4. -
  5. - -
  6. -
-
+

-

+ + +
+
+

Please log in through an app

+

To log in and access documents, you need to use a Solid app.

+

This server provides secure storage, but it is not a client app.

+

+ Choose one of the + Solid apps + to log in and browse Pods. +

+

+ If you're developing an app yourself, + use a library such as + solid-client-authn-js + to initiate an OIDC authentication flow. +

+
- - + + diff --git a/templates/identity/email-password/register-partial.html.ejs b/templates/identity/email-password/register-partial.html.ejs index dfe67bea5..815bb3f08 100644 --- a/templates/identity/email-password/register-partial.html.ejs +++ b/templates/identity/email-password/register-partial.html.ejs @@ -165,32 +165,8 @@ } } - // Checks whether the given element is visible - function isVisible(element) { - return !(elements[element] ?? element).classList.contains('hidden'); - } - - // Sets the visibility of the given element - function setVisibility(element, visible) { - // Show or hide the element - element = elements[element] ?? element; - element.classList[visible ? 'remove' : 'add']('hidden'); - - // Disable children of hidden elements, - // such that the browser does not expect input for them - for (const child of getDescendants(element)) { - if ('disabled' in child) - child.disabled = !visible; - } - } - - // Obtains all children, grandchildren, etc. of the given element - function getDescendants(element) { - return [...element.querySelectorAll("*")]; - } - // Prepare the form when the DOM is ready - window.addEventListener('DOMContentLoaded', (event) => { + addEventListener('DOMContentLoaded', (event) => { synchronizeInputFields(); elements.mainForm.classList.add('loaded'); }); diff --git a/templates/identity/email-password/register-response-partial.html.ejs b/templates/identity/email-password/register-response-partial.html.ejs index 0c141edc9..4a94e76bc 100644 --- a/templates/identity/email-password/register-response-partial.html.ejs +++ b/templates/identity/email-password/register-response-partial.html.ejs @@ -1,38 +1,54 @@ -<% if (createPod) { %> +

Your new Pod

- Your new Pod is located at <%= podBaseUrl %>. + Your new Pod is located at .
You can store your documents and data there.

-<% } %> +
-<% if (createWebId) { %> +

Your new WebID

- Your new WebID is <%= webId %>. + Your new WebID is .
You can use this identifier to interact with Solid pods and apps.

-<% } %> +
-<% if (register) { %> +

Your new account

- Via your email address <%= email %>, - <% if (authenticating) { %> - you can now log in - <% } else { %> - this server lets you log in to Solid apps - <% } %> - with your WebID <%= webId %> + Via your email address , + this server lets you log in to Solid apps + with your WebID

- <% if (!createWebId) { %> +

You will need to add the triple - <%= `<${webId}> <${oidcIssuer}>.`%> - to your existing WebID document <%= webId %> + + to your existing WebID document to indicate that you trust this server as a login provider.

- <% } %> -<% } %> +
+

+ You can now log in. +

+
+ + diff --git a/templates/identity/email-password/register-response.html.ejs b/templates/identity/email-password/register-response.html.ejs deleted file mode 100644 index bb0689c8d..000000000 --- a/templates/identity/email-password/register-response.html.ejs +++ /dev/null @@ -1,7 +0,0 @@ -

You've been signed up

-

- Welcome to Solid. - We wish you an exciting experience! -

- -<%- include('./register-response-partial.html.ejs') %> diff --git a/templates/identity/email-password/register.html.ejs b/templates/identity/email-password/register.html.ejs index f1d209bdc..40944d84b 100644 --- a/templates/identity/email-password/register.html.ejs +++ b/templates/identity/email-password/register.html.ejs @@ -1,11 +1,31 @@ -

Sign up

-
+
+

Sign up

+ +

- <% if (locals.message) { %> -

Error: <%= message %>

- <% } %> + <%- include('./register-partial.html.ejs', { allowRoot: false }) %> - <%- include('./register-partial.html.ejs', { allowRoot: false }) %> +

+ +
+
+

You've been signed up

+

+ Welcome to Solid. + We wish you an exciting experience! +

-

- + <%- include('./register-response-partial.html.ejs') %> +
+ + diff --git a/templates/identity/email-password/reset-password-response.html.ejs b/templates/identity/email-password/reset-password-response.html.ejs deleted file mode 100644 index 4c7169c58..000000000 --- a/templates/identity/email-password/reset-password-response.html.ejs +++ /dev/null @@ -1,2 +0,0 @@ -

Password reset

-

Your password was successfully reset.

diff --git a/templates/identity/email-password/reset-password.html.ejs b/templates/identity/email-password/reset-password.html.ejs index a5c605eb8..9f8837250 100644 --- a/templates/identity/email-password/reset-password.html.ejs +++ b/templates/identity/email-password/reset-password.html.ejs @@ -1,21 +1,40 @@ -

Reset password

-
- <% if (locals.message) { %> -

<%= message %>

- <% } %> +
+

Reset password

+ +

-
-
    -
  1. - - -
  2. -
  3. - - -
  4. -
-
+
+
    +
  1. + + +
  2. +
  3. + + +
  4. +
+ +
-

- +

+ +
+
+

Password reset

+

Your password was successfully reset.

+
+ + diff --git a/templates/main.html.ejs b/templates/main.html.ejs index 0c3dfc1b2..26bd02e3f 100644 --- a/templates/main.html.ejs +++ b/templates/main.html.ejs @@ -4,11 +4,12 @@ <%= extractTitle(htmlBody) %> - + +
- [Solid logo] + [Solid logo]

Community Solid Server

@@ -16,7 +17,7 @@
diff --git a/templates/root/prefilled/index.html b/templates/root/prefilled/index.html index db867df82..1ac5f7cb7 100644 --- a/templates/root/prefilled/index.html +++ b/templates/root/prefilled/index.html @@ -4,11 +4,11 @@ Community Solid Server - +
- [Solid logo] + [Solid logo]

Community Solid Server

@@ -58,7 +58,7 @@
diff --git a/templates/scripts/util.js b/templates/scripts/util.js new file mode 100644 index 000000000..2b07423b5 --- /dev/null +++ b/templates/scripts/util.js @@ -0,0 +1,144 @@ +/** + * Acquires all data from the given form and POSTs it as JSON to the target URL. + * In case of failure this function will throw an error. + * In case of success a parsed JSON body of the response will be returned, + * unless the body contains a `location` field, + * in that case the page will be redirected to that location. + * + * @param formId - ID of the form. + * @param target - Target URL to POST to. Defaults to the current URL. + * @returns {Promise} - The response JSON. + */ +async function postJsonForm(formId, target = '') { + const form = document.getElementById(formId); + const formData = new FormData(form); + const res = await fetch(target, { + method: 'POST', + credentials: 'include', + headers: { 'accept': 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify(Object.fromEntries(formData)), + }); + if (res.status >= 400) { + const error = await res.json(); + throw new Error(`${error.statusCode} - ${error.name}: ${error.message}`) + } else if (res.status === 200 || res.status === 201) { + const body = await res.json(); + if (body.location) { + location.href = body.location; + } else { + return body; + } + } +} + +/** + * Redirects the page to the given target with the key/value pairs of the JSON body as query parameters. + * Controls will be deleted from the JSON to prevent very large URLs. + * `false` values will be deleted to prevent incorrect serializations to "false". + * @param json - JSON to convert. + * @param target - URL to redirect to. + */ +function redirectJsonResponse(json, target) { + // These would cause the URL to get very large, can be acquired later if needed + delete json.controls; + + // Remove false parameters since these would be converted to "false" strings + for (const [key, val] of Object.entries(json)) { + if (typeof val === 'boolean' && !val) { + delete json[key]; + } + } + + const searchParams = new URLSearchParams(Object.entries(json)); + location.href = `${target}?${searchParams.toString()}`; +} + +/** + * Adds a listener to the given form to catch the form submission and do an API call instead. + * In case of an error, the inner text of the given error block will be updated with the message. + * In case of success the callback function will be called. + * + * @param formId - ID of the form. + * @param errorId - ID of the error block. + * @param apiTarget - Target URL to send the POST request to. Defaults to the current URL. + * @param callback - Callback function that will be called with the response JSON. + */ +async function addPostListener(formId, errorId, apiTarget, callback) { + const form = document.getElementById(formId); + const errorBlock = document.getElementById(errorId); + + form.addEventListener('submit', async(event) => { + event.preventDefault(); + + try { + const json = await postJsonForm(formId, apiTarget); + if (json) { + callback(json); + } + } catch (error) { + errorBlock.innerText = error.message; + } + }); +} + +/** + * Updates links on a page based on the controls received from the API. + * @param url - API URL that will return the controls + * @param controlMap - Key/value map with keys being element IDs and values being the control field names. + */ +async function addControlLinks(url, controlMap) { + const json = await fetchJson(url); + for (let [ id, control ] of Object.entries(controlMap)) { + updateElement(id, json.controls[control], { href: true }); + } +} + +/** + * Shows or hides the given element. + * @param id - ID of the element. + * @param visible - If it should be visible. + */ +function setVisibility(id, visible) { + const element = document.getElementById(id); + element.classList[visible ? 'remove' : 'add']('hidden'); + // Disable children of hidden elements, + // such that the browser does not expect input for them + for (const child of getDescendants(element)) { + if ('disabled' in child) + child.disabled = !visible; + } +} + +/** + * Obtains all children, grandchildren, etc. of the given element. + * @param element - Element to get all descendants from. + */ +function getDescendants(element) { + return [...element.querySelectorAll("*")]; +} + +/** + * Updates the inner text and href field of an element. + * @param id - ID of the element. + * @param text - Text to put in the field(s). + * @param options - Indicates which fields should be updated. + * Keys should be `innerText` and/or `href`, values should be booleans. + */ +function updateElement(id, text, options) { + const element = document.getElementById(id); + if (options.innerText) { + element.innerText = text; + } + if (options.href) { + element.href = text; + } +} + +/** + * Fetches JSON from the url and converts it to an object. + * @param url - URL to fetch JSON from. + */ +async function fetchJson(url) { + const res = await fetch(url, { headers: { accept: 'application/json' } }); + return res.json(); +} diff --git a/templates/setup/index.html.ejs b/templates/setup/index.html.ejs index acdb998b8..b5b9923fb 100644 --- a/templates/setup/index.html.ejs +++ b/templates/setup/index.html.ejs @@ -1,72 +1,45 @@ -

Set up your Solid server

-

- Your Solid server needs a one-time setup - so it acts exactly the way you want. -

+
+ <%- include('./input-partial.html.ejs') %> +
+
+

Server setup complete

+

+ Congratulations! + Your Solid server is now ready to use. +
+ You can now visit its homepage. +

-
- <% const safePrefilled = locals.prefilled || {}; %> +
+

Root Pod

+

+ Warning: the root Pod is publicly accessible. +
+ Prevent public write and control access to the root + by modifying its ACL document. +

+
- <% if (locals.message) { %> -

<%= message %>

- <% } %> -
- Accounts on this server -
    -
  1. - -

    - You can disable account registration - by changing the configuration. -

    -
  2. -
  3. - -

    - Any existing root Pod will be disabled. -

    -
  4. -
  5. - -

    - By default, the public has read and write access to the root Pod. -
    - You typically only want to choose this - for rapid testing and development. -

    -
  6. -
-
+
+ <%- include('../identity/email-password/register-response-partial.html.ejs', { idpIndex: '' }) %> +
+
-
- Sign up - <%- - include('../identity/email-password/register-partial.html.ejs', { - allowRoot: true, - }) - %> -
- -

- - - diff --git a/templates/setup/input-partial.html.ejs b/templates/setup/input-partial.html.ejs new file mode 100644 index 000000000..bf9eb785f --- /dev/null +++ b/templates/setup/input-partial.html.ejs @@ -0,0 +1,69 @@ +

Set up your Solid server

+

+ Your Solid server needs a one-time setup + so it acts exactly the way you want. +

+ +
+

+ +
+ Accounts on this server +
    +
  1. + +

    + You can disable account registration + by changing the configuration. +

    +
  2. +
  3. + +

    + Any existing root Pod will be disabled. +

    +
  4. +
  5. + +

    + By default, the public has read and write access to the root Pod. +
    + You typically only want to choose this + for rapid testing and development. +

    +
  6. +
+
+ +
+ Sign up + <%- + include('../identity/email-password/register-partial.html.ejs', { + allowRoot: true, + }) + %> +
+ +

+
+ + + diff --git a/templates/setup/response.html.ejs b/templates/setup/response.html.ejs deleted file mode 100644 index c416ef82e..000000000 --- a/templates/setup/response.html.ejs +++ /dev/null @@ -1,21 +0,0 @@ -

Server setup complete

-

- Congratulations! - Your Solid server is now ready to use. -
- You can now visit its homepage. -

- -<% if (initialize && !registration) { %> -

Root Pod

-

- Warning: the root Pod is publicly accessible. -
- Prevent public write and control access to the root - by modifying its ACL document. -

-<% } %> - -<% if (registration) { %> -<%- include('../identity/email-password/register-response-partial.html.ejs', { authenticating: false }) %> -<% } %> diff --git a/templates/styles/main.css b/templates/styles/main.css index 830c1f15c..77d8a1bf2 100644 --- a/templates/styles/main.css +++ b/templates/styles/main.css @@ -233,11 +233,18 @@ form ul.actions > li { margin-right: 1em; } +/* Directly hide hidden elements. */ +.hidden { + display: none; +} + +/* Hide form elements with a sliding animation so users can track more easily what is happening. */ form.loaded * { max-height: 1000px; transition: max-height .2s; } form .hidden { + display: block; max-height: 0; overflow: hidden; } diff --git a/test/.eslintrc.js b/test/.eslintrc.js index 585742262..58ba353d6 100644 --- a/test/.eslintrc.js +++ b/test/.eslintrc.js @@ -11,6 +11,9 @@ module.exports = { 'unicorn/no-useless-undefined': 'off', 'no-process-env': 'off', + // Rule is not smart enough to check called function in the test + 'jest/expect-expect': 'off', + // We are not using Mocha 'mocha/no-exports': 'off', 'mocha/no-nested-tests': 'off', diff --git a/test/integration/ContentNegotiation.test.ts b/test/integration/ContentNegotiation.test.ts new file mode 100644 index 000000000..8573df15b --- /dev/null +++ b/test/integration/ContentNegotiation.test.ts @@ -0,0 +1,67 @@ +import assert from 'assert'; +import fetch from 'cross-fetch'; +import type { App } from '../../src/init/App'; +import { getPort } from '../util/Util'; +import { getDefaultVariables, getTestConfigPath, instantiateFromConfig } from './Config'; + +const port = getPort('ContentNegotiation'); +const baseUrl = `http://localhost:${port}`; + +const documents = [ + [ '/turtle', 'text/turtle', '# Test' ], + [ '/markdown', 'text/markdown', '# Test' ], +]; + +const cases: [string, string, string][] = [ + [ '/turtle', 'text/turtle', '' ], + [ '/turtle', 'text/turtle', '*/*' ], + [ '/turtle', 'text/turtle', 'text/html,*/*;q=0.1' ], + [ '/turtle', 'application/json', 'application/json' ], + [ '/turtle', 'application/ld+json', 'application/ld+json' ], + [ '/turtle', 'application/octet-stream', 'application/octet-stream' ], + [ '/markdown', 'text/markdown', '' ], + [ '/markdown', 'text/markdown', '*/*' ], + [ '/markdown', 'text/markdown', 'text/html,text/markdown' ], + [ '/markdown', 'text/markdown', 'text/markdown;q=0.9, text/html;q=0.1' ], + [ '/markdown', 'text/html', 'text/html' ], + [ '/markdown', 'text/html', 'text/html,*/*;q=0.8' ], + [ '/markdown', 'text/html', 'text/markdown;q=0.1, text/html;q=0.9' ], + [ '/markdown', 'application/octet-stream', 'application/octet-stream' ], +]; + +describe('Content negotiation', (): void => { + let app: App; + + beforeAll(async(): Promise => { + // Start the server + const instances = await instantiateFromConfig( + 'urn:solid-server:test:Instances', + getTestConfigPath('server-memory.json'), + getDefaultVariables(port, baseUrl), + ) as Record; + ({ app } = instances); + await app.start(); + + // Create documents + for (const [ slug, contentType, body ] of documents) { + const res = await fetch(`${baseUrl}${slug}`, { + method: 'PUT', + headers: { 'content-type': contentType }, + body, + }); + assert.strictEqual(res.status, 201); + } + }); + + afterAll(async(): Promise => { + await app.stop(); + }); + + describe.each(cases)('a request for %s', (name, expected, accept): void => { + it(`results in ${expected} in response to Accept: ${accept}`, async(): Promise => { + const res = await fetch(`${baseUrl}${name}`, { headers: { accept }}); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe(expected); + }); + }); +}); diff --git a/test/integration/GuardedStream.test.ts b/test/integration/GuardedStream.test.ts index d1f478de7..4963da1d5 100644 --- a/test/integration/GuardedStream.test.ts +++ b/test/integration/GuardedStream.test.ts @@ -1,6 +1,5 @@ import { RepresentationMetadata, - TypedRepresentationConverter, readableToString, ChainedConverter, guardedStreamFrom, @@ -12,6 +11,7 @@ import { import type { Representation, RepresentationConverterArgs, Logger } from '../../src'; +import { BaseTypedRepresentationConverter } from '../../src/storage/conversion/BaseTypedRepresentationConverter'; jest.mock('../../src/logging/LogUtil', (): any => { const logger: Logger = @@ -20,17 +20,9 @@ jest.mock('../../src/logging/LogUtil', (): any => { }); const logger: jest.Mocked = getLoggerFor('GuardedStream') as any; -class DummyConverter extends TypedRepresentationConverter { +class DummyConverter extends BaseTypedRepresentationConverter { public constructor() { - super('*/*', 'custom/type'); - } - - public async getInputTypes(): Promise> { - return { [INTERNAL_QUADS]: 1 }; - } - - public async getOutputTypes(): Promise> { - return { 'x/x': 1 }; + super(INTERNAL_QUADS, 'x/x'); } public async handle({ representation }: RepresentationConverterArgs): Promise { diff --git a/test/integration/Identity.test.ts b/test/integration/Identity.test.ts index b767eddf4..6f258d48e 100644 --- a/test/integration/Identity.test.ts +++ b/test/integration/Identity.test.ts @@ -30,19 +30,6 @@ async function postForm(url: string, formBody: string): Promise { }); } -/** - * Extracts the registration triple from the registration form body. - */ -function extractRegistrationTriple(body: string, webId: string): string { - const error = load(body)('p.error').first().text(); - const regex = new RegExp( - `<${webId}>\\s+\\s+"[^"]+"\\s*\\.`, 'u', - ); - const match = regex.exec(error); - expect(match).toHaveLength(1); - return match![0]; -} - // No way around the cookies https://github.com/panva/node-oidc-provider/issues/552 . // They will be simulated by storing the values and passing them along. // This is why the redirects are handled manually. @@ -98,7 +85,8 @@ describe('A Solid server with IDP', (): void => { it('sends the form once to receive the registration triple.', async(): Promise => { const res = await postForm(`${baseUrl}idp/register/`, formBody); expect(res.status).toBe(400); - registrationTriple = extractRegistrationTriple(await res.text(), webId); + const json = await res.json(); + registrationTriple = json.details.quad; }); it('updates the webId with the registration token.', async(): Promise => { @@ -114,10 +102,11 @@ describe('A Solid server with IDP', (): void => { it('sends the form again to successfully register.', async(): Promise => { const res = await postForm(`${baseUrl}idp/register/`, formBody); expect(res.status).toBe(200); - const text = await res.text(); - expect(text).toMatch(new RegExp(`your.WebID.*${webId}`, 'u')); - expect(text).toMatch(new RegExp(`your.email.address.*${email}`, 'u')); - expect(text).toMatch(new RegExp(`<${webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${baseUrl}>\\.`, 'mu')); + await expect(res.json()).resolves.toEqual(expect.objectContaining({ + webId, + email, + oidcIssuer: baseUrl, + })); }); }); @@ -149,9 +138,11 @@ describe('A Solid server with IDP', (): void => { }); it('initializes the session and logs in.', async(): Promise => { - const url = await state.startSession(); - await state.parseLoginPage(url); - await state.login(url, email, password); + let url = await state.startSession(); + const res = await state.fetchIdp(url); + expect(res.status).toBe(200); + url = await state.login(url, email, password); + await state.consent(url); expect(state.session.info?.webId).toBe(webId); }); @@ -172,26 +163,113 @@ describe('A Solid server with IDP', (): void => { it('can log in again.', async(): Promise => { const url = await state.startSession(); - let res = await state.fetchIdp(url); + const res = await state.fetchIdp(url); expect(res.status).toBe(200); - res = await state.fetchIdp(url, 'POST', '', APPLICATION_X_WWW_FORM_URLENCODED); - const nextUrl = res.headers.get('location'); - expect(typeof nextUrl).toBe('string'); + // Will receive confirm screen here instead of login screen + await state.consent(url); - await state.handleLoginRedirect(nextUrl!); expect(state.session.info?.webId).toBe(webId); }); }); + describe('authenticating a client with a WebID', (): void => { + const clientId = joinUrl(baseUrl, 'client-id'); + const badClientId = joinUrl(baseUrl, 'bad-client-id'); + /* eslint-disable @typescript-eslint/naming-convention */ + const clientJson = { + '@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld', + + client_id: clientId, + client_name: 'Solid Application Name', + redirect_uris: [ redirectUrl ], + post_logout_redirect_uris: [ 'https://app.example/logout' ], + client_uri: 'https://app.example/', + logo_uri: 'https://app.example/logo.png', + tos_uri: 'https://app.example/tos.html', + scope: 'openid profile offline_access webid', + grant_types: [ 'refresh_token', 'authorization_code' ], + response_types: [ 'code' ], + default_max_age: 3600, + require_auth_time: true, + }; + // This client will always reject requests since there is no valid redirect + const badClientJson = { + ...clientJson, + client_id: badClientId, + redirect_uris: [], + }; + /* eslint-enable @typescript-eslint/naming-convention */ + let state: IdentityTestState; + + beforeAll(async(): Promise => { + state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer); + + await fetch(clientId, { + method: 'PUT', + headers: { 'content-type': 'application/ld+json' }, + body: JSON.stringify(clientJson), + }); + + await fetch(badClientId, { + method: 'PUT', + headers: { 'content-type': 'application/ld+json' }, + body: JSON.stringify(badClientJson), + }); + }); + + afterAll(async(): Promise => { + await state.session.logout(); + }); + + it('initializes the session and logs in.', async(): Promise => { + let url = await state.startSession(clientId); + const res = await state.fetchIdp(url); + expect(res.status).toBe(200); + url = await state.login(url, email, password); + + // Verify the client information the server discovered + const consentRes = await state.fetchIdp(url, 'GET'); + expect(consentRes.status).toBe(200); + const { client } = await consentRes.json(); + expect(client.client_id).toBe(clientJson.client_id); + expect(client.client_name).toBe(clientJson.client_name); + + await state.consent(url); + expect(state.session.info?.webId).toBe(webId); + }); + + it('rejects requests in case the redirect URL is not accepted.', async(): Promise => { + // This test allows us to make sure the server actually uses the client WebID. + // If it did not, it would not see the invalid redirect_url array. + + let nextUrl = ''; + await state.session.login({ + redirectUrl, + oidcIssuer, + clientId: badClientId, + handleRedirect(data): void { + nextUrl = data; + }, + }); + expect(nextUrl.length > 0).toBeTruthy(); + expect(nextUrl.startsWith(oidcIssuer)).toBeTruthy(); + + // Redirect will error due to invalid client WebID + const res = await state.fetchIdp(nextUrl); + expect(res.status).toBe(400); + await expect(res.text()).resolves.toContain('invalid_redirect_uri'); + }); + }); + describe('resetting password', (): void => { let nextUrl: string; it('sends the corresponding email address through the form to get a mail.', async(): Promise => { const res = await postForm(`${baseUrl}idp/forgotpassword/`, stringify({ email })); expect(res.status).toBe(200); - expect(load(await res.text())('form p').first().text().trim()) - .toBe('If your account exists, an email has been sent with a link to reset your password.'); + const json = await res.json(); + expect(json.email).toBe(email); const mail = sendMail.mock.calls[0][0]; expect(mail.to).toBe(email); @@ -210,15 +288,18 @@ describe('A Solid server with IDP', (): void => { // Reset password form has no action causing the current URL to be used expect(relative).toBeUndefined(); + // Extract recordId from URL since JS is used to add it + const recordId = /\?rid=([^/]+)$/u.exec(nextUrl)?.[1]; + expect(typeof recordId).toBe('string'); + // POST the new password to the same URL - const formData = stringify({ password: password2, confirmPassword: password2 }); + const formData = stringify({ password: password2, confirmPassword: password2, recordId }); res = await fetch(nextUrl, { method: 'POST', headers: { 'content-type': APPLICATION_X_WWW_FORM_URLENCODED }, body: formData, }); expect(res.status).toBe(200); - expect(await res.text()).toContain('Your password was successfully reset.'); }); }); @@ -237,15 +318,17 @@ describe('A Solid server with IDP', (): void => { it('can not log in with the old password anymore.', async(): Promise => { const url = await state.startSession(); nextUrl = url; - await state.parseLoginPage(url); + let res = await state.fetchIdp(url); + expect(res.status).toBe(200); const formData = stringify({ email, password }); - const res = await state.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED); + res = await state.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED); expect(res.status).toBe(500); expect(await res.text()).toContain('Incorrect password'); }); it('can log in with the new password.', async(): Promise => { - await state.login(nextUrl, email, password2); + const url = await state.login(nextUrl, email, password2); + await state.consent(url); expect(state.session.info?.webId).toBe(webId); }); }); @@ -270,7 +353,8 @@ describe('A Solid server with IDP', (): void => { it('sends the form once to receive the registration triple.', async(): Promise => { const res = await postForm(`${baseUrl}idp/register/`, formBody); expect(res.status).toBe(400); - registrationTriple = extractRegistrationTriple(await res.text(), webId2); + const json = await res.json(); + registrationTriple = json.details.quad; }); it('updates the webId with the registration token.', async(): Promise => { @@ -286,8 +370,11 @@ describe('A Solid server with IDP', (): void => { it('sends the form again to successfully register.', async(): Promise => { const res = await postForm(`${baseUrl}idp/register/`, formBody); expect(res.status).toBe(200); - const text = await res.text(); - expect(text).toMatch(new RegExp(`Your new Pod.*${baseUrl}${podName}/`, 'u')); + await expect(res.json()).resolves.toEqual(expect.objectContaining({ + email: 'bob@test.email', + webId: webId2, + podBaseUrl: `${baseUrl}${podName}/`, + })); }); }); @@ -308,22 +395,23 @@ describe('A Solid server with IDP', (): void => { it('sends the form to create the WebID and register.', async(): Promise => { const res = await postForm(`${baseUrl}idp/register/`, formBody); expect(res.status).toBe(200); - const text = await res.text(); - - const matchWebId = /Your new WebID is [^>]+>([^<]+)/u.exec(text); - expect(matchWebId).toBeDefined(); - expect(matchWebId).toHaveLength(2); - newWebId = matchWebId![1]; - expect(text).toMatch(new RegExp(`new WebID is.*${newWebId}`, 'u')); - expect(text).toMatch(new RegExp(`your email address.*${newMail}`, 'u')); - expect(text).toMatch(new RegExp(`Your new Pod.*${baseUrl}${podName}/`, 'u')); + const json = await res.json(); + expect(json).toEqual(expect.objectContaining({ + webId: expect.any(String), + email: newMail, + oidcIssuer: baseUrl, + podBaseUrl: `${baseUrl}${podName}/`, + })); + newWebId = json.webId; }); it('initializes the session and logs in.', async(): Promise => { state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer); - const url = await state.startSession(); - await state.parseLoginPage(url); - await state.login(url, newMail, password); + let url = await state.startSession(); + const res = await state.fetchIdp(url); + expect(res.status).toBe(200); + url = await state.login(url, newMail, password); + await state.consent(url); expect(state.session.info?.webId).toBe(newWebId); }); @@ -385,12 +473,12 @@ describe('A Solid server with IDP', (): void => { const jsonBody = await res.json(); expect(res.status).toBe(200); - // https://solid.github.io/authentication-panel/solid-oidc/#discovery - expect(jsonBody.solid_oidc_supported).toBe('https://solidproject.org/TR/solid-oidc'); + // https://solid.github.io/solid-oidc/#discovery + expect(jsonBody.scopes_supported).toContain('webid'); }); it('should return correct error output.', async(): Promise => { - const res = await fetch(`${baseUrl}idp/auth`); + const res = await fetch(`${baseUrl}.oidc/auth`); expect(res.status).toBe(400); await expect(res.text()).resolves.toContain('InvalidRequest: invalid_request'); }); diff --git a/test/integration/IdentityTestState.ts b/test/integration/IdentityTestState.ts index 28597d006..836fad1ad 100644 --- a/test/integration/IdentityTestState.ts +++ b/test/integration/IdentityTestState.ts @@ -74,11 +74,12 @@ export class IdentityTestState { * Initializes an authentication session and stores the relevant cookies for later re-use. * All te relevant links from the login page get extracted. */ - public async startSession(): Promise { + public async startSession(clientId?: string): Promise { let nextUrl = ''; await this.session.login({ redirectUrl: this.redirectUrl, oidcIssuer: this.oidcIssuer, + clientId, handleRedirect(data): void { nextUrl = data; }, @@ -87,42 +88,47 @@ export class IdentityTestState { expect(nextUrl.startsWith(this.oidcIssuer)).toBeTruthy(); // Need to catch the redirect so we can copy the cookies - const res = await this.fetchIdp(nextUrl); - expect(res.status).toBe(302); + let res = await this.fetchIdp(nextUrl); + expect(res.status).toBe(303); nextUrl = res.headers.get('location')!; - return nextUrl; - } - - public async parseLoginPage(url: string): Promise<{ register: string; forgotPassword: string }> { - const res = await this.fetchIdp(url); + // Handle redirect + res = await this.fetchIdp(nextUrl); expect(res.status).toBe(200); - const text = await res.text(); - const register = this.extractUrl(text, 'a:contains("Sign up")', 'href'); - const forgotPassword = this.extractUrl(text, 'a:contains("Forgot password")', 'href'); - return { register, forgotPassword }; + // Need to send request to prompt API to get actual location + let json = await res.json(); + res = await this.fetchIdp(json.controls.prompt); + json = await res.json(); + nextUrl = json.location; + + return nextUrl; } /** * Logs in by sending the corresponding email and password to the given form action. * The URL should be extracted from the login page. */ - public async login(url: string, email: string, password: string): Promise { + public async login(url: string, email: string, password: string): Promise { const formData = stringify({ email, password }); - const res = await this.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED); - expect(res.status).toBe(302); - const nextUrl = res.headers.get('location')!; - - return this.handleLoginRedirect(nextUrl); + let res = await this.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED); + expect(res.status).toBe(200); + const json = await res.json(); + res = await this.fetchIdp(json.location); + expect(res.status).toBe(303); + return res.headers.get('location')!; } /** - * Handles the redirect that happens after logging in. + * Handles the consent screen at the given URL and the followup redirect back to the client. */ - public async handleLoginRedirect(url: string): Promise { - const res = await this.fetchIdp(url); - expect(res.status).toBe(302); + public async consent(url: string): Promise { + let res = await this.fetchIdp(url, 'POST', '', APPLICATION_X_WWW_FORM_URLENCODED); + expect(res.status).toBe(200); + const json = await res.json(); + + res = await this.fetchIdp(json.location); + expect(res.status).toBe(303); const mockUrl = res.headers.get('location')!; expect(mockUrl.startsWith(this.redirectUrl)).toBeTruthy(); diff --git a/test/integration/LdpHandlerWithoutAuth.test.ts b/test/integration/LdpHandlerWithoutAuth.test.ts index 96dc905ec..12f4f82bd 100644 --- a/test/integration/LdpHandlerWithoutAuth.test.ts +++ b/test/integration/LdpHandlerWithoutAuth.test.ts @@ -386,4 +386,14 @@ describe.each(stores)('An LDP handler allowing all requests %s', (name, { storeC // DELETE expect(await deleteResource(documentUrl)).toBeUndefined(); }); + + it('returns 405 for unsupported methods.', async(): Promise => { + const response = await fetch(baseUrl, { method: 'TRACE' }); + expect(response.status).toBe(405); + }); + + it('returns 415 for unsupported PATCH types.', async(): Promise => { + const response = await fetch(baseUrl, { method: 'PATCH', headers: { 'content-type': 'text/plain' }, body: 'abc' }); + expect(response.status).toBe(415); + }); }); diff --git a/test/integration/N3Patch.test.ts b/test/integration/N3Patch.test.ts new file mode 100644 index 000000000..21281c91a --- /dev/null +++ b/test/integration/N3Patch.test.ts @@ -0,0 +1,398 @@ +import 'jest-rdf'; +import { fetch } from 'cross-fetch'; +import { Parser } from 'n3'; +import type { AclPermission } from '../../src/authorization/permissions/AclPermission'; +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 { AclHelper } from '../util/AclHelper'; +import { getPort } from '../util/Util'; +import { + getDefaultVariables, + getPresetConfigPath, + getTestConfigPath, + instantiateFromConfig, +} from './Config'; + +const port = getPort('N3Patch'); +const baseUrl = `http://localhost:${port}/`; + +let store: ResourceStore; +let aclHelper: AclHelper; + +async function expectPatch( + input: { path: string; contentType?: string; body: string }, + expected: { status: number; message?: string; turtle?: string }, +): Promise { + const message = expected.message ?? ''; + const contentType = input.contentType ?? 'text/n3'; + + const body = `@prefix solid: . + ${input.body}`; + + const url = joinUrl(baseUrl, input.path); + const res = await fetch(url, { + method: 'PATCH', + headers: { 'content-type': contentType }, + body, + }); + await expect(res.text()).resolves.toContain(message); + expect(res.status).toBe(expected.status); + + // Verify if the resource has the expected RDF data + if (expected.turtle) { + // Might not have read permissions so need to update + await aclHelper.setSimpleAcl(url, { permissions: { read: true }, agentClass: 'agent', accessTo: true }); + const get = await fetch(url, { + method: 'GET', + headers: { accept: 'text/turtle' }, + }); + const expectedTurtle = `@prefix solid: . + ${expected.turtle}`; + + expect(get.status).toBe(200); + const parser = new Parser({ format: 'text/turtle', baseIRI: url }); + const actualTriples = parser.parse(await get.text()); + expect(actualTriples).toBeRdfIsomorphic(parser.parse(expectedTurtle)); + } +} + +// Creates/updates a resource with the given data and permissions +async function setResource(path: string, turtle: string, permissions: AclPermission): Promise { + const url = joinUrl(baseUrl, path); + await store.setRepresentation({ path: url }, new BasicRepresentation(turtle, 'text/turtle')); + await aclHelper.setSimpleAcl(url, { permissions, agentClass: 'agent', accessTo: true }); +} + +describe('A Server supporting N3 Patch', (): void => { + let app: App; + + beforeAll(async(): Promise => { + // Create and start the server + const instances = await instantiateFromConfig( + 'urn:solid-server:test:Instances', + [ + getPresetConfigPath('storage/backend/memory.json'), + getTestConfigPath('ldp-with-auth.json'), + ], + getDefaultVariables(port, baseUrl), + ) as Record; + ({ app, store } = instances); + + await app.start(); + + // Create test helper for manipulating acl + aclHelper = new AclHelper(store); + }); + + afterAll(async(): Promise => { + await app.stop(); + }); + + describe('with an invalid patch document', (): void => { + it('requires text/n3 content-type.', async(): Promise => { + await expectPatch( + { path: '/invalid', contentType: 'text/other', body: '' }, + { status: 415 }, + ); + }); + + it('requires valid syntax.', async(): Promise => { + await expectPatch( + { path: '/invalid', body: 'invalid syntax' }, + { status: 400, message: 'Invalid N3' }, + ); + }); + + it('requires a solid:InsertDeletePatch.', async(): Promise => { + await expectPatch( + { path: '/invalid', body: '<> a solid:Patch.' }, + { + status: 422, + message: 'This patcher only supports N3 Patch documents with exactly 1 solid:InsertDeletePatch entry', + }, + ); + }); + }); + + describe('inserting data', (): void => { + it('succeeds if there is no resource.', async(): Promise => { + await expectPatch( + { path: '/new-insert', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, + { status: 201, turtle: ' .' }, + ); + }); + + it('fails if there is only read access.', async(): Promise => { + await setResource('/read-only', ' .', { read: true }); + await expectPatch( + { path: '/read-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, + { status: 401 }, + ); + }); + + it('succeeds if there is only read access.', async(): Promise => { + await setResource('/append-only', ' .', { append: true }); + await expectPatch( + { path: '/append-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, + { status: 205, turtle: ' . .' }, + ); + }); + + it('succeeds if there is only write access.', async(): Promise => { + await setResource('/write-only', ' .', { write: true }); + await expectPatch( + { path: '/write-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, + { status: 205, turtle: ' . .' }, + ); + }); + }); + + describe('inserting conditional data', (): void => { + it('fails if there is no resource.', async(): Promise => { + await expectPatch( + { path: '/new-insert-where', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' }, + ); + }); + + it('fails if there is only read access.', async(): Promise => { + await setResource('/read-only', ' .', { read: true }); + await expectPatch( + { path: '/read-only', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only append access.', async(): Promise => { + await setResource('/append-only', ' .', { append: true }); + await expectPatch( + { path: '/append-only', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only write access.', async(): Promise => { + await setResource('/write-only', ' .', { write: true }); + await expectPatch( + { path: '/write-only', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 401 }, + ); + }); + + describe('with read/append access', (): void => { + it('succeeds if the conditions match.', async(): Promise => { + await setResource('/read-append', ' .', { read: true, append: true }); + await expectPatch( + { path: '/read-append', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 205, turtle: ' . .' }, + ); + }); + + it('rejects if there is no match.', async(): Promise => { + await setResource('/read-append', ' .', { read: true, append: true }); + await expectPatch( + { path: '/read-append', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' }, + ); + }); + + it('rejects if there are multiple matches.', async(): Promise => { + await setResource('/read-append', ' . .', { read: true, append: true }); + await expectPatch( + { path: '/read-append', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 409, message: 'The document contains multiple matches for the N3 Patch solid:where condition' }, + ); + }); + }); + + describe('with read/write access', (): void => { + it('succeeds if the conditions match.', async(): Promise => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 205, turtle: ' . .' }, + ); + }); + }); + }); + + describe('deleting data', (): void => { + it('fails if there is no resource.', async(): Promise => { + await expectPatch( + { path: '/new-delete', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' }, + ); + }); + + it('fails if there is only append access.', async(): Promise => { + await setResource('/append-only', ' .', { append: true }); + await expectPatch( + { path: '/append-only', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only write access.', async(): Promise => { + await setResource('/write-only', ' .', { write: true }); + await expectPatch( + { path: '/write-only', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only read/append access.', async(): Promise => { + await setResource('/read-append', ' .', { read: true, append: true }); + await expectPatch( + { path: '/read-append', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + describe('with read/write access', (): void => { + it('succeeds if the delete triples exist.', async(): Promise => { + await setResource('/read-write', ' . .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { status: 205, turtle: ' .' }, + ); + }); + + it('fails if the delete triples do not exist.', async(): Promise => { + await setResource('/read-write', ' . .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' }, + ); + }); + + it('succeeds if the conditions match.', async(): Promise => { + await setResource('/read-write', ' . .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:deletes { ?a . }.` }, + { status: 205, turtle: ' .' }, + ); + }); + + it('fails if the conditions do not match.', async(): Promise => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:deletes { ?a . }.` }, + { status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' }, + ); + }); + }); + }); + + describe('deleting and inserting data', (): void => { + it('fails if there is no resource.', async(): Promise => { + await expectPatch( + { path: '/new-delete-insert', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' }, + ); + }); + + it('fails if there is only append access.', async(): Promise => { + await setResource('/append-only', ' .', { append: true }); + await expectPatch( + { path: '/append-only', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only write access.', async(): Promise => { + await setResource('/write-only', ' .', { write: true }); + await expectPatch( + { path: '/write-only', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only read/append access.', async(): Promise => { + await setResource('/read-append', ' .', { read: true, append: true }); + await expectPatch( + { path: '/read-append', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + describe('with read/write access', (): void => { + it('executes deletes before inserts.', async(): Promise => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' }, + ); + }); + + it('succeeds if the delete triples exist.', async(): Promise => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { status: 205, turtle: ' .' }, + ); + }); + + it('succeeds if the conditions match.', async(): Promise => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:inserts { ?a . }; + solid:deletes { ?a . }.` }, + { status: 205, turtle: ' .' }, + ); + }); + + it('fails if the conditions do not match.', async(): Promise => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:inserts { ?a . }; + solid:deletes { ?a . }.` }, + { status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' }, + ); + }); + }); + }); +}); diff --git a/test/integration/Quota.test.ts b/test/integration/Quota.test.ts new file mode 100644 index 000000000..58cc3825f --- /dev/null +++ b/test/integration/Quota.test.ts @@ -0,0 +1,222 @@ +import { promises as fsPromises } from 'fs'; +import type { Stats } from 'fs'; +import fetch from 'cross-fetch'; +import type { Response } from 'cross-fetch'; +import { joinFilePath, joinUrl } from '../../src'; +import type { App } from '../../src'; +import { getPort } from '../util/Util'; +import { getDefaultVariables, getTestConfigPath, getTestFolder, instantiateFromConfig, removeFolder } from './Config'; + +/** Performs a simple PUT request to the given 'path' with a body containing 'length' amount of characters */ +async function performSimplePutWithLength(path: string, length: number): Promise { + return fetch( + path, + { + method: 'PUT', + headers: { + 'content-type': 'text/plain', + }, + body: 'A'.repeat(length), + }, + ); +} + +/** Registers two test pods on the server matching the 'baseUrl' */ +async function registerTestPods(baseUrl: string, pods: string[]): Promise { + for (const pod of pods) { + await fetch(`${baseUrl}idp/register/`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + createWebId: 'on', + webId: '', + register: 'on', + createPod: 'on', + podName: pod, + email: `${pod}@example.ai`, + password: 't', + confirmPassword: 't', + submit: '', + }), + }); + } +} + +/* We just want a container with the correct metadata, everything else can be removed */ +async function clearInitialFiles(rootFilePath: string, pods: string[]): Promise { + for (const pod of pods) { + const fileList = await fsPromises.readdir(joinFilePath(rootFilePath, pod)); + for (const file of fileList) { + if (file !== '.meta') { + const path = joinFilePath(rootFilePath, pod, file); + if ((await fsPromises.stat(path)).isDirectory()) { + await fsPromises.rmdir(path, { recursive: true }); + } else { + await fsPromises.unlink(path); + } + } + } + } +} + +describe('A quota server', (): void => { + // The allowed quota depends on what filesystem/OS you are using. + // For example: an empty folder is reported as + // - 0KB on NTFS (most of the time, mileage may vary) + // - 0-...KB on APFS (depending on its contents and settings) + // - 4O96KB on FAT + // This is why we need to determine the size of a folder on the current system. + let folderSizeTest: Stats; + beforeAll(async(): Promise => { + // We want to use an empty folder as on APFS/Mac folder sizes vary a lot + const tempFolder = getTestFolder('quota-temp'); + await fsPromises.mkdir(tempFolder); + folderSizeTest = await fsPromises.stat(tempFolder); + await removeFolder(tempFolder); + }); + const podName1 = 'arthur'; + const podName2 = 'abel'; + + /** Test the general functionality of the server using pod quota */ + describe('with pod quota enabled', (): void => { + const port = getPort('PodQuota'); + const baseUrl = `http://localhost:${port}/`; + const pod1 = joinUrl(baseUrl, podName1); + const pod2 = joinUrl(baseUrl, podName2); + const rootFilePath = getTestFolder('quota-pod'); + + let app: App; + + beforeAll(async(): Promise => { + // Calculate the allowed quota depending on file system used + const size = folderSizeTest.size + 4000; + + const instances = await instantiateFromConfig( + 'urn:solid-server:test:Instances', + getTestConfigPath('quota-pod.json'), + { + ...getDefaultVariables(port, baseUrl), + 'urn:solid-server:default:variable:rootFilePath': rootFilePath, + 'urn:solid-server:default:variable:PodQuota': size, + }, + ) as Record; + ({ app } = instances); + await app.start(); + + // Initialize 2 pods + await registerTestPods(baseUrl, [ podName1, podName2 ]); + await clearInitialFiles(rootFilePath, [ podName1, podName2 ]); + }); + + afterAll(async(): Promise => { + await app.stop(); + await removeFolder(rootFilePath); + }); + + // Test quota in the first pod + it('should return a 413 when the quota is exceeded during write.', async(): Promise => { + const testFile1 = `${pod1}/test1.txt`; + const testFile2 = `${pod1}/test2.txt`; + + const response1 = performSimplePutWithLength(testFile1, 2000); + await expect(response1).resolves.toBeDefined(); + expect((await response1).status).toBe(201); + + const response2 = performSimplePutWithLength(testFile2, 2500); + await expect(response2).resolves.toBeDefined(); + expect((await response2).status).toBe(413); + }); + + // Test if writing in another pod is still possible + it('should allow writing in a pod that is not full yet.', async(): Promise => { + const testFile1 = `${pod2}/test1.txt`; + + const response1 = performSimplePutWithLength(testFile1, 2000); + await expect(response1).resolves.toBeDefined(); + expect((await response1).status).toBe(201); + }); + + // Both pods should not accept this request anymore + it('should block PUT requests to different pods if their quota is exceeded.', async(): Promise => { + const testFile1 = `${pod1}/test2.txt`; + const testFile2 = `${pod2}/test2.txt`; + + const response1 = performSimplePutWithLength(testFile1, 2500); + await expect(response1).resolves.toBeDefined(); + expect((await response1).status).toBe(413); + + const response2 = performSimplePutWithLength(testFile2, 2500); + await expect(response2).resolves.toBeDefined(); + expect((await response2).status).toBe(413); + }); + }); + + /** Test the general functionality of the server using global quota */ + describe('with global quota enabled', (): void => { + const port = getPort('GlobalQuota'); + const baseUrl = `http://localhost:${port}/`; + const pod1 = `${baseUrl}${podName1}`; + const pod2 = `${baseUrl}${podName2}`; + const rootFilePath = getTestFolder('quota-global'); + + let app: App; + + beforeAll(async(): Promise => { + // Calculate the allowed quota depending on file system used + const size = (folderSizeTest.size * 3) + 4000; + + const instances = await instantiateFromConfig( + 'urn:solid-server:test:Instances', + getTestConfigPath('quota-global.json'), + { + ...getDefaultVariables(port, baseUrl), + 'urn:solid-server:default:variable:rootFilePath': rootFilePath, + 'urn:solid-server:default:variable:GlobalQuota': size, + }, + ) as Record; + ({ app } = instances); + await app.start(); + + // Initialize 2 pods + await registerTestPods(baseUrl, [ podName1, podName2 ]); + await clearInitialFiles(rootFilePath, [ podName1, podName2 ]); + }); + + afterAll(async(): Promise => { + await app.stop(); + await removeFolder(rootFilePath); + }); + + it('should return 413 when global quota is exceeded.', async(): Promise => { + const testFile1 = `${baseUrl}test1.txt`; + const testFile2 = `${baseUrl}test2.txt`; + + const response1 = performSimplePutWithLength(testFile1, 2000); + await expect(response1).resolves.toBeDefined(); + const awaitedRes1 = await response1; + expect(awaitedRes1.status).toBe(201); + + const response2 = performSimplePutWithLength(testFile2, 2500); + await expect(response2).resolves.toBeDefined(); + const awaitedRes2 = await response2; + expect(awaitedRes2.status).toBe(413); + }); + + it('should return 413 when trying to write to any pod when global quota is exceeded.', async(): Promise => { + const testFile1 = `${pod1}/test3.txt`; + const testFile2 = `${pod2}/test4.txt`; + + const response1 = performSimplePutWithLength(testFile1, 2500); + await expect(response1).resolves.toBeDefined(); + const awaitedRes1 = await response1; + expect(awaitedRes1.status).toBe(413); + + const response2 = performSimplePutWithLength(testFile2, 2500); + await expect(response2).resolves.toBeDefined(); + const awaitedRes2 = await response2; + expect(awaitedRes2.status).toBe(413); + }); + }); +}); diff --git a/test/integration/RepresentationConverter.test.ts b/test/integration/RepresentationConverter.test.ts deleted file mode 100644 index 24be36a9d..000000000 --- a/test/integration/RepresentationConverter.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { BasicRepresentation } from '../../src/http/representation/BasicRepresentation'; -import { ChainedConverter } from '../../src/storage/conversion/ChainedConverter'; -import { QuadToRdfConverter } from '../../src/storage/conversion/QuadToRdfConverter'; -import { RdfToQuadConverter } from '../../src/storage/conversion/RdfToQuadConverter'; -import { readableToString } from '../../src/util/StreamUtil'; - -describe('A ChainedConverter', (): void => { - const converters = [ - new RdfToQuadConverter(), - new QuadToRdfConverter(), - ]; - const converter = new ChainedConverter(converters); - - it('can convert from JSON-LD to turtle.', async(): Promise => { - const representation = new BasicRepresentation( - '{"@id": "http://test.com/s", "http://test.com/p": { "@id": "http://test.com/o" }}', - 'application/ld+json', - ); - - const result = await converter.handleSafe({ - representation, - preferences: { type: { 'text/turtle': 1 }}, - identifier: { path: 'path' }, - }); - - await expect(readableToString(result.data)).resolves.toBe(' .\n'); - expect(result.metadata.contentType).toBe('text/turtle'); - }); - - it('can convert from turtle to JSON-LD.', async(): Promise => { - const representation = new BasicRepresentation( - ' .', - 'text/turtle', - ); - - const result = await converter.handleSafe({ - representation, - preferences: { type: { 'application/ld+json': 1 }}, - identifier: { path: 'path' }, - }); - - expect(JSON.parse(await readableToString(result.data))).toEqual( - [{ '@id': 'http://test.com/s', 'http://test.com/p': [{ '@id': 'http://test.com/o' }]}], - ); - expect(result.metadata.contentType).toBe('application/ld+json'); - }); -}); diff --git a/test/integration/RestrictedIdentity.test.ts b/test/integration/RestrictedIdentity.test.ts index 853a26464..6295d937f 100644 --- a/test/integration/RestrictedIdentity.test.ts +++ b/test/integration/RestrictedIdentity.test.ts @@ -94,13 +94,15 @@ describe('A server with restricted IDP access', (): void => { it('can still access registration with the correct credentials.', async(): Promise => { // Logging into session const state = new IdentityTestState(baseUrl, 'http://mockedredirect/', baseUrl); - const url = await state.startSession(); - await state.parseLoginPage(url); - await state.login(url, settings.email, settings.password); + let url = await state.startSession(); + let res = await state.fetchIdp(url); + expect(res.status).toBe(200); + url = await state.login(url, settings.email, settings.password); + await state.consent(url); expect(state.session.info?.webId).toBe(webId); // Registration still works for this WebID - let res = await state.session.fetch(`${baseUrl}idp/register/`); + res = await state.session.fetch(`${baseUrl}idp/register/`); expect(res.status).toBe(200); res = await state.session.fetch(`${baseUrl}idp/register/`, { diff --git a/test/integration/Setup.test.ts b/test/integration/Setup.test.ts index b4be03ef3..f8976e975 100644 --- a/test/integration/Setup.test.ts +++ b/test/integration/Setup.test.ts @@ -33,21 +33,24 @@ describe('A Solid server with setup', (): void => { it('catches all requests.', async(): Promise => { let res = await fetch(baseUrl, { method: 'GET', headers: { accept: 'text/html' }}); expect(res.status).toBe(200); + expect(res.url).toBe(setupUrl); await expect(res.text()).resolves.toContain('Set up your Solid server'); res = await fetch(joinUrl(baseUrl, '/random/path/'), { method: 'GET', headers: { accept: 'text/html' }}); expect(res.status).toBe(200); + expect(res.url).toBe(setupUrl); await expect(res.text()).resolves.toContain('Set up your Solid server'); - res = await fetch(joinUrl(baseUrl, '/random/path/'), { method: 'PUT', headers: { accept: 'text/html' }}); + res = await fetch(joinUrl(baseUrl, '/random/path/'), { method: 'PUT' }); expect(res.status).toBe(405); - await expect(res.text()).resolves.toContain('Set up your Solid server'); + expect(res.url).toBe(setupUrl); + await expect(res.json()).resolves.toEqual(expect.objectContaining({ name: 'MethodNotAllowedHttpError' })); }); it('can create a server that disables root but allows registration.', async(): Promise => { - let res = await fetch(setupUrl, { method: 'POST', headers: { accept: 'text/html' }}); + let res = await fetch(setupUrl, { method: 'POST' }); expect(res.status).toBe(200); - await expect(res.text()).resolves.toContain('Server setup complete'); + await expect(res.json()).resolves.toEqual({ initialize: false, registration: false }); // Root access disabled res = await fetch(baseUrl); @@ -57,7 +60,7 @@ describe('A Solid server with setup', (): void => { const registerParams = { email, podName, password, confirmPassword: password, createWebId: true }; res = await fetch(joinUrl(baseUrl, 'idp/register/'), { method: 'POST', - headers: { accept: 'text/html', 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json' }, body: JSON.stringify(registerParams), }); expect(res.status).toBe(200); @@ -70,11 +73,11 @@ describe('A Solid server with setup', (): void => { it('can create a server with a public root.', async(): Promise => { let res = await fetch(setupUrl, { method: 'POST', - headers: { accept: 'text/html', 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ initialize: true }), }); expect(res.status).toBe(200); - await expect(res.text()).resolves.toContain('Server setup complete'); + await expect(res.json()).resolves.toEqual({ initialize: true, registration: false }); // Root access enabled res = await fetch(baseUrl); @@ -85,7 +88,7 @@ describe('A Solid server with setup', (): void => { const registerParams = { email, podName, password, confirmPassword: password, createWebId: true, rootPod: true }; res = await fetch(joinUrl(baseUrl, 'idp/register/'), { method: 'POST', - headers: { accept: 'text/html', 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json' }, body: JSON.stringify(registerParams), }); expect(res.status).toBe(500); @@ -95,11 +98,19 @@ describe('A Solid server with setup', (): void => { const registerParams = { email, podName, password, confirmPassword: password, createWebId: true, rootPod: true }; let res = await fetch(setupUrl, { method: 'POST', - headers: { accept: 'text/html', 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ registration: true, initialize: true, ...registerParams }), }); expect(res.status).toBe(200); - await expect(res.text()).resolves.toContain('Server setup complete'); + const json = await res.json(); + expect(json).toEqual(expect.objectContaining({ + registration: true, + initialize: false, + oidcIssuer: baseUrl, + webId: `${baseUrl}profile/card#me`, + email, + podBaseUrl: baseUrl, + })); // Root profile created res = await fetch(joinUrl(baseUrl, '/profile/card')); @@ -109,7 +120,7 @@ describe('A Solid server with setup', (): void => { // Pod root is not accessible even though initialize was set to true res = await fetch(joinUrl(baseUrl, 'resource'), { method: 'PUT', - headers: { accept: 'text/html', 'content-type': 'text/plain' }, + headers: { 'content-type': 'text/plain' }, body: 'random data', }); expect(res.status).toBe(401); diff --git a/test/integration/config/ldp-with-auth.json b/test/integration/config/ldp-with-auth.json index b3c60f09f..4ef09bd4d 100644 --- a/test/integration/config/ldp-with-auth.json +++ b/test/integration/config/ldp-with-auth.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", diff --git a/test/integration/config/quota-global.json b/test/integration/config/quota-global.json new file mode 100644 index 000000000..10d4116bf --- /dev/null +++ b/test/integration/config/quota-global.json @@ -0,0 +1,65 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "import": [ + "files-scs:config/app/main/default.json", + "files-scs:config/app/init/initialize-root.json", + "files-scs:config/app/setup/disabled.json", + "files-scs:config/http/handler/default.json", + "files-scs:config/http/middleware/websockets.json", + "files-scs:config/http/server-factory/websockets.json", + "files-scs:config/http/static/default.json", + "files-scs:config/identity/access/public.json", + "files-scs:config/identity/email/default.json", + "files-scs:config/identity/handler/default.json", + "files-scs:config/identity/ownership/token.json", + "files-scs:config/identity/pod/static.json", + "files-scs:config/identity/registration/enabled.json", + "files-scs:config/ldp/authentication/dpop-bearer.json", + "files-scs:config/ldp/authorization/allow-all.json", + "files-scs:config/ldp/handler/default.json", + "files-scs:config/ldp/metadata-parser/default.json", + "files-scs:config/ldp/metadata-writer/default.json", + "files-scs:config/ldp/modes/default.json", + "files-scs:config/storage/backend/global-quota-file.json", + "files-scs:config/storage/key-value/resource-store.json", + "files-scs:config/storage/middleware/default.json", + "files-scs:config/util/auxiliary/acl.json", + "files-scs:config/util/identifiers/suffix.json", + "files-scs:config/util/index/default.json", + "files-scs:config/util/logging/winston.json", + "files-scs:config/util/representation-conversion/default.json", + "files-scs:config/util/resource-locker/memory.json", + "files-scs:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": "A single-pod server that stores its resources on disk while enforcing quota." + }, + { + "comment": "The set quota enforced globally", + "@id": "urn:solid-server:default:variable:GlobalQuota", + "@type": "Variable" + }, + { + "@id": "urn:solid-server:default:QuotaStrategy", + "GlobalQuotaStrategy:_limit_amount": { + "@id": "urn:solid-server:default:variable:GlobalQuota" + }, + "GlobalQuotaStrategy:_limit_unit": "bytes" + }, + { + "@id": "urn:solid-server:default:SizeReporter", + "FileSizeReporter:_ignoreFolders": [ "^/\\.internal$" ] + }, + { + "@id": "urn:solid-server:test:Instances", + "@type": "RecordObject", + "record": [ + { + "RecordObject:_record_key": "app", + "RecordObject:_record_value": { "@id": "urn:solid-server:default:App" } + } + ] + } + ] +} diff --git a/test/integration/config/quota-pod.json b/test/integration/config/quota-pod.json new file mode 100644 index 000000000..0e5157a01 --- /dev/null +++ b/test/integration/config/quota-pod.json @@ -0,0 +1,61 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "import": [ + "files-scs:config/app/main/default.json", + "files-scs:config/app/init/initialize-root.json", + "files-scs:config/app/setup/disabled.json", + "files-scs:config/http/handler/default.json", + "files-scs:config/http/middleware/websockets.json", + "files-scs:config/http/server-factory/websockets.json", + "files-scs:config/http/static/default.json", + "files-scs:config/identity/access/public.json", + "files-scs:config/identity/email/default.json", + "files-scs:config/identity/handler/default.json", + "files-scs:config/identity/ownership/token.json", + "files-scs:config/identity/pod/static.json", + "files-scs:config/identity/registration/enabled.json", + "files-scs:config/ldp/authentication/dpop-bearer.json", + "files-scs:config/ldp/authorization/allow-all.json", + "files-scs:config/ldp/handler/default.json", + "files-scs:config/ldp/metadata-parser/default.json", + "files-scs:config/ldp/metadata-writer/default.json", + "files-scs:config/ldp/modes/default.json", + "files-scs:config/storage/backend/pod-quota-file.json", + "files-scs:config/storage/key-value/resource-store.json", + "files-scs:config/storage/middleware/default.json", + "files-scs:config/util/auxiliary/acl.json", + "files-scs:config/util/identifiers/suffix.json", + "files-scs:config/util/index/default.json", + "files-scs:config/util/logging/winston.json", + "files-scs:config/util/representation-conversion/default.json", + "files-scs:config/util/resource-locker/memory.json", + "files-scs:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": "A single-pod server that stores its resources on disk while enforcing quota." + }, + { + "comment": "The set quota enforced per pod", + "@id": "urn:solid-server:default:variable:PodQuota", + "@type": "Variable" + }, + { + "@id": "urn:solid-server:default:QuotaStrategy", + "PodQuotaStrategy:_limit_amount": { + "@id": "urn:solid-server:default:variable:PodQuota" + }, + "PodQuotaStrategy:_limit_unit": "bytes" + }, + { + "@id": "urn:solid-server:test:Instances", + "@type": "RecordObject", + "record": [ + { + "RecordObject:_record_key": "app", + "RecordObject:_record_value": { "@id": "urn:solid-server:default:App" } + } + ] + } + ] +} diff --git a/test/integration/config/restricted-idp.json b/test/integration/config/restricted-idp.json index 37ecdf923..289334e78 100644 --- a/test/integration/config/restricted-idp.json +++ b/test/integration/config/restricted-idp.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", diff --git a/test/integration/config/run-with-redlock.json b/test/integration/config/run-with-redlock.json index d61ebfa24..ae8164828 100644 --- a/test/integration/config/run-with-redlock.json +++ b/test/integration/config/run-with-redlock.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", diff --git a/test/integration/config/server-dynamic-unsafe.json b/test/integration/config/server-dynamic-unsafe.json index 60c15858d..3cb0ae6a4 100644 --- a/test/integration/config/server-dynamic-unsafe.json +++ b/test/integration/config/server-dynamic-unsafe.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", diff --git a/test/integration/config/server-memory.json b/test/integration/config/server-memory.json index c45937399..1da805998 100644 --- a/test/integration/config/server-memory.json +++ b/test/integration/config/server-memory.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", diff --git a/test/integration/config/server-middleware.json b/test/integration/config/server-middleware.json index 112f03a4f..b7e3bc8be 100644 --- a/test/integration/config/server-middleware.json +++ b/test/integration/config/server-middleware.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/http/handler/simple.json", "files-scs:config/http/middleware/websockets.json", diff --git a/test/integration/config/server-subdomains-unsafe.json b/test/integration/config/server-subdomains-unsafe.json index 6d701ef4a..ebe811732 100644 --- a/test/integration/config/server-subdomains-unsafe.json +++ b/test/integration/config/server-subdomains-unsafe.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", diff --git a/test/integration/config/server-without-auth.json b/test/integration/config/server-without-auth.json index 963980f47..5b9bdbf12 100644 --- a/test/integration/config/server-without-auth.json +++ b/test/integration/config/server-without-auth.json @@ -1,10 +1,10 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", "files-scs:config/app/setup/disabled.json", - "files-scs:config/http/handler/default.json", + "files-scs:config/http/handler/simple.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", "files-scs:config/http/static/default.json", @@ -26,9 +26,5 @@ "files-scs:config/util/variables/default.json" ], "@graph": [ - { - "@id": "urn:solid-server:default:IdentityProviderHandler", - "@type": "UnsupportedAsyncHandler" - } ] } diff --git a/test/integration/config/setup-memory.json b/test/integration/config/setup-memory.json index c4afb8bd9..929547e92 100644 --- a/test/integration/config/setup-memory.json +++ b/test/integration/config/setup-memory.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", diff --git a/test/unit/authorization/access/AgentGroupAccessChecker.test.ts b/test/unit/authorization/access/AgentGroupAccessChecker.test.ts index ae86a9356..adc44727c 100644 --- a/test/unit/authorization/access/AgentGroupAccessChecker.test.ts +++ b/test/unit/authorization/access/AgentGroupAccessChecker.test.ts @@ -3,7 +3,6 @@ import type { AccessCheckerArgs } from '../../../../src/authorization/access/Acc import { AgentGroupAccessChecker } from '../../../../src/authorization/access/AgentGroupAccessChecker'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../../src/http/representation/Representation'; -import type { RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter'; import type { ExpiringStorage } from '../../../../src/storage/keyvalue/ExpiringStorage'; import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes'; import * as fetchUtil from '../../../../src/util/FetchUtil'; @@ -18,7 +17,6 @@ describe('An AgentGroupAccessChecker', (): void => { acl.addQuad(namedNode('noMatch'), ACL.terms.agentGroup, namedNode('badGroup')); let fetchMock: jest.SpyInstance; let representation: Representation; - const converter: RepresentationConverter = {} as any; let cache: ExpiringStorage>; let checker: AgentGroupAccessChecker; @@ -31,7 +29,7 @@ describe('An AgentGroupAccessChecker', (): void => { cache = new Map() as any; - checker = new AgentGroupAccessChecker(converter, cache); + checker = new AgentGroupAccessChecker(cache); }); it('can handle all requests.', async(): Promise => { diff --git a/test/unit/authorization/permissions/N3PatchModesExtractor.test.ts b/test/unit/authorization/permissions/N3PatchModesExtractor.test.ts new file mode 100644 index 000000000..7b4688858 --- /dev/null +++ b/test/unit/authorization/permissions/N3PatchModesExtractor.test.ts @@ -0,0 +1,62 @@ +import { DataFactory } from 'n3'; +import type { Quad } from 'rdf-js'; +import { N3PatchModesExtractor } from '../../../../src/authorization/permissions/N3PatchModesExtractor'; +import { AccessMode } from '../../../../src/authorization/permissions/Permissions'; +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { N3Patch } from '../../../../src/http/representation/N3Patch'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; + +const { quad, namedNode } = DataFactory; + +describe('An N3PatchModesExtractor', (): void => { + const triple: Quad = quad(namedNode('a'), namedNode('b'), namedNode('c')); + let patch: N3Patch; + let operation: Operation; + const extractor = new N3PatchModesExtractor(); + + beforeEach(async(): Promise => { + patch = new BasicRepresentation() as N3Patch; + patch.deletes = []; + patch.inserts = []; + patch.conditions = []; + + operation = { + method: 'PATCH', + body: patch, + preferences: {}, + target: { path: 'http://example.com/foo' }, + }; + }); + + it('can only handle N3 Patch documents.', async(): Promise => { + operation.body = new BasicRepresentation(); + await expect(extractor.canHandle(operation)).rejects.toThrow(NotImplementedHttpError); + + operation.body = patch; + await expect(extractor.canHandle(operation)).resolves.toBeUndefined(); + }); + + it('requires read access when there are conditions.', async(): Promise => { + patch.conditions = [ triple ]; + await expect(extractor.handle(operation)).resolves.toEqual(new Set([ AccessMode.read ])); + }); + + it('requires append access when there are inserts.', async(): Promise => { + patch.inserts = [ triple ]; + await expect(extractor.handle(operation)).resolves.toEqual(new Set([ AccessMode.append ])); + }); + + it('requires read and write access when there are inserts.', async(): Promise => { + patch.deletes = [ triple ]; + await expect(extractor.handle(operation)).resolves.toEqual(new Set([ AccessMode.read, AccessMode.write ])); + }); + + it('combines required access modes when required.', async(): Promise => { + patch.conditions = [ triple ]; + patch.inserts = [ triple ]; + patch.deletes = [ triple ]; + await expect(extractor.handle(operation)).resolves + .toEqual(new Set([ AccessMode.read, AccessMode.append, AccessMode.write ])); + }); +}); diff --git a/test/unit/authorization/permissions/SparqlPatchModesExtractor.test.ts b/test/unit/authorization/permissions/SparqlUpdateModesExtractor.test.ts similarity index 85% rename from test/unit/authorization/permissions/SparqlPatchModesExtractor.test.ts rename to test/unit/authorization/permissions/SparqlUpdateModesExtractor.test.ts index 9fdf97a1f..eb03643f6 100644 --- a/test/unit/authorization/permissions/SparqlPatchModesExtractor.test.ts +++ b/test/unit/authorization/permissions/SparqlUpdateModesExtractor.test.ts @@ -1,25 +1,21 @@ import { Factory } from 'sparqlalgebrajs'; import { AccessMode } from '../../../../src/authorization/permissions/Permissions'; -import { SparqlPatchModesExtractor } from '../../../../src/authorization/permissions/SparqlPatchModesExtractor'; +import { SparqlUpdateModesExtractor } from '../../../../src/authorization/permissions/SparqlUpdateModesExtractor'; import type { Operation } from '../../../../src/http/Operation'; import type { SparqlUpdatePatch } from '../../../../src/http/representation/SparqlUpdatePatch'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; -describe('A SparqlPatchModesExtractor', (): void => { - const extractor = new SparqlPatchModesExtractor(); +describe('A SparqlUpdateModesExtractor', (): void => { + const extractor = new SparqlUpdateModesExtractor(); const factory = new Factory(); - it('can only handle (composite) SPARQL DELETE/INSERT PATCH operations.', async(): Promise => { + it('can only handle (composite) SPARQL DELETE/INSERT operations.', async(): Promise => { const operation = { method: 'PATCH', body: { algebra: factory.createDeleteInsert() }} as unknown as Operation; await expect(extractor.canHandle(operation)).resolves.toBeUndefined(); (operation.body as SparqlUpdatePatch).algebra = factory.createCompositeUpdate([ factory.createDeleteInsert() ]); await expect(extractor.canHandle(operation)).resolves.toBeUndefined(); - let result = extractor.canHandle({ ...operation, method: 'GET' }); - await expect(result).rejects.toThrow(NotImplementedHttpError); - await expect(result).rejects.toThrow('Cannot determine permissions of GET, only PATCH.'); - - result = extractor.canHandle({ ...operation, body: {} as SparqlUpdatePatch }); + let result = extractor.canHandle({ ...operation, body: {} as SparqlUpdatePatch }); await expect(result).rejects.toThrow(NotImplementedHttpError); await expect(result).rejects.toThrow('Cannot determine permissions of non-SPARQL patches.'); diff --git a/test/unit/http/auxiliary/ComposedAuxiliaryStrategy.test.ts b/test/unit/http/auxiliary/ComposedAuxiliaryStrategy.test.ts index 4e07aca1c..cdc961c8e 100644 --- a/test/unit/http/auxiliary/ComposedAuxiliaryStrategy.test.ts +++ b/test/unit/http/auxiliary/ComposedAuxiliaryStrategy.test.ts @@ -61,10 +61,10 @@ describe('A ComposedAuxiliaryStrategy', (): void => { }); it('validates data through the Validator.', async(): Promise => { - const representation = { data: 'data!' } as any; + const representation = { data: 'data!', metadata: { identifier: { value: 'any' }}} as any; await expect(strategy.validate(representation)).resolves.toBeUndefined(); expect(validator.handleSafe).toHaveBeenCalledTimes(1); - expect(validator.handleSafe).toHaveBeenLastCalledWith(representation); + expect(validator.handleSafe).toHaveBeenLastCalledWith({ representation, identifier: { path: 'any' }}); }); it('defaults isRequiredInRoot to false.', async(): Promise => { diff --git a/test/unit/http/auxiliary/RdfValidator.test.ts b/test/unit/http/auxiliary/RdfValidator.test.ts index 71b5b48a0..16e3fe8f4 100644 --- a/test/unit/http/auxiliary/RdfValidator.test.ts +++ b/test/unit/http/auxiliary/RdfValidator.test.ts @@ -1,5 +1,6 @@ import { RdfValidator } from '../../../../src/http/auxiliary/RdfValidator'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; import type { RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter'; import { readableToString } from '../../../../src/util/StreamUtil'; import { StaticAsyncHandler } from '../../../util/StaticAsyncHandler'; @@ -8,6 +9,7 @@ import 'jest-rdf'; describe('An RdfValidator', (): void => { let converter: RepresentationConverter; let validator: RdfValidator; + const identifier: ResourceIdentifier = { path: 'any/path' }; beforeEach(async(): Promise => { converter = new StaticAsyncHandler(true, null); @@ -20,14 +22,15 @@ describe('An RdfValidator', (): void => { it('always accepts content-type internal/quads.', async(): Promise => { const representation = new BasicRepresentation('data', 'internal/quads'); - await expect(validator.handle(representation)).resolves.toBeUndefined(); + await expect(validator.handle({ representation, identifier })).resolves.toEqual(representation); }); it('validates data by running it through a converter.', async(): Promise => { converter.handleSafe = jest.fn().mockResolvedValue(new BasicRepresentation('transformedData', 'wrongType')); const representation = new BasicRepresentation('data', 'content-type'); const quads = representation.metadata.quads(); - await expect(validator.handle(representation)).resolves.toBeUndefined(); + // Output is not important for this Validator + await expect(validator.handle({ representation, identifier })).resolves.toBeDefined(); // Make sure the data can still be streamed await expect(readableToString(representation.data)).resolves.toBe('data'); // Make sure the metadata was not changed @@ -37,7 +40,7 @@ describe('An RdfValidator', (): void => { it('throws an error when validating invalid data.', async(): Promise => { converter.handleSafe = jest.fn().mockRejectedValue(new Error('bad data!')); const representation = new BasicRepresentation('data', 'content-type'); - await expect(validator.handle(representation)).rejects.toThrow('bad data!'); + await expect(validator.handle({ representation, identifier })).rejects.toThrow('bad data!'); // Make sure the data on the readable has not been reset expect(representation.data.destroyed).toBe(true); }); diff --git a/test/unit/http/input/body/N3PatchBodyParser.test.ts b/test/unit/http/input/body/N3PatchBodyParser.test.ts new file mode 100644 index 000000000..902baec41 --- /dev/null +++ b/test/unit/http/input/body/N3PatchBodyParser.test.ts @@ -0,0 +1,204 @@ +import 'jest-rdf'; +import type { Term } from '@rdfjs/types'; +import { DataFactory } from 'n3'; +import type { BodyParserArgs } from '../../../../../src/http/input/body/BodyParser'; +import { N3PatchBodyParser } from '../../../../../src/http/input/body/N3PatchBodyParser'; +import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata'; +import type { HttpRequest } from '../../../../../src/server/HttpRequest'; +import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; +import { UnsupportedMediaTypeHttpError } from '../../../../../src/util/errors/UnsupportedMediaTypeHttpError'; +import { guardedStreamFrom } from '../../../../../src/util/StreamUtil'; +const { defaultGraph, literal, namedNode, quad, variable } = DataFactory; + +describe('An N3PatchBodyParser', (): void => { + let input: BodyParserArgs; + const parser = new N3PatchBodyParser(); + + beforeEach(async(): Promise => { + input = { + request: { headers: {}} as HttpRequest, + metadata: new RepresentationMetadata({ path: 'http://example.com/foo' }, 'text/n3'), + }; + }); + + it('can only handle N3 data.', async(): Promise => { + input.metadata.contentType = 'text/plain'; + await expect(parser.canHandle(input)).rejects.toThrow(UnsupportedMediaTypeHttpError); + input.metadata.contentType = 'text/n3'; + await expect(parser.canHandle(input)).resolves.toBeUndefined(); + }); + + it('errors on invalid N3.', async(): Promise => { + input.request = guardedStreamFrom([ 'invalid syntax' ]) as HttpRequest; + await expect(parser.handle(input)).rejects.toThrow(BadRequestHttpError); + }); + + it('extracts the patch quads from the request.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { ?person ex:givenName "Alex". }; + solid:deletes { ?person ex:givenName "Claudia". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + const patch = await parser.handle(input); + expect(patch.conditions).toBeRdfIsomorphic([ + quad(variable('person'), namedNode('http://www.example.org/terms#familyName'), literal('Garcia')), + quad(variable('person'), namedNode('http://www.example.org/terms#nickName'), literal('Garry')), + ]); + expect(patch.inserts).toBeRdfIsomorphic([ + quad(variable('person'), namedNode('http://www.example.org/terms#givenName'), literal('Alex')), + ]); + expect(patch.deletes).toBeRdfIsomorphic([ + quad(variable('person'), namedNode('http://www.example.org/terms#givenName'), literal('Claudia')), + ]); + }); + + it('strips the graph from the result quads.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { ?person ex:givenName "Alex". }; + solid:deletes { ?person ex:givenName "Claudia". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + const patch = await parser.handle(input); + const quads = [ ...patch.deletes, ...patch.inserts, ...patch.conditions ]; + const uniqueGraphs = [ ...new Set(quads.map((entry): Term => entry.graph)) ]; + expect(uniqueGraphs).toHaveLength(1); + expect(uniqueGraphs[0]).toEqualRdfTerm(defaultGraph()); + }); + + it('errors if no solid:InsertDeletePatch is found.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { ?person ex:givenName "Alex". }; + solid:deletes { ?person ex:givenName "Claudia". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects.toThrow( + 'This patcher only supports N3 Patch documents with exactly 1 solid:InsertDeletePatch entry, but received 0.', + ); + }); + + it('errors if multiple solid:InsertDeletePatch entries are found.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:other a solid:InsertDeletePatch. + +_:rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { ?person ex:givenName "Alex". }; + solid:deletes { ?person ex:givenName "Claudia". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects.toThrow( + 'This patcher only supports N3 Patch documents with exactly 1 solid:InsertDeletePatch entry, but received 2.', + ); + }); + + it('errors if the patch subject is not a blank or named node.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +?rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { ?person ex:givenName "Alex". }; + solid:deletes { ?person ex:givenName "Claudia". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects + .toThrow('An N3 Patch subject needs to be a blank or named node.'); + }); + + it('errors if there are multiple where entries.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:where { ?person ex:givenName "Alex". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects + .toThrow('An N3 Patch can have at most 1 http://www.w3.org/ns/solid/terms#where.'); + }); + + it('errors if there are multiple delete entries.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:deletes { ex:person ex:familyName "Garcia". }; + solid:deletes { ex:person ex:givenName "Alex". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects + .toThrow('An N3 Patch can have at most 1 http://www.w3.org/ns/solid/terms#deletes.'); + }); + + it('errors if there are multiple insert entries.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:inserts { ex:person ex:familyName "Garcia". }; + solid:inserts { ex:person ex:givenName "Alex". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects + .toThrow('An N3 Patch can have at most 1 http://www.w3.org/ns/solid/terms#inserts.'); + }); + + it('errors if there are blank nodes in the delete formula.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { ?person ex:givenName "Alex". }; + solid:deletes { _:person ex:givenName "Claudia". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects + .toThrow('An N3 Patch delete/insert formula can not contain blank nodes.'); + }); + + it('errors if there are blank nodes in the insert formula.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { _:person ex:givenName "Alex". }; + solid:deletes { ?person ex:givenName "Claudia". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects + .toThrow('An N3 Patch delete/insert formula can not contain blank nodes.'); + }); + + it('errors if there are unknown variables in the delete formula.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { ?person ex:givenName "Alex". }; + solid:deletes { ?person ex:givenName ?name. }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects + .toThrow('An N3 Patch delete/insert formula can only contain variables found in the conditions formula.'); + }); + + it('errors if there are unknown variables in the insert formula.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { ?person ex:givenName ?name. }; + solid:deletes { ?person ex:givenName "Claudia". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects + .toThrow('An N3 Patch delete/insert formula can only contain variables found in the conditions formula.'); + }); +}); diff --git a/test/unit/http/input/metadata/ContentLengthParser.test.ts b/test/unit/http/input/metadata/ContentLengthParser.test.ts new file mode 100644 index 000000000..1805ed9eb --- /dev/null +++ b/test/unit/http/input/metadata/ContentLengthParser.test.ts @@ -0,0 +1,32 @@ +import { ContentLengthParser } from '../../../../../src/http/input/metadata/ContentLengthParser'; +import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata'; +import type { HttpRequest } from '../../../../../src/server/HttpRequest'; + +describe('A ContentLengthParser', (): void => { + const parser = new ContentLengthParser(); + let request: HttpRequest; + let metadata: RepresentationMetadata; + + beforeEach(async(): Promise => { + request = { headers: {}} as HttpRequest; + metadata = new RepresentationMetadata(); + }); + + it('does nothing if there is no content-length header.', async(): Promise => { + await expect(parser.handle({ request, metadata })).resolves.toBeUndefined(); + expect(metadata.quads()).toHaveLength(0); + }); + + it('sets the given content-length as metadata.', async(): Promise => { + request.headers['content-length'] = '50'; + await expect(parser.handle({ request, metadata })).resolves.toBeUndefined(); + expect(metadata.quads()).toHaveLength(1); + expect(metadata.contentLength).toBe(50); + }); + + it('does not set a content-length when the header is invalid.', async(): Promise => { + request.headers['content-length'] = 'aabbcc50ccbbaa'; + await expect(parser.handle({ request, metadata })).resolves.toBeUndefined(); + expect(metadata.quads()).toHaveLength(0); + }); +}); diff --git a/test/unit/http/output/error/RedirectingErrorHandler.test.ts b/test/unit/http/output/error/RedirectingErrorHandler.test.ts new file mode 100644 index 000000000..1cdd2046b --- /dev/null +++ b/test/unit/http/output/error/RedirectingErrorHandler.test.ts @@ -0,0 +1,25 @@ +import { RedirectingErrorHandler } from '../../../../../src/http/output/error/RedirectingErrorHandler'; +import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; +import { FoundHttpError } from '../../../../../src/util/errors/FoundHttpError'; +import { NotImplementedHttpError } from '../../../../../src/util/errors/NotImplementedHttpError'; +import { SOLID_HTTP } from '../../../../../src/util/Vocabularies'; + +describe('A RedirectingErrorHandler', (): void => { + const preferences = {}; + const handler = new RedirectingErrorHandler(); + + it('only accepts redirect errors.', async(): Promise => { + const unsupportedError = new BadRequestHttpError(); + await expect(handler.canHandle({ error: unsupportedError, preferences })).rejects.toThrow(NotImplementedHttpError); + + const supportedError = new FoundHttpError('http://test.com/foo/bar'); + await expect(handler.canHandle({ error: supportedError, preferences })).resolves.toBeUndefined(); + }); + + it('creates redirect responses.', async(): Promise => { + const error = new FoundHttpError('http://test.com/foo/bar'); + const result = await handler.handle({ error, preferences }); + expect(result.statusCode).toBe(error.statusCode); + expect(result.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(error.location); + }); +}); diff --git a/test/unit/http/output/response/RedirectResponseDescription.test.ts b/test/unit/http/output/response/RedirectResponseDescription.test.ts index 7bbac99fc..2f9096abb 100644 --- a/test/unit/http/output/response/RedirectResponseDescription.test.ts +++ b/test/unit/http/output/response/RedirectResponseDescription.test.ts @@ -1,18 +1,13 @@ import { RedirectResponseDescription } from '../../../../../src/http/output/response/RedirectResponseDescription'; +import { FoundHttpError } from '../../../../../src/util/errors/FoundHttpError'; import { SOLID_HTTP } from '../../../../../src/util/Vocabularies'; describe('A RedirectResponseDescription', (): void => { - const location = 'http://test.com/foo'; + const error = new FoundHttpError('http://test.com/foo'); - it('has status code 302 and a location.', async(): Promise => { - const description = new RedirectResponseDescription(location); - expect(description.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(location); - expect(description.statusCode).toBe(302); - }); - - it('has status code 301 if the change is permanent.', async(): Promise => { - const description = new RedirectResponseDescription(location, true); - expect(description.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(location); - expect(description.statusCode).toBe(301); + it('has status the code and location of the error.', async(): Promise => { + const description = new RedirectResponseDescription(error); + expect(description.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(error.location); + expect(description.statusCode).toBe(error.statusCode); }); }); diff --git a/test/unit/http/representation/RepresentationMetadata.test.ts b/test/unit/http/representation/RepresentationMetadata.test.ts index 0ead43f12..5032f6f31 100644 --- a/test/unit/http/representation/RepresentationMetadata.test.ts +++ b/test/unit/http/representation/RepresentationMetadata.test.ts @@ -61,6 +61,16 @@ describe('A RepresentationMetadata', (): void => { expect(metadata.contentType).toBe('text/turtle'); }); + it('stores the content-length correctly.', async(): Promise => { + metadata = new RepresentationMetadata(); + metadata.contentLength = 50; + expect(metadata.contentLength).toBe(50); + + metadata = new RepresentationMetadata(); + metadata.contentLength = undefined; + expect(metadata.contentLength).toBeUndefined(); + }); + it('copies an other metadata object.', async(): Promise => { const other = new RepresentationMetadata({ path: 'otherId' }, { 'test:pred': 'objVal' }); metadata = new RepresentationMetadata(other); diff --git a/test/unit/identity/ControlHandler.test.ts b/test/unit/identity/ControlHandler.test.ts new file mode 100644 index 000000000..cafa667f2 --- /dev/null +++ b/test/unit/identity/ControlHandler.test.ts @@ -0,0 +1,52 @@ +import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation'; +import { ControlHandler } from '../../../src/identity/interaction/ControlHandler'; +import type { InteractionHandler, InteractionHandlerInput } from '../../../src/identity/interaction/InteractionHandler'; +import type { InteractionRoute } from '../../../src/identity/interaction/routing/InteractionRoute'; +import { APPLICATION_JSON } from '../../../src/util/ContentTypes'; +import { InternalServerError } from '../../../src/util/errors/InternalServerError'; +import { readJsonStream } from '../../../src/util/StreamUtil'; + +describe('A ControlHandler', (): void => { + const input: InteractionHandlerInput = {} as any; + let controls: Record>; + let source: jest.Mocked; + let handler: ControlHandler; + + beforeEach(async(): Promise => { + controls = { + login: { getPath: jest.fn().mockReturnValue('http://example.com/login/') } as any, + register: { getPath: jest.fn().mockReturnValue('http://example.com/register/') } as any, + }; + + source = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue(new BasicRepresentation(JSON.stringify({ data: 'data' }), APPLICATION_JSON)), + } as any; + + handler = new ControlHandler(source, controls); + }); + + it('can handle any input its source can handle.', async(): Promise => { + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + + source.canHandle.mockRejectedValueOnce(new Error('bad data')); + await expect(handler.canHandle(input)).rejects.toThrow('bad data'); + }); + + it('errors in case its source does not return JSON.', async(): Promise => { + source.handle.mockResolvedValueOnce(new BasicRepresentation()); + await expect(handler.handle(input)).rejects.toThrow(InternalServerError); + }); + + it('adds controls to the source response.', async(): Promise => { + const result = await handler.handle(input); + await expect(readJsonStream(result.data)).resolves.toEqual({ + data: 'data', + apiVersion: '0.3', + controls: { + login: 'http://example.com/login/', + register: 'http://example.com/register/', + }, + }); + }); +}); diff --git a/test/unit/identity/IdentityProviderHttpHandler.test.ts b/test/unit/identity/IdentityProviderHttpHandler.test.ts index d11f8f367..9b5b2fdad 100644 --- a/test/unit/identity/IdentityProviderHttpHandler.test.ts +++ b/test/unit/identity/IdentityProviderHttpHandler.test.ts @@ -1,15 +1,12 @@ import type { Provider } from 'oidc-provider'; import type { Operation } from '../../../src/http/Operation'; -import type { ErrorHandler, ErrorHandlerArgs } from '../../../src/http/output/error/ErrorHandler'; -import type { ResponseDescription } from '../../../src/http/output/response/ResponseDescription'; import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../src/http/representation/Representation'; import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata'; import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory'; import type { IdentityProviderHttpHandlerArgs } from '../../../src/identity/IdentityProviderHttpHandler'; import { IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler'; -import type { InteractionRoute } from '../../../src/identity/interaction/routing/InteractionRoute'; -import type { InteractionCompleter } from '../../../src/identity/interaction/util/InteractionCompleter'; +import type { Interaction, InteractionHandler } from '../../../src/identity/interaction/InteractionHandler'; import type { HttpRequest } from '../../../src/server/HttpRequest'; import type { HttpResponse } from '../../../src/server/HttpResponse'; import { getBestPreference } from '../../../src/storage/conversion/ConversionUtil'; @@ -17,25 +14,20 @@ import type { RepresentationConverter, RepresentationConverterArgs, } from '../../../src/storage/conversion/RepresentationConverter'; -import { joinUrl } from '../../../src/util/PathUtil'; -import { guardedStreamFrom, readableToString } from '../../../src/util/StreamUtil'; -import { CONTENT_TYPE, SOLID_HTTP, SOLID_META } from '../../../src/util/Vocabularies'; +import { APPLICATION_JSON, APPLICATION_X_WWW_FORM_URLENCODED } from '../../../src/util/ContentTypes'; +import { CONTENT_TYPE } from '../../../src/util/Vocabularies'; describe('An IdentityProviderHttpHandler', (): void => { - const apiVersion = '0.2'; - const baseUrl = 'http://test.com/'; - const idpPath = '/idp'; const request: HttpRequest = {} as any; const response: HttpResponse = {} as any; + const oidcInteraction: Interaction = {} as any; let operation: Operation; + let representation: Representation; let providerFactory: jest.Mocked; - let routes: Record<'response' | 'complete' | 'error', jest.Mocked>; - let controls: Record; - let interactionCompleter: jest.Mocked; let converter: jest.Mocked; - let errorHandler: jest.Mocked; let provider: jest.Mocked; - let handler: IdentityProviderHttpHandler; + let handler: jest.Mocked; + let idpHandler: IdentityProviderHttpHandler; beforeEach(async(): Promise => { operation = { @@ -46,45 +38,13 @@ describe('An IdentityProviderHttpHandler', (): void => { }; provider = { - callback: jest.fn(), - interactionDetails: jest.fn(), + interactionDetails: jest.fn().mockReturnValue(oidcInteraction), } as any; providerFactory = { getProvider: jest.fn().mockResolvedValue(provider), }; - routes = { - response: { - getControls: jest.fn().mockReturnValue({ response: '/routeResponse' }), - supportsPath: jest.fn((path: string): boolean => /^\/routeResponse$/u.test(path)), - handleOperation: jest.fn().mockResolvedValue({ - type: 'response', - details: { key: 'val' }, - templateFiles: { 'text/html': '/response' }, - }), - }, - complete: { - getControls: jest.fn().mockReturnValue({}), - supportsPath: jest.fn((path: string): boolean => /^\/routeComplete$/u.test(path)), - handleOperation: jest.fn().mockResolvedValue({ - type: 'complete', - details: { webId: 'webId' }, - templateFiles: {}, - }), - }, - error: { - getControls: jest.fn().mockReturnValue({}), - supportsPath: jest.fn((path: string): boolean => /^\/routeError$/u.test(path)), - handleOperation: jest.fn().mockResolvedValue({ - type: 'error', - error: new Error('test error'), - templateFiles: { 'text/html': '/response' }, - }), - }, - }; - controls = { response: 'http://test.com/idp/routeResponse' }; - converter = { handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => { // Just find the best match; @@ -94,130 +54,50 @@ describe('An IdentityProviderHttpHandler', (): void => { }), } as any; - interactionCompleter = { handleSafe: jest.fn().mockResolvedValue('http://test.com/idp/auth') } as any; - - errorHandler = { handleSafe: jest.fn(({ error }: ErrorHandlerArgs): ResponseDescription => ({ - statusCode: 400, - data: guardedStreamFrom(`{ "name": "${error.name}", "message": "${error.message}" }`), - })) } as any; + representation = new BasicRepresentation(); + handler = { + handleSafe: jest.fn().mockResolvedValue(representation), + } as any; const args: IdentityProviderHttpHandlerArgs = { - baseUrl, - idpPath, providerFactory, - interactionRoutes: Object.values(routes), converter, - interactionCompleter, - errorHandler, + handler, }; - handler = new IdentityProviderHttpHandler(args); + idpHandler = new IdentityProviderHttpHandler(args); }); - it('calls the provider if there is no matching route.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, 'invalid'); - await expect(handler.handle({ request, response, operation })).resolves.toBeUndefined(); - expect(provider.callback).toHaveBeenCalledTimes(1); - expect(provider.callback).toHaveBeenLastCalledWith(request, response); - }); - - it('creates Representations for InteractionResponseResults.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, '/idp/routeResponse'); - operation.method = 'POST'; - operation.body = new BasicRepresentation('value', 'text/plain'); - const result = (await handler.handle({ request, response, operation }))!; - expect(result).toBeDefined(); - expect(routes.response.handleOperation).toHaveBeenCalledTimes(1); - expect(routes.response.handleOperation).toHaveBeenLastCalledWith(operation, undefined); - expect(operation.body?.metadata.contentType).toBe('application/json'); - - expect(JSON.parse(await readableToString(result.data!))) - .toEqual({ apiVersion, key: 'val', authenticating: false, controls }); + it('returns the handler result as 200 response.', async(): Promise => { + const result = await idpHandler.handle({ operation, request, response }); expect(result.statusCode).toBe(200); - expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe('/response'); + expect(result.data).toBe(representation.data); + expect(result.metadata).toBe(representation.metadata); + expect(handler.handleSafe).toHaveBeenCalledTimes(1); + expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); }); - it('creates Representations for InteractionErrorResults.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, '/idp/routeError'); - operation.method = 'POST'; - operation.preferences = { type: { 'text/html': 1 }}; - - const result = (await handler.handle({ request, response, operation }))!; - expect(result).toBeDefined(); - expect(routes.error.handleOperation).toHaveBeenCalledTimes(1); - expect(routes.error.handleOperation).toHaveBeenLastCalledWith(operation, undefined); - - expect(JSON.parse(await readableToString(result.data!))) - .toEqual({ apiVersion, name: 'Error', message: 'test error', authenticating: false, controls }); - expect(result.statusCode).toBe(400); - expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe('/response'); + it('passes no interaction if the provider call failed.', async(): Promise => { + provider.interactionDetails.mockRejectedValueOnce(new Error('no interaction')); + const result = await idpHandler.handle({ operation, request, response }); + expect(result.statusCode).toBe(200); + expect(result.data).toBe(representation.data); + expect(result.metadata).toBe(representation.metadata); + expect(handler.handleSafe).toHaveBeenCalledTimes(1); + expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation }); }); - it('adds a prefilled field in case error requests had a body.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, '/idp/routeError'); - operation.method = 'POST'; - operation.preferences = { type: { 'text/html': 1 }}; - operation.body = new BasicRepresentation('{ "key": "val" }', 'application/json'); - - const result = (await handler.handle({ request, response, operation }))!; - expect(result).toBeDefined(); - expect(routes.error.handleOperation).toHaveBeenCalledTimes(1); - expect(routes.error.handleOperation).toHaveBeenLastCalledWith(operation, undefined); - expect(operation.body?.metadata.contentType).toBe('application/json'); - - expect(JSON.parse(await readableToString(result.data!))).toEqual( - { apiVersion, name: 'Error', message: 'test error', authenticating: false, controls, prefilled: { key: 'val' }}, + it('converts input bodies to JSON.', async(): Promise => { + operation.body.metadata.contentType = APPLICATION_X_WWW_FORM_URLENCODED; + const result = await idpHandler.handle({ operation, request, response }); + expect(result.statusCode).toBe(200); + expect(result.data).toBe(representation.data); + expect(result.metadata).toBe(representation.metadata); + expect(handler.handleSafe).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { body, ...partialOperation } = operation; + expect(handler.handleSafe).toHaveBeenLastCalledWith( + { operation: expect.objectContaining(partialOperation), oidcInteraction }, ); - expect(result.statusCode).toBe(400); - expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe('/response'); - }); - - it('indicates to the templates if the request is part of an auth flow.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, '/idp/routeResponse'); - operation.method = 'POST'; - const oidcInteraction = { session: { accountId: 'account' }, prompt: {}} as any; - provider.interactionDetails.mockResolvedValueOnce(oidcInteraction); - routes.response.handleOperation - .mockResolvedValueOnce({ type: 'response', templateFiles: { 'text/html': '/response' }}); - - const result = (await handler.handle({ request, response, operation }))!; - expect(result).toBeDefined(); - expect(JSON.parse(await readableToString(result.data!))).toEqual({ apiVersion, authenticating: true, controls }); - }); - - it('errors for InteractionCompleteResults if no oidcInteraction is defined.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, '/idp/routeComplete'); - operation.method = 'POST'; - - const error = expect.objectContaining({ - statusCode: 400, - message: 'This action can only be performed as part of an OIDC authentication flow.', - errorCode: 'E0002', - }); - await expect(handler.handle({ request, response, operation })).rejects.toThrow(error); - expect(routes.complete.handleOperation).toHaveBeenCalledTimes(1); - expect(routes.complete.handleOperation).toHaveBeenLastCalledWith(operation, undefined); - expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(0); - }); - - it('calls the interactionCompleter for InteractionCompleteResults and redirects.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, '/idp/routeComplete'); - operation.method = 'POST'; - operation.body = new BasicRepresentation('value', 'text/plain'); - const oidcInteraction = { session: { accountId: 'account' }, prompt: {}} as any; - provider.interactionDetails.mockResolvedValueOnce(oidcInteraction); - const result = (await handler.handle({ request, response, operation }))!; - expect(result).toBeDefined(); - expect(routes.complete.handleOperation).toHaveBeenCalledTimes(1); - expect(routes.complete.handleOperation).toHaveBeenLastCalledWith(operation, oidcInteraction); - expect(operation.body?.metadata.contentType).toBe('application/json'); - - expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); - expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ request, webId: 'webId' }); - const location = await interactionCompleter.handleSafe.mock.results[0].value; - expect(result.statusCode).toBe(302); - expect(result.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(location); + expect(handler.handleSafe.mock.calls[0][0].operation.body.metadata.contentType).toBe(APPLICATION_JSON); }); }); diff --git a/test/unit/identity/OidcHttpHandler.test.ts b/test/unit/identity/OidcHttpHandler.test.ts new file mode 100644 index 000000000..7ceba4473 --- /dev/null +++ b/test/unit/identity/OidcHttpHandler.test.ts @@ -0,0 +1,32 @@ +import type { Provider } from 'oidc-provider'; +import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory'; +import { OidcHttpHandler } from '../../../src/identity/OidcHttpHandler'; +import type { HttpRequest } from '../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../src/server/HttpResponse'; + +describe('An OidcHttpHandler', (): void => { + const request: HttpRequest = {} as any; + const response: HttpResponse = {} as any; + let provider: jest.Mocked; + let providerFactory: jest.Mocked; + let handler: OidcHttpHandler; + + beforeEach(async(): Promise => { + provider = { + callback: jest.fn().mockReturnValue(jest.fn()), + } as any; + + providerFactory = { + getProvider: jest.fn().mockResolvedValue(provider), + }; + + handler = new OidcHttpHandler(providerFactory); + }); + + it('sends all requests to the OIDC library.', async(): Promise => { + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(provider.callback).toHaveBeenCalledTimes(1); + expect(provider.callback.mock.results[0].value).toHaveBeenCalledTimes(1); + expect(provider.callback.mock.results[0].value).toHaveBeenLastCalledWith(request, response); + }); +}); diff --git a/test/unit/identity/configuration/IdentityProviderFactory.test.ts b/test/unit/identity/configuration/IdentityProviderFactory.test.ts index 835b85de4..10b4aa10f 100644 --- a/test/unit/identity/configuration/IdentityProviderFactory.test.ts +++ b/test/unit/identity/configuration/IdentityProviderFactory.test.ts @@ -1,10 +1,13 @@ -import type { Configuration } from 'oidc-provider'; +import type { Configuration, KoaContextWithOIDC } from 'oidc-provider'; import type { ErrorHandler } from '../../../../src/http/output/error/ErrorHandler'; import type { ResponseWriter } from '../../../../src/http/output/ResponseWriter'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import { IdentityProviderFactory } from '../../../../src/identity/configuration/IdentityProviderFactory'; +import type { Interaction, InteractionHandler } from '../../../../src/identity/interaction/InteractionHandler'; import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory'; import type { HttpResponse } from '../../../../src/server/HttpResponse'; import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; +import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; /* eslint-disable @typescript-eslint/naming-convention */ jest.mock('oidc-provider', (): any => ({ @@ -12,25 +15,29 @@ jest.mock('oidc-provider', (): any => ({ })); const routes = { - authorization: '/foo/idp/auth', - check_session: '/foo/idp/session/check', - code_verification: '/foo/idp/device', - device_authorization: '/foo/idp/device/auth', - end_session: '/foo/idp/session/end', - introspection: '/foo/idp/token/introspection', - jwks: '/foo/idp/jwks', - pushed_authorization_request: '/foo/idp/request', - registration: '/foo/idp/reg', - revocation: '/foo/idp/token/revocation', - token: '/foo/idp/token', - userinfo: '/foo/idp/me', + authorization: '/foo/oidc/auth', + backchannel_authentication: '/foo/oidc/backchannel', + code_verification: '/foo/oidc/device', + device_authorization: '/foo/oidc/device/auth', + end_session: '/foo/oidc/session/end', + introspection: '/foo/oidc/token/introspection', + jwks: '/foo/oidc/jwks', + pushed_authorization_request: '/foo/oidc/request', + registration: '/foo/oidc/reg', + revocation: '/foo/oidc/token/revocation', + token: '/foo/oidc/token', + userinfo: '/foo/oidc/me', }; describe('An IdentityProviderFactory', (): void => { let baseConfig: Configuration; - const baseUrl = 'http://test.com/foo/'; - const idpPath = '/idp'; - const webId = 'http://alice.test.com/card#me'; + const baseUrl = 'http://example.com/foo/'; + const oidcPath = '/oidc'; + const webId = 'http://alice.example.com/card#me'; + const redirectUrl = 'http://example.com/login/'; + const oidcInteraction: Interaction = {} as any; + let ctx: KoaContextWithOIDC; + let interactionHandler: jest.Mocked; let adapterFactory: jest.Mocked; let storage: jest.Mocked>; let errorHandler: jest.Mocked; @@ -40,6 +47,17 @@ describe('An IdentityProviderFactory', (): void => { beforeEach(async(): Promise => { baseConfig = { claims: { webid: [ 'webid', 'client_webid' ]}}; + ctx = { + method: 'GET', + request: { + href: 'http://example.com/idp/', + }, + } as any; + + interactionHandler = { + handleSafe: jest.fn().mockRejectedValue(new FoundHttpError(redirectUrl)), + } as any; + adapterFactory = { createStorageAdapter: jest.fn().mockReturnValue('adapter!'), }; @@ -59,24 +77,14 @@ describe('An IdentityProviderFactory', (): void => { factory = new IdentityProviderFactory(baseConfig, { adapterFactory, baseUrl, - idpPath, + oidcPath, + interactionHandler, storage, errorHandler, responseWriter, }); }); - it('errors if the idpPath parameter does not start with a slash.', async(): Promise => { - expect((): any => new IdentityProviderFactory(baseConfig, { - adapterFactory, - baseUrl, - idpPath: 'idp', - storage, - errorHandler, - responseWriter, - })).toThrow('idpPath needs to start with a /'); - }); - it('creates a correct configuration.', async(): Promise => { // This is the output of our mock function const provider = await factory.getProvider() as any; @@ -92,27 +100,36 @@ describe('An IdentityProviderFactory', (): void => { expect(adapterFactory.createStorageAdapter).toHaveBeenLastCalledWith('test!'); expect(config.cookies?.keys).toEqual([ expect.any(String) ]); - expect(config.jwks).toEqual({ keys: [ expect.objectContaining({ kty: 'RSA' }) ]}); + expect(config.jwks).toEqual({ keys: [ expect.objectContaining({ alg: 'ES256' }) ]}); expect(config.routes).toEqual(routes); + expect(config.pkce?.methods).toEqual([ 'S256' ]); + expect((config.pkce!.required as any)()).toBe(true); + expect(config.clientDefaults?.id_token_signed_response_alg).toBe('ES256'); - expect((config.interactions?.url as any)()).toBe('/idp/'); - expect((config.audiences as any)(null, null, {}, 'access_token')).toBe('solid'); - expect((config.audiences as any)(null, null, { clientId: 'clientId' }, 'client_credentials')).toBe('clientId'); + await expect((config.interactions?.url as any)(ctx, oidcInteraction)).resolves.toBe(redirectUrl); - const findResult = await config.findAccount?.({ oidc: { client: { clientId: 'clientId' }}} as any, webId); + let findResult = await config.findAccount?.({ oidc: { client: { clientId: 'clientId' }}} as any, webId); expect(findResult?.accountId).toBe(webId); + await expect((findResult?.claims as any)()).resolves.toEqual({ sub: webId, webid: webId, azp: 'clientId' }); + findResult = await config.findAccount?.({ oidc: {}} as any, webId); await expect((findResult?.claims as any)()).resolves.toEqual({ sub: webId, webid: webId }); - expect((config.extraAccessTokenClaims as any)({}, {})).toEqual({}); - expect((config.extraAccessTokenClaims as any)({}, { kind: 'AccessToken', accountId: webId, clientId: 'clientId' })) - .toEqual({ - webid: webId, - client_id: 'clientId', - }); + expect((config.extraTokenClaims as any)({}, {})).toEqual({}); + expect((config.extraTokenClaims as any)({}, { kind: 'AccessToken', accountId: webId, clientId: 'clientId' })) + .toEqual({ webid: webId }); + + expect(config.features?.resourceIndicators?.enabled).toBe(true); + expect((config.features?.resourceIndicators?.defaultResource as any)()).toBe('http://example.com/'); + expect((config.features?.resourceIndicators?.getResourceServerInfo as any)()).toEqual({ + scope: '', + audience: 'solid', + accessTokenFormat: 'jwt', + jwt: { sign: { alg: 'ES256' }}, + }); // Test the renderError function const response = { } as HttpResponse; - await expect((config.renderError as any)({ res: response }, null, 'error!')).resolves.toBeUndefined(); + await expect((config.renderError as any)({ res: response }, {}, 'error!')).resolves.toBeUndefined(); expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1); expect(errorHandler.handleSafe) .toHaveBeenLastCalledWith({ error: 'error!', preferences: { type: { 'text/plain': 1 }}}); @@ -120,6 +137,17 @@ describe('An IdentityProviderFactory', (): void => { expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }}); }); + it('errors if there is no valid interaction redirect.', async(): Promise => { + interactionHandler.handleSafe.mockRejectedValueOnce(new Error('bad data')); + const provider = await factory.getProvider() as any; + const { config } = provider as { config: Configuration }; + await expect((config.interactions?.url as any)(ctx, oidcInteraction)).rejects.toThrow('bad data'); + + interactionHandler.handleSafe.mockResolvedValueOnce(new BasicRepresentation()); + await expect((config.interactions?.url as any)(ctx, oidcInteraction)) + .rejects.toThrow('Could not correctly redirect for the given interaction.'); + }); + it('copies a field from the input config if values need to be added to it.', async(): Promise => { baseConfig.cookies = { long: { signed: true }, @@ -127,7 +155,8 @@ describe('An IdentityProviderFactory', (): void => { factory = new IdentityProviderFactory(baseConfig, { adapterFactory, baseUrl, - idpPath, + oidcPath, + interactionHandler, storage, errorHandler, responseWriter, @@ -148,7 +177,8 @@ describe('An IdentityProviderFactory', (): void => { const factory2 = new IdentityProviderFactory(baseConfig, { adapterFactory, baseUrl, - idpPath, + oidcPath, + interactionHandler, storage, errorHandler, responseWriter, @@ -161,4 +191,22 @@ describe('An IdentityProviderFactory', (): void => { expect(storage.set).toHaveBeenCalledWith('jwks', result1.config.jwks); expect(storage.set).toHaveBeenCalledWith('cookie-secret', result1.config.cookies?.keys); }); + + it('updates errors if there is more information.', async(): Promise => { + const provider = await factory.getProvider() as any; + const { config } = provider as { config: Configuration }; + const response = { } as HttpResponse; + + const error = new Error('bad data'); + const out = { error_description: 'more info' }; + + await expect((config.renderError as any)({ res: response }, out, error)).resolves.toBeUndefined(); + expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(errorHandler.handleSafe) + .toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/plain': 1 }}}); + expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); + expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }}); + expect(error.message).toBe('bad data - more info'); + expect(error.stack).toContain('Error: bad data - more info'); + }); }); diff --git a/test/unit/identity/interaction/BaseInteractionHandler.test.ts b/test/unit/identity/interaction/BaseInteractionHandler.test.ts new file mode 100644 index 000000000..4736ee2d0 --- /dev/null +++ b/test/unit/identity/interaction/BaseInteractionHandler.test.ts @@ -0,0 +1,70 @@ +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { Representation } from '../../../../src/http/representation/Representation'; +import { BaseInteractionHandler } from '../../../../src/identity/interaction/BaseInteractionHandler'; +import type { InteractionHandlerInput } from '../../../../src/identity/interaction/InteractionHandler'; +import { APPLICATION_JSON } from '../../../../src/util/ContentTypes'; +import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError'; +import { readJsonStream } from '../../../../src/util/StreamUtil'; + +class DummyBaseInteractionHandler extends BaseInteractionHandler { + public constructor() { + super({ view: 'view' }); + } + + public async handlePost(input: InteractionHandlerInput): Promise { + return new BasicRepresentation(JSON.stringify({ data: 'data' }), input.operation.target, APPLICATION_JSON); + } +} + +describe('A BaseInteractionHandler', (): void => { + const handler = new DummyBaseInteractionHandler(); + + it('can only handle GET and POST requests.', async(): Promise => { + const operation: Operation = { + method: 'DELETE', + target: { path: 'http://example.com/foo' }, + body: new BasicRepresentation(), + preferences: {}, + }; + await expect(handler.canHandle({ operation })).rejects.toThrow(MethodNotAllowedHttpError); + + operation.method = 'GET'; + await expect(handler.canHandle({ operation })).resolves.toBeUndefined(); + + operation.method = 'POST'; + await expect(handler.canHandle({ operation })).resolves.toBeUndefined(); + }); + + it('returns the view on GET requests.', async(): Promise => { + const operation: Operation = { + method: 'GET', + target: { path: 'http://example.com/foo' }, + body: new BasicRepresentation(), + preferences: {}, + }; + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ view: 'view' }); + }); + + it('calls the handlePost function on POST requests.', async(): Promise => { + const operation: Operation = { + method: 'POST', + target: { path: 'http://example.com/foo' }, + body: new BasicRepresentation(), + preferences: {}, + }; + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ data: 'data' }); + }); + + it('rejects other methods.', async(): Promise => { + const operation: Operation = { + method: 'DELETE', + target: { path: 'http://example.com/foo' }, + body: new BasicRepresentation(), + preferences: {}, + }; + await expect(handler.handle({ operation })).rejects.toThrow(MethodNotAllowedHttpError); + }); +}); diff --git a/test/unit/identity/interaction/ConsentHandler.test.ts b/test/unit/identity/interaction/ConsentHandler.test.ts new file mode 100644 index 000000000..fbb8159e5 --- /dev/null +++ b/test/unit/identity/interaction/ConsentHandler.test.ts @@ -0,0 +1,173 @@ +import type { Provider } from 'oidc-provider'; +import type { ProviderFactory } from '../../../../src/identity/configuration/ProviderFactory'; +import { ConsentHandler } from '../../../../src/identity/interaction/ConsentHandler'; +import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler'; +import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import { readJsonStream } from '../../../../src/util/StreamUtil'; +import { createPostJsonOperation } from './email-password/handler/Util'; + +const newGrantId = 'newGrantId'; +class DummyGrant { + public accountId: string; + public clientId: string; + + public readonly scopes: string[] = []; + public claims: string[] = []; + public readonly rejectedScopes: string[] = []; + public readonly resourceScopes: Record = {}; + + public constructor(props: { accountId: string; clientId: string }) { + this.accountId = props.accountId; + this.clientId = props.clientId; + } + + public rejectOIDCScope(scope: string): void { + this.rejectedScopes.push(scope); + } + + public addOIDCScope(scope: string): void { + this.scopes.push(scope); + } + + public addOIDCClaims(claims: string[]): void { + this.claims = claims; + } + + public addResourceScope(resource: string, scope: string): void { + this.resourceScopes[resource] = scope; + } + + public async save(): Promise { + return newGrantId; + } +} + +describe('A ConsentHandler', (): void => { + const accountId = 'http://example.com/id#me'; + const clientId = 'clientId'; + const clientMetadata = { + // eslint-disable-next-line @typescript-eslint/naming-convention + client_id: 'clientId', + }; + let grantFn: jest.Mock & { find: jest.Mock }; + let knownGrant: DummyGrant; + let oidcInteraction: Interaction; + let provider: jest.Mocked; + let providerFactory: jest.Mocked; + let handler: ConsentHandler; + + beforeEach(async(): Promise => { + oidcInteraction = { + session: { accountId }, + // eslint-disable-next-line @typescript-eslint/naming-convention + params: { client_id: clientId }, + prompt: { details: {}}, + save: jest.fn(), + } as any; + + knownGrant = new DummyGrant({ accountId, clientId }); + + grantFn = jest.fn((props): DummyGrant => new DummyGrant(props)) as any; + grantFn.find = jest.fn((grantId: string): any => grantId ? knownGrant : undefined); + provider = { + /* eslint-disable @typescript-eslint/naming-convention */ + Grant: grantFn, + Client: { + find: (id: string): any => (id ? { metadata: jest.fn().mockReturnValue(clientMetadata) } : undefined), + }, + /* eslint-enable @typescript-eslint/naming-convention */ + } as any; + + providerFactory = { + getProvider: jest.fn().mockResolvedValue(provider), + }; + + handler = new ConsentHandler(providerFactory); + }); + + it('errors if no oidcInteraction is defined on POST requests.', async(): Promise => { + const error = expect.objectContaining({ + statusCode: 400, + message: 'This action can only be performed as part of an OIDC authentication flow.', + errorCode: 'E0002', + }); + await expect(handler.canHandle({ operation: createPostJsonOperation({}) })).rejects.toThrow(error); + + await expect(handler.canHandle({ operation: createPostJsonOperation({}), oidcInteraction })) + .resolves.toBeUndefined(); + }); + + it('returns the client metadata on a GET request.', async(): Promise => { + const operation = { method: 'GET', target: { path: 'http://example.com/foo' }} as any; + const representation = await handler.handle({ operation, oidcInteraction }); + await expect(readJsonStream(representation.data)).resolves.toEqual({ + client: { + ...clientMetadata, + '@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld', + }, + }); + }); + + it('returns an empty object if no client was found.', async(): Promise => { + delete oidcInteraction.params.client_id; + const operation = { method: 'GET', target: { path: 'http://example.com/foo' }} as any; + const representation = await handler.handle({ operation, oidcInteraction }); + await expect(readJsonStream(representation.data)).resolves.toEqual({ + client: { + '@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld', + }, + }); + }); + + it('requires an oidcInteraction with a defined session.', async(): Promise => { + oidcInteraction.session = undefined; + await expect(handler.handle({ operation: createPostJsonOperation({}), oidcInteraction })) + .rejects.toThrow(NotImplementedHttpError); + }); + + it('throws a redirect error.', async(): Promise => { + const operation = createPostJsonOperation({}); + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); + }); + + it('stores the requested scopes and claims in the grant.', async(): Promise => { + oidcInteraction.prompt.details = { + missingOIDCScope: [ 'scope1', 'scope2' ], + missingOIDCClaims: [ 'claim1', 'claim2' ], + missingResourceScopes: { resource: [ 'scope1', 'scope2' ]}, + }; + + const operation = createPostJsonOperation({ remember: true }); + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); + expect(grantFn.mock.results).toHaveLength(1); + expect(grantFn.mock.results[0].value.scopes).toEqual([ 'scope1 scope2' ]); + expect(grantFn.mock.results[0].value.claims).toEqual([ 'claim1', 'claim2' ]); + expect(grantFn.mock.results[0].value.resourceScopes).toEqual({ resource: 'scope1 scope2' }); + expect(grantFn.mock.results[0].value.rejectedScopes).toEqual([]); + }); + + it('creates a new Grant when needed.', async(): Promise => { + const operation = createPostJsonOperation({}); + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); + expect(grantFn).toHaveBeenCalledTimes(1); + expect(grantFn).toHaveBeenLastCalledWith({ accountId, clientId }); + expect(grantFn.find).toHaveBeenCalledTimes(0); + }); + + it('reuses existing Grant objects.', async(): Promise => { + const operation = createPostJsonOperation({}); + oidcInteraction.grantId = '123456'; + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); + expect(grantFn).toHaveBeenCalledTimes(0); + expect(grantFn.find).toHaveBeenCalledTimes(1); + expect(grantFn.find).toHaveBeenLastCalledWith('123456'); + }); + + it('rejectes offline_access as scope if a user does not want to be remembered.', async(): Promise => { + const operation = createPostJsonOperation({}); + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); + expect(grantFn.mock.results).toHaveLength(1); + expect(grantFn.mock.results[0].value.rejectedScopes).toEqual([ 'offline_access' ]); + }); +}); diff --git a/test/unit/identity/interaction/FixedInteractionHandler.test.ts b/test/unit/identity/interaction/FixedInteractionHandler.test.ts new file mode 100644 index 000000000..85c9c0e2b --- /dev/null +++ b/test/unit/identity/interaction/FixedInteractionHandler.test.ts @@ -0,0 +1,15 @@ +import type { Operation } from '../../../../src/http/Operation'; +import { FixedInteractionHandler } from '../../../../src/identity/interaction/FixedInteractionHandler'; +import { readJsonStream } from '../../../../src/util/StreamUtil'; + +describe('A FixedInteractionHandler', (): void => { + const json = { data: 'data' }; + const operation: Operation = { target: { path: 'http://example.com/test/' }} as any; + const handler = new FixedInteractionHandler(json); + + it('returns the given JSON as response.', async(): Promise => { + const response = await handler.handle({ operation }); + await expect(readJsonStream(response.data)).resolves.toEqual(json); + expect(response.metadata.contentType).toBe('application/json'); + }); +}); diff --git a/test/unit/identity/interaction/HtmlViewHandler.test.ts b/test/unit/identity/interaction/HtmlViewHandler.test.ts new file mode 100644 index 000000000..504bdaca8 --- /dev/null +++ b/test/unit/identity/interaction/HtmlViewHandler.test.ts @@ -0,0 +1,86 @@ +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import { HtmlViewHandler } from '../../../../src/identity/interaction/HtmlViewHandler'; +import type { InteractionRoute } from '../../../../src/identity/interaction/routing/InteractionRoute'; +import { TEXT_HTML } from '../../../../src/util/ContentTypes'; +import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError'; +import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import { readableToString } from '../../../../src/util/StreamUtil'; +import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; + +describe('An HtmlViewHandler', (): void => { + const idpIndex = 'http://example.com/idp/'; + let index: InteractionRoute; + let operation: Operation; + let templates: Record>; + let templateEngine: TemplateEngine; + let handler: HtmlViewHandler; + + beforeEach(async(): Promise => { + index = { + getPath: jest.fn().mockReturnValue(idpIndex), + } as any; + + operation = { + method: 'GET', + target: { path: 'http://example.com/idp/login/' }, + preferences: { type: { 'text/html': 1 }}, + body: new BasicRepresentation(), + }; + + templates = { + '/templates/login.html.ejs': { getPath: jest.fn().mockReturnValue('http://example.com/idp/login/') } as any, + '/templates/register.html.ejs': { getPath: jest.fn().mockReturnValue('http://example.com/idp/register/') } as any, + }; + + templateEngine = { + render: jest.fn().mockReturnValue(Promise.resolve('')), + }; + + handler = new HtmlViewHandler(index, templateEngine, templates); + }); + + it('rejects non-GET requests.', async(): Promise => { + operation.method = 'POST'; + await expect(handler.canHandle({ operation })).rejects.toThrow(MethodNotAllowedHttpError); + }); + + it('rejects unsupported paths.', async(): Promise => { + operation.target.path = 'http://example.com/idp/otherPath/'; + await expect(handler.canHandle({ operation })).rejects.toThrow(NotFoundHttpError); + }); + + it('rejects requests that do not prefer HTML to JSON.', async(): Promise => { + operation.preferences = { type: { '*/*': 1 }}; + await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError); + + operation.preferences = { type: { 'application/json': 1, 'text/html': 1 }}; + await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError); + + operation.preferences = { type: { 'application/json': 1, 'text/html': 0.8 }}; + await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError); + }); + + it('can handle matching requests.', async(): Promise => { + await expect(handler.canHandle({ operation })).resolves.toBeUndefined(); + }); + + it('returns the resolved template.', async(): Promise => { + const result = await handler.handle({ operation }); + expect(result.metadata.contentType).toBe(TEXT_HTML); + await expect(readableToString(result.data)).resolves.toBe(''); + expect(templateEngine.render).toHaveBeenCalledTimes(1); + expect(templateEngine.render) + .toHaveBeenLastCalledWith({ idpIndex, authenticating: false }, { templateFile: '/templates/login.html.ejs' }); + }); + + it('sets authenticating to true if there is an active interaction.', async(): Promise => { + const result = await handler.handle({ operation, oidcInteraction: {} as any }); + expect(result.metadata.contentType).toBe(TEXT_HTML); + await expect(readableToString(result.data)).resolves.toBe(''); + expect(templateEngine.render).toHaveBeenCalledTimes(1); + expect(templateEngine.render) + .toHaveBeenLastCalledWith({ idpIndex, authenticating: true }, { templateFile: '/templates/login.html.ejs' }); + }); +}); diff --git a/test/unit/identity/interaction/InteractionHandler.test.ts b/test/unit/identity/interaction/InteractionHandler.test.ts new file mode 100644 index 000000000..167fef7aa --- /dev/null +++ b/test/unit/identity/interaction/InteractionHandler.test.ts @@ -0,0 +1,28 @@ +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { Representation } from '../../../../src/http/representation/Representation'; +import { + InteractionHandler, +} from '../../../../src/identity/interaction/InteractionHandler'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; + +class SimpleInteractionHandler extends InteractionHandler { + public async handle(): Promise { + return new BasicRepresentation(); + } +} + +describe('An InteractionHandler', (): void => { + const handler = new SimpleInteractionHandler(); + + it('only supports JSON data or empty bodies.', async(): Promise => { + let representation = new BasicRepresentation('{}', 'application/json'); + await expect(handler.canHandle({ operation: { body: representation }} as any)).resolves.toBeUndefined(); + + representation = new BasicRepresentation('', 'application/x-www-form-urlencoded'); + await expect(handler.canHandle({ operation: { body: representation }} as any)) + .rejects.toThrow(NotImplementedHttpError); + + representation = new BasicRepresentation(); + await expect(handler.canHandle({ operation: { body: representation }} as any)).resolves.toBeUndefined(); + }); +}); diff --git a/test/unit/identity/interaction/LocationInteractionHandler.test.ts b/test/unit/identity/interaction/LocationInteractionHandler.test.ts new file mode 100644 index 000000000..60125e90b --- /dev/null +++ b/test/unit/identity/interaction/LocationInteractionHandler.test.ts @@ -0,0 +1,62 @@ +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { + InteractionHandler, + InteractionHandlerInput, +} from '../../../../src/identity/interaction/InteractionHandler'; +import { LocationInteractionHandler } from '../../../../src/identity/interaction/LocationInteractionHandler'; +import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; +import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; +import { readJsonStream } from '../../../../src/util/StreamUtil'; + +describe('A LocationInteractionHandler', (): void => { + const representation = new BasicRepresentation(); + const input: InteractionHandlerInput = { + operation: { + target: { path: 'http://example.com/target' }, + preferences: {}, + method: 'GET', + body: new BasicRepresentation(), + }, + }; + let source: jest.Mocked; + let handler: LocationInteractionHandler; + + beforeEach(async(): Promise => { + source = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue(representation), + } as any; + + handler = new LocationInteractionHandler(source); + }); + + it('calls the source canHandle function.', async(): Promise => { + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + expect(source.canHandle).toHaveBeenCalledTimes(1); + expect(source.canHandle).toHaveBeenLastCalledWith(input); + + source.canHandle.mockRejectedValueOnce(new Error('bad input')); + await expect(handler.canHandle(input)).rejects.toThrow('bad input'); + }); + + it('returns the source output.', async(): Promise => { + await expect(handler.handle(input)).resolves.toBe(representation); + expect(source.handle).toHaveBeenCalledTimes(1); + expect(source.handle).toHaveBeenLastCalledWith(input); + }); + + it('returns a location object in case of redirect errors.', async(): Promise => { + const location = 'http://example.com/foo'; + source.handle.mockRejectedValueOnce(new FoundHttpError(location)); + + const response = await handler.handle(input); + expect(response.metadata.identifier.value).toEqual(input.operation.target.path); + await expect(readJsonStream(response.data)).resolves.toEqual({ location }); + }); + + it('rethrows non-redirect errors.', async(): Promise => { + source.handle.mockRejectedValueOnce(new NotFoundHttpError()); + + await expect(handler.handle(input)).rejects.toThrow(NotFoundHttpError); + }); +}); diff --git a/test/unit/identity/interaction/PromptHandler.test.ts b/test/unit/identity/interaction/PromptHandler.test.ts new file mode 100644 index 000000000..05a6834ef --- /dev/null +++ b/test/unit/identity/interaction/PromptHandler.test.ts @@ -0,0 +1,37 @@ +import type { Operation } from '../../../../src/http/Operation'; +import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler'; +import { PromptHandler } from '../../../../src/identity/interaction/PromptHandler'; +import type { InteractionRoute } from '../../../../src/identity/interaction/routing/InteractionRoute'; +import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; + +describe('A PromptHandler', (): void => { + const operation: Operation = { target: { path: 'http://example.com/test/' }} as any; + let oidcInteraction: Interaction; + let promptRoutes: Record>; + let handler: PromptHandler; + + beforeEach(async(): Promise => { + oidcInteraction = { prompt: { name: 'login' }} as any; + promptRoutes = { + login: { getPath: jest.fn().mockReturnValue('http://example.com/idp/login/') } as any, + }; + handler = new PromptHandler(promptRoutes); + }); + + it('errors if there is no interaction.', async(): Promise => { + await expect(handler.handle({ operation })).rejects.toThrow(BadRequestHttpError); + }); + + it('errors if the prompt is unsupported.', async(): Promise => { + oidcInteraction.prompt.name = 'unsupported'; + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(BadRequestHttpError); + }); + + it('throws a redirect error with the correct location.', async(): Promise => { + const error = expect.objectContaining({ + statusCode: 302, + location: 'http://example.com/idp/login/', + }); + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(error); + }); +}); diff --git a/test/unit/identity/interaction/SessionHttpHandler.test.ts b/test/unit/identity/interaction/SessionHttpHandler.test.ts deleted file mode 100644 index a7bbc34db..000000000 --- a/test/unit/identity/interaction/SessionHttpHandler.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Interaction } from '../../../../src/identity/interaction/email-password/handler/InteractionHandler'; -import { SessionHttpHandler } from '../../../../src/identity/interaction/SessionHttpHandler'; -import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; -import { createPostJsonOperation } from './email-password/handler/Util'; - -describe('A SessionHttpHandler', (): void => { - const webId = 'http://test.com/id#me'; - let oidcInteraction: Interaction; - let handler: SessionHttpHandler; - - beforeEach(async(): Promise => { - oidcInteraction = { session: { accountId: webId }} as any; - - handler = new SessionHttpHandler(); - }); - - it('requires a defined oidcInteraction with a session.', async(): Promise => { - oidcInteraction!.session = undefined; - await expect(handler.handle({ operation: {} as any, oidcInteraction })).rejects.toThrow(NotImplementedHttpError); - - await expect(handler.handle({ operation: {} as any })).rejects.toThrow(NotImplementedHttpError); - }); - - it('returns an InteractionCompleteResult when done.', async(): Promise => { - const operation = createPostJsonOperation({ remember: true }); - await expect(handler.handle({ operation, oidcInteraction })).resolves.toEqual({ - details: { webId, shouldRemember: true }, - type: 'complete', - }); - }); -}); diff --git a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts index 4a7470571..75a5a7640 100644 --- a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts @@ -3,7 +3,9 @@ import { ForgotPasswordHandler, } from '../../../../../../src/identity/interaction/email-password/handler/ForgotPasswordHandler'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; -import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender'; +import type { EmailSender } from '../../../../../../src/identity/interaction/email-password/util/EmailSender'; +import type { InteractionRoute } from '../../../../../../src/identity/interaction/routing/InteractionRoute'; +import { readJsonStream } from '../../../../../../src/util/StreamUtil'; import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine'; import { createPostJsonOperation } from './Util'; @@ -11,11 +13,10 @@ describe('A ForgotPasswordHandler', (): void => { let operation: Operation; const email = 'test@test.email'; const recordId = '123456'; - const html = `Reset Password`; + const html = `Reset Password`; let accountStore: AccountStore; - const baseUrl = 'http://test.com/base/'; - const idpPath = '/idp'; let templateEngine: TemplateEngine<{ resetLink: string }>; + let resetRoute: jest.Mocked; let emailSender: EmailSender; let handler: ForgotPasswordHandler; @@ -30,16 +31,19 @@ describe('A ForgotPasswordHandler', (): void => { render: jest.fn().mockResolvedValue(html), } as any; + resetRoute = { + getPath: jest.fn().mockReturnValue('http://test.com/base/idp/resetpassword/'), + } as any; + emailSender = { handleSafe: jest.fn(), } as any; handler = new ForgotPasswordHandler({ accountStore, - baseUrl, - idpPath, templateEngine, emailSender, + resetRoute, }); }); @@ -52,19 +56,20 @@ describe('A ForgotPasswordHandler', (): void => { it('does not send a mail if a ForgotPassword record could not be generated.', async(): Promise => { (accountStore.generateForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('error'); - await expect(handler.handle({ operation })).resolves - .toEqual({ type: 'response', details: { email }}); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ email }); expect(emailSender.handleSafe).toHaveBeenCalledTimes(0); }); it('sends a mail if a ForgotPassword record could be generated.', async(): Promise => { - await expect(handler.handle({ operation })).resolves - .toEqual({ type: 'response', details: { email }}); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ email }); + expect(result.metadata.contentType).toBe('application/json'); expect(emailSender.handleSafe).toHaveBeenCalledTimes(1); expect(emailSender.handleSafe).toHaveBeenLastCalledWith({ recipient: email, subject: 'Reset your password', - text: `To reset your password, go to this link: http://test.com/base/idp/resetpassword/${recordId}`, + text: `To reset your password, go to this link: http://test.com/base/idp/resetpassword/?rid=${recordId}`, html, }); }); diff --git a/test/unit/identity/interaction/email-password/handler/InteractionHandler.test.ts b/test/unit/identity/interaction/email-password/handler/InteractionHandler.test.ts deleted file mode 100644 index 4cb14f924..000000000 --- a/test/unit/identity/interaction/email-password/handler/InteractionHandler.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation'; -import type { - InteractionResponseResult, -} from '../../../../../../src/identity/interaction/email-password/handler/InteractionHandler'; -import { - InteractionHandler, -} from '../../../../../../src/identity/interaction/email-password/handler/InteractionHandler'; -import { NotImplementedHttpError } from '../../../../../../src/util/errors/NotImplementedHttpError'; - -class SimpleInteractionHandler extends InteractionHandler { - public async handle(): Promise { - return { type: 'response' }; - } -} - -describe('An InteractionHandler', (): void => { - const handler = new SimpleInteractionHandler(); - - it('only supports JSON data.', async(): Promise => { - let representation = new BasicRepresentation('{}', 'application/json'); - await expect(handler.canHandle({ operation: { body: representation }} as any)).resolves.toBeUndefined(); - - representation = new BasicRepresentation('', 'application/x-www-form-urlencoded'); - await expect(handler.canHandle({ operation: { body: representation }} as any)) - .rejects.toThrow(NotImplementedHttpError); - - await expect(handler.canHandle({ operation: {}} as any)).rejects.toThrow(NotImplementedHttpError); - }); -}); diff --git a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts index 7b3642194..2ea04b774 100644 --- a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts @@ -1,19 +1,27 @@ -import type { - InteractionHandlerInput, -} from '../../../../../../src/identity/interaction/email-password/handler/InteractionHandler'; import { LoginHandler } from '../../../../../../src/identity/interaction/email-password/handler/LoginHandler'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; +import type { + Interaction, + InteractionHandlerInput, +} from '../../../../../../src/identity/interaction/InteractionHandler'; +import { FoundHttpError } from '../../../../../../src/util/errors/FoundHttpError'; import { createPostJsonOperation } from './Util'; describe('A LoginHandler', (): void => { const webId = 'http://alice.test.com/card#me'; const email = 'alice@test.email'; - let input: InteractionHandlerInput; + let oidcInteraction: jest.Mocked; + let input: Required; let accountStore: jest.Mocked; let handler: LoginHandler; beforeEach(async(): Promise => { - input = {} as any; + oidcInteraction = { + exp: 123456, + save: jest.fn(), + } as any; + + input = { oidcInteraction } as any; accountStore = { authenticate: jest.fn().mockResolvedValue(webId), @@ -22,6 +30,17 @@ describe('A LoginHandler', (): void => { handler = new LoginHandler(accountStore); }); + it('errors if no oidcInteraction is defined on POST requests.', async(): Promise => { + const error = expect.objectContaining({ + statusCode: 400, + message: 'This action can only be performed as part of an OIDC authentication flow.', + errorCode: 'E0002', + }); + await expect(handler.canHandle({ operation: createPostJsonOperation({}) })).rejects.toThrow(error); + + await expect(handler.canHandle({ operation: createPostJsonOperation({}), oidcInteraction })) + .resolves.toBeUndefined(); + }); it('errors on invalid emails.', async(): Promise => { input.operation = createPostJsonOperation({}); @@ -46,16 +65,17 @@ describe('A LoginHandler', (): void => { it('throws an error if the account does not have the correct settings.', async(): Promise => { input.operation = createPostJsonOperation({ email, password: 'password!' }); accountStore.getSettings.mockResolvedValueOnce({ useIdp: false }); - await expect(handler.handle(input)).rejects.toThrow('This server is not an identity provider for this account.'); + await expect(handler.handle(input)) + .rejects.toThrow('This server is not an identity provider for this account.'); }); - it('returns an InteractionCompleteResult when done.', async(): Promise => { + it('returns the generated redirect URL.', async(): Promise => { input.operation = createPostJsonOperation({ email, password: 'password!' }); - await expect(handler.handle(input)).resolves.toEqual({ - type: 'complete', - details: { webId, shouldRemember: false }, - }); + await expect(handler.handle(input)).rejects.toThrow(FoundHttpError); + expect(accountStore.authenticate).toHaveBeenCalledTimes(1); expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, 'password!'); + expect(oidcInteraction.save).toHaveBeenCalledTimes(1); + expect(oidcInteraction.result).toEqual({ login: { accountId: webId, remember: false }}); }); }); diff --git a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts index 1a08ea2cd..52ff1c0e1 100644 --- a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts @@ -5,6 +5,7 @@ import { import type { RegistrationManager, RegistrationParams, RegistrationResponse, } from '../../../../../../src/identity/interaction/email-password/util/RegistrationManager'; +import { readJsonStream } from '../../../../../../src/util/StreamUtil'; import { createPostJsonOperation } from './Util'; describe('A RegistrationHandler', (): void => { @@ -41,10 +42,9 @@ describe('A RegistrationHandler', (): void => { it('converts the stream to json and sends it to the registration manager.', async(): Promise => { const params = { email: 'alice@test.email', password: 'superSecret' }; operation = createPostJsonOperation(params); - await expect(handler.handle({ operation })).resolves.toEqual({ - type: 'response', - details, - }); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual(details); + expect(result.metadata.contentType).toBe('application/json'); expect(registrationManager.validateInput).toHaveBeenCalledTimes(1); expect(registrationManager.validateInput).toHaveBeenLastCalledWith(params, false); diff --git a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts index 37f4c2176..cf589350d 100644 --- a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts @@ -3,6 +3,7 @@ import { ResetPasswordHandler, } from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordHandler'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; +import { readJsonStream } from '../../../../../../src/util/StreamUtil'; import { createPostJsonOperation } from './Util'; describe('A ResetPasswordHandler', (): void => { @@ -27,26 +28,28 @@ describe('A ResetPasswordHandler', (): void => { const errorMessage = 'Invalid request. Open the link from your email again'; operation = createPostJsonOperation({}); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); - operation = createPostJsonOperation({}, ''); + operation = createPostJsonOperation({ recordId: 5 }); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); }); it('errors for invalid passwords.', async(): Promise => { const errorMessage = 'Your password and confirmation did not match.'; - operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'otherPassword!' }, url); + operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'otherPassword!', recordId }, url); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); }); it('errors for invalid emails.', async(): Promise => { const errorMessage = 'This reset password link is no longer valid.'; - operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!' }, url); + operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!', recordId }, url); (accountStore.getForgotPasswordRecord as jest.Mock).mockResolvedValueOnce(undefined); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); }); it('renders a message on success.', async(): Promise => { - operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!' }, url); - await expect(handler.handle({ operation })).resolves.toEqual({ type: 'response' }); + operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!', recordId }, url); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({}); + expect(result.metadata.contentType).toBe('application/json'); expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1); expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId); expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1); diff --git a/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts b/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts index e640b6b53..e4cb7c122 100644 --- a/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts +++ b/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts @@ -3,10 +3,12 @@ import type { EmailPasswordData, } from '../../../../../../src/identity/interaction/email-password/storage/BaseAccountStore'; import { BaseAccountStore } from '../../../../../../src/identity/interaction/email-password/storage/BaseAccountStore'; +import type { ExpiringStorage } from '../../../../../../src/storage/keyvalue/ExpiringStorage'; import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage'; describe('A BaseAccountStore', (): void => { let storage: KeyValueStorage; + let forgotPasswordStorage: ExpiringStorage; const saltRounds = 11; let store: BaseAccountStore; const email = 'test@test.com'; @@ -22,7 +24,13 @@ describe('A BaseAccountStore', (): void => { delete: jest.fn((id: string): any => map.delete(id)), } as any; - store = new BaseAccountStore(storage, saltRounds); + forgotPasswordStorage = { + get: jest.fn((id: string): any => map.get(id)), + set: jest.fn((id: string, value: any): any => map.set(id, value)), + delete: jest.fn((id: string): any => map.delete(id)), + } as any; + + store = new BaseAccountStore(storage, forgotPasswordStorage, saltRounds); }); it('can create accounts.', async(): Promise => { diff --git a/test/unit/identity/interaction/util/BaseEmailSender.test.ts b/test/unit/identity/interaction/email-password/util/BaseEmailSender.test.ts similarity index 82% rename from test/unit/identity/interaction/util/BaseEmailSender.test.ts rename to test/unit/identity/interaction/email-password/util/BaseEmailSender.test.ts index a04fa4424..4a2839f29 100644 --- a/test/unit/identity/interaction/util/BaseEmailSender.test.ts +++ b/test/unit/identity/interaction/email-password/util/BaseEmailSender.test.ts @@ -1,6 +1,6 @@ -import type { EmailSenderArgs } from '../../../../../src/identity/interaction/util/BaseEmailSender'; -import { BaseEmailSender } from '../../../../../src/identity/interaction/util/BaseEmailSender'; -import type { EmailArgs } from '../../../../../src/identity/interaction/util/EmailSender'; +import type { EmailSenderArgs } from '../../../../../../src/identity/interaction/email-password/util/BaseEmailSender'; +import { BaseEmailSender } from '../../../../../../src/identity/interaction/email-password/util/BaseEmailSender'; +import type { EmailArgs } from '../../../../../../src/identity/interaction/email-password/util/EmailSender'; jest.mock('nodemailer'); describe('A BaseEmailSender', (): void => { diff --git a/test/unit/identity/interaction/routing/AbsolutePathInteractionRoute.test.ts b/test/unit/identity/interaction/routing/AbsolutePathInteractionRoute.test.ts new file mode 100644 index 000000000..bed58c07e --- /dev/null +++ b/test/unit/identity/interaction/routing/AbsolutePathInteractionRoute.test.ts @@ -0,0 +1,12 @@ +import { + AbsolutePathInteractionRoute, +} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute'; + +describe('An AbsolutePathInteractionRoute', (): void => { + const path = 'http://example.com/idp/path/'; + const route = new AbsolutePathInteractionRoute(path); + + it('returns the given path.', async(): Promise => { + expect(route.getPath()).toBe('http://example.com/idp/path/'); + }); +}); diff --git a/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts b/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts deleted file mode 100644 index ef9d0fb1a..000000000 --- a/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { - InteractionHandler, -} from '../../../../../src/identity/interaction/email-password/handler/InteractionHandler'; -import { BasicInteractionRoute } from '../../../../../src/identity/interaction/routing/BasicInteractionRoute'; -import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; -import { InternalServerError } from '../../../../../src/util/errors/InternalServerError'; - -describe('A BasicInteractionRoute', (): void => { - const path = '^/route$'; - const viewTemplates = { 'text/html': '/viewTemplate' }; - let handler: jest.Mocked; - const prompt = 'login'; - const responseTemplates = { 'text/html': '/responseTemplate' }; - const controls = { login: '/route' }; - const response = { type: 'response' }; - let route: BasicInteractionRoute; - - beforeEach(async(): Promise => { - handler = { - handleSafe: jest.fn().mockResolvedValue(response), - } as any; - - route = new BasicInteractionRoute(path, viewTemplates, handler, prompt, responseTemplates, controls); - }); - - it('returns its controls.', async(): Promise => { - expect(route.getControls()).toEqual(controls); - }); - - it('supports a path if it matches the stored route.', async(): Promise => { - expect(route.supportsPath('/route')).toBe(true); - expect(route.supportsPath('/notRoute')).toBe(false); - }); - - it('supports prompts when targeting the base path.', async(): Promise => { - expect(route.supportsPath('/', prompt)).toBe(true); - expect(route.supportsPath('/notRoute', prompt)).toBe(false); - expect(route.supportsPath('/', 'notPrompt')).toBe(false); - }); - - it('returns a response result on a GET request.', async(): Promise => { - await expect(route.handleOperation({ method: 'GET' } as any)) - .resolves.toEqual({ type: 'response', templateFiles: viewTemplates }); - }); - - it('returns the result of the InteractionHandler on POST requests.', async(): Promise => { - await expect(route.handleOperation({ method: 'POST' } as any)) - .resolves.toEqual({ ...response, templateFiles: responseTemplates }); - expect(handler.handleSafe).toHaveBeenCalledTimes(1); - expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation: { method: 'POST' }}); - }); - - it('creates an error result in case the InteractionHandler errors.', async(): Promise => { - const error = new Error('bad data'); - handler.handleSafe.mockRejectedValueOnce(error); - await expect(route.handleOperation({ method: 'POST' } as any)) - .resolves.toEqual({ type: 'error', error, templateFiles: viewTemplates }); - }); - - it('creates an internal error in case of non-native errors.', async(): Promise => { - handler.handleSafe.mockRejectedValueOnce('notAnError'); - await expect(route.handleOperation({ method: 'POST' } as any)).resolves.toEqual({ - type: 'error', - error: new InternalServerError('Unknown error: notAnError'), - templateFiles: viewTemplates, - }); - }); - - it('errors for non-supported operations.', async(): Promise => { - const prom = route.handleOperation({ method: 'DELETE', target: { path: '/route' }} as any); - await expect(prom).rejects.toThrow(BadRequestHttpError); - await expect(prom).rejects.toThrow('Unsupported request: DELETE /route'); - expect(handler.handleSafe).toHaveBeenCalledTimes(0); - }); - - it('defaults to empty controls.', async(): Promise => { - route = new BasicInteractionRoute(path, viewTemplates, handler, prompt); - expect(route.getControls()).toEqual({}); - }); - - it('defaults to empty response templates.', async(): Promise => { - route = new BasicInteractionRoute(path, viewTemplates, handler, prompt); - await expect(route.handleOperation({ method: 'POST' } as any)).resolves.toEqual({ ...response, templateFiles: {}}); - }); -}); diff --git a/test/unit/identity/interaction/routing/InteractionRouteHandler.test.ts b/test/unit/identity/interaction/routing/InteractionRouteHandler.test.ts new file mode 100644 index 000000000..cecc36cd9 --- /dev/null +++ b/test/unit/identity/interaction/routing/InteractionRouteHandler.test.ts @@ -0,0 +1,53 @@ +import type { Operation } from '../../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation'; +import type { Representation } from '../../../../../src/http/representation/Representation'; +import type { InteractionHandler } from '../../../../../src/identity/interaction/InteractionHandler'; +import type { InteractionRoute } from '../../../../../src/identity/interaction/routing/InteractionRoute'; +import { InteractionRouteHandler } from '../../../../../src/identity/interaction/routing/InteractionRouteHandler'; +import { APPLICATION_JSON } from '../../../../../src/util/ContentTypes'; +import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; +import { createPostJsonOperation } from '../email-password/handler/Util'; + +describe('An InteractionRouteHandler', (): void => { + const path = 'http://example.com/idp/path/'; + let operation: Operation; + let representation: Representation; + let route: InteractionRoute; + let source: jest.Mocked; + let handler: InteractionRouteHandler; + + beforeEach(async(): Promise => { + operation = createPostJsonOperation({}, path); + + representation = new BasicRepresentation(JSON.stringify({}), APPLICATION_JSON); + + route = { + getPath: jest.fn().mockReturnValue(path), + }; + + source = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue(representation), + } as any; + + handler = new InteractionRouteHandler(route, source); + }); + + it('rejects other paths.', async(): Promise => { + operation = createPostJsonOperation({}, 'http://example.com/idp/otherPath/'); + await expect(handler.canHandle({ operation })).rejects.toThrow(NotFoundHttpError); + }); + + it('rejects input its source cannot handle.', async(): Promise => { + source.canHandle.mockRejectedValueOnce(new Error('bad data')); + await expect(handler.canHandle({ operation })).rejects.toThrow('bad data'); + }); + + it('can handle requests its source can handle.', async(): Promise => { + await expect(handler.canHandle({ operation })).resolves.toBeUndefined(); + }); + + it('lets its source handle requests.', async(): Promise => { + await expect(handler.handle({ operation })).resolves.toBe(representation); + }); +}); diff --git a/test/unit/identity/interaction/routing/RelativePathInteractionRoute.test.ts b/test/unit/identity/interaction/routing/RelativePathInteractionRoute.test.ts new file mode 100644 index 000000000..b8991202c --- /dev/null +++ b/test/unit/identity/interaction/routing/RelativePathInteractionRoute.test.ts @@ -0,0 +1,24 @@ +import type { InteractionRoute } from '../../../../../src/identity/interaction/routing/InteractionRoute'; +import { + RelativePathInteractionRoute, +} from '../../../../../src/identity/interaction/routing/RelativePathInteractionRoute'; + +describe('A RelativePathInteractionRoute', (): void => { + const relativePath = '/relative/'; + let route: jest.Mocked; + let relativeRoute: RelativePathInteractionRoute; + + beforeEach(async(): Promise => { + route = { + getPath: jest.fn().mockReturnValue('http://example.com/'), + }; + }); + + it('returns the joined path.', async(): Promise => { + relativeRoute = new RelativePathInteractionRoute(route, relativePath); + expect(relativeRoute.getPath()).toBe('http://example.com/relative/'); + + relativeRoute = new RelativePathInteractionRoute('http://example.com/test/', relativePath); + expect(relativeRoute.getPath()).toBe('http://example.com/test/relative/'); + }); +}); diff --git a/test/unit/identity/interaction/util/InteractionCompleter.test.ts b/test/unit/identity/interaction/util/InteractionCompleter.test.ts deleted file mode 100644 index 520f5746b..000000000 --- a/test/unit/identity/interaction/util/InteractionCompleter.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { ServerResponse } from 'http'; -import type { Provider } from 'oidc-provider'; -import type { ProviderFactory } from '../../../../../src/identity/configuration/ProviderFactory'; -import { InteractionCompleter } from '../../../../../src/identity/interaction/util/InteractionCompleter'; -import type { HttpRequest } from '../../../../../src/server/HttpRequest'; - -jest.useFakeTimers(); - -describe('An InteractionCompleter', (): void => { - const request: HttpRequest = {} as any; - const webId = 'http://alice.test.com/#me'; - let provider: jest.Mocked; - let completer: InteractionCompleter; - - beforeEach(async(): Promise => { - provider = { - interactionResult: jest.fn(), - } as any; - - const factory: ProviderFactory = { - getProvider: jest.fn().mockResolvedValue(provider), - }; - - completer = new InteractionCompleter(factory); - }); - - it('sends the correct data to the provider.', async(): Promise => { - await expect(completer.handle({ request, webId, shouldRemember: true })) - .resolves.toBeUndefined(); - expect(provider.interactionResult).toHaveBeenCalledTimes(1); - expect(provider.interactionResult).toHaveBeenLastCalledWith(request, expect.any(ServerResponse), { - login: { - account: webId, - remember: true, - ts: Math.floor(Date.now() / 1000), - }, - consent: { - rejectedScopes: [], - }, - }); - }); - - it('rejects offline access if shouldRemember is false.', async(): Promise => { - await expect(completer.handle({ request, webId, shouldRemember: false })) - .resolves.toBeUndefined(); - expect(provider.interactionResult).toHaveBeenCalledTimes(1); - expect(provider.interactionResult).toHaveBeenLastCalledWith(request, expect.any(ServerResponse), { - login: { - account: webId, - remember: false, - ts: Math.floor(Date.now() / 1000), - }, - consent: { - rejectedScopes: [ 'offline_access' ], - }, - }); - }); -}); diff --git a/test/unit/identity/ownership/TokenOwnershipValidator.test.ts b/test/unit/identity/ownership/TokenOwnershipValidator.test.ts index 78b327e15..46c58f0c3 100644 --- a/test/unit/identity/ownership/TokenOwnershipValidator.test.ts +++ b/test/unit/identity/ownership/TokenOwnershipValidator.test.ts @@ -1,15 +1,17 @@ -import { fetch } from 'cross-fetch'; +import { Readable } from 'stream'; import { DataFactory } from 'n3'; import type { Quad } from 'n3'; +import rdfDereferencer from 'rdf-dereference'; import { v4 } from 'uuid'; import { TokenOwnershipValidator } from '../../../../src/identity/ownership/TokenOwnershipValidator'; -import { RdfToQuadConverter } from '../../../../src/storage/conversion/RdfToQuadConverter'; import type { ExpiringStorage } from '../../../../src/storage/keyvalue/ExpiringStorage'; import { SOLID } from '../../../../src/util/Vocabularies'; const { literal, namedNode, quad } = DataFactory; -jest.mock('cross-fetch'); jest.mock('uuid'); +jest.mock('rdf-dereference', (): any => ({ + dereference: jest.fn(), +})); function quadToString(qq: Quad): string { const subPred = `<${qq.subject.value}> <${qq.predicate.value}>`; @@ -20,21 +22,19 @@ function quadToString(qq: Quad): string { } describe('A TokenOwnershipValidator', (): void => { - const fetchMock: jest.Mock = fetch as any; + const rdfDereferenceMock: jest.Mocked = rdfDereferencer as any; const webId = 'http://alice.test.com/#me'; const token = 'randomlyGeneratedToken'; const tokenTriple = quad(namedNode(webId), SOLID.terms.oidcIssuerRegistrationToken, literal(token)); const tokenString = `${quadToString(tokenTriple)}.`; - const converter = new RdfToQuadConverter(); let storage: ExpiringStorage; let validator: TokenOwnershipValidator; - function mockFetch(body: string): void { - fetchMock.mockImplementation((url: string): any => ({ - text: (): any => body, - url, - status: 200, - headers: { get: (): any => 'text/turtle' }, + function mockDereference(qq?: Quad): any { + rdfDereferenceMock.dereference.mockImplementation((uri: string): any => ({ + uri, + quads: Readable.from(qq ? [ qq ] : []), + exists: true, })); } @@ -50,32 +50,32 @@ describe('A TokenOwnershipValidator', (): void => { delete: jest.fn().mockImplementation((key: string): any => map.delete(key)), } as any; - mockFetch(''); + mockDereference(); - validator = new TokenOwnershipValidator(converter, storage); + validator = new TokenOwnershipValidator(storage); }); it('errors if no token is stored in the storage.', async(): Promise => { // Even if the token is in the WebId, it will error since it's not in the storage - mockFetch(tokenString); + mockDereference(tokenTriple); await expect(validator.handle({ webId })).rejects.toThrow(expect.objectContaining({ message: expect.stringContaining(tokenString), details: { quad: tokenString }, })); - expect(fetch).toHaveBeenCalledTimes(0); + expect(rdfDereferenceMock.dereference).toHaveBeenCalledTimes(0); }); it('errors if the expected triple is missing.', async(): Promise => { // First call will add the token to the storage await expect(validator.handle({ webId })).rejects.toThrow(tokenString); - expect(fetch).toHaveBeenCalledTimes(0); + expect(rdfDereferenceMock.dereference).toHaveBeenCalledTimes(0); // Second call will fetch the WebId await expect(validator.handle({ webId })).rejects.toThrow(tokenString); - expect(fetch).toHaveBeenCalledTimes(1); + expect(rdfDereferenceMock.dereference).toHaveBeenCalledTimes(1); }); it('resolves if the WebId contains the verification triple.', async(): Promise => { - mockFetch(tokenString); + mockDereference(tokenTriple); // First call will add the token to the storage await expect(validator.handle({ webId })).rejects.toThrow(tokenString); // Second call will succeed since it has the verification triple @@ -84,7 +84,7 @@ describe('A TokenOwnershipValidator', (): void => { it('fails if the WebId contains the wrong verification triple.', async(): Promise => { const wrongQuad = quad(namedNode(webId), SOLID.terms.oidcIssuerRegistrationToken, literal('wrongToken')); - mockFetch(`${quadToString(wrongQuad)} .`); + mockDereference(wrongQuad); // First call will add the token to the storage await expect(validator.handle({ webId })).rejects.toThrow(tokenString); // Second call will fail since it has the wrong verification triple diff --git a/test/unit/init/AppRunner.test.ts b/test/unit/init/AppRunner.test.ts index da1915df3..4ed64b7be 100644 --- a/test/unit/init/AppRunner.test.ts +++ b/test/unit/init/AppRunner.test.ts @@ -1,14 +1,38 @@ import { ComponentsManager } from 'componentsjs'; import type { App } from '../../../src/init/App'; import { AppRunner } from '../../../src/init/AppRunner'; +import type { CliExtractor } from '../../../src/init/cli/CliExtractor'; +import type { SettingsResolver } from '../../../src/init/variables/SettingsResolver'; import { joinFilePath } from '../../../src/util/PathUtil'; const app: jest.Mocked = { start: jest.fn(), } as any; +const defaultParameters = { + port: 3000, + logLevel: 'info', +}; +const cliExtractor: jest.Mocked = { + handleSafe: jest.fn().mockResolvedValue(defaultParameters), +} as any; + +const defaultVariables = { + 'urn:solid-server:default:variable:port': 3000, + 'urn:solid-server:default:variable:loggingLevel': 'info', +}; +const settingsResolver: jest.Mocked = { + handleSafe: jest.fn().mockResolvedValue(defaultVariables), +} as any; + const manager: jest.Mocked> = { - instantiate: jest.fn(async(): Promise => app), + instantiate: jest.fn(async(iri: string): Promise => { + switch (iri) { + case 'urn:solid-server-app-setup:default:CliResolver': return { cliExtractor, settingsResolver }; + case 'urn:solid-server:default:App': return app; + default: throw new Error('unknown iri'); + } + }), configRegistry: { register: jest.fn(), }, @@ -22,7 +46,6 @@ jest.mock('componentsjs', (): any => ({ })); jest.spyOn(process, 'cwd').mockReturnValue('/var/cwd'); -const error = jest.spyOn(console, 'error').mockImplementation(jest.fn()); const write = jest.spyOn(process.stderr, 'write').mockImplementation(jest.fn()); const exit = jest.spyOn(process, 'exit').mockImplementation(jest.fn() as any); @@ -31,8 +54,52 @@ describe('AppRunner', (): void => { jest.clearAllMocks(); }); + describe('create', (): void => { + it('creates an App with the provided settings.', async(): Promise => { + const variables = { + 'urn:solid-server:default:variable:port': 3000, + 'urn:solid-server:default:variable:loggingLevel': 'info', + 'urn:solid-server:default:variable:rootFilePath': '/var/cwd/', + 'urn:solid-server:default:variable:showStackTrace': false, + 'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json', + }; + const createdApp = await new AppRunner().create( + { + mainModulePath: joinFilePath(__dirname, '../../../'), + dumpErrorState: true, + logLevel: 'info', + }, + joinFilePath(__dirname, '../../../config/default.json'), + variables, + ); + expect(createdApp).toBe(app); + + expect(ComponentsManager.build).toHaveBeenCalledTimes(1); + expect(ComponentsManager.build).toHaveBeenCalledWith({ + dumpErrorState: true, + logLevel: 'info', + mainModulePath: joinFilePath(__dirname, '../../../'), + }); + expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); + expect(manager.configRegistry.register) + .toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json')); + expect(manager.instantiate).toHaveBeenCalledTimes(1); + expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server:default:App', { variables }); + expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(0); + expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(0); + expect(app.start).toHaveBeenCalledTimes(0); + }); + }); + describe('run', (): void => { - it('starts the server with default settings.', async(): Promise => { + it('starts the server with provided settings.', async(): Promise => { + const variables = { + 'urn:solid-server:default:variable:port': 3000, + 'urn:solid-server:default:variable:loggingLevel': 'info', + 'urn:solid-server:default:variable:rootFilePath': '/var/cwd/', + 'urn:solid-server:default:variable:showStackTrace': false, + 'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json', + }; await new AppRunner().run( { mainModulePath: joinFilePath(__dirname, '../../../'), @@ -40,13 +107,7 @@ describe('AppRunner', (): void => { logLevel: 'info', }, joinFilePath(__dirname, '../../../config/default.json'), - { - port: 3000, - loggingLevel: 'info', - rootFilePath: '/var/cwd/', - showStackTrace: false, - podConfigJson: '/var/cwd/pod-config.json', - }, + variables, ); expect(ComponentsManager.build).toHaveBeenCalledTimes(1); @@ -59,35 +120,17 @@ describe('AppRunner', (): void => { expect(manager.configRegistry.register) .toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json')); expect(manager.instantiate).toHaveBeenCalledTimes(1); - expect(manager.instantiate).toHaveBeenCalledWith( - 'urn:solid-server:default:App', - { - variables: { - 'urn:solid-server:default:variable:port': 3000, - 'urn:solid-server:default:variable:baseUrl': 'http://localhost:3000/', - 'urn:solid-server:default:variable:rootFilePath': '/var/cwd/', - 'urn:solid-server:default:variable:sparqlEndpoint': undefined, - 'urn:solid-server:default:variable:loggingLevel': 'info', - 'urn:solid-server:default:variable:showStackTrace': false, - 'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json', - }, - }, - ); + expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server:default:App', { variables }); + expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(0); + expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(0); expect(app.start).toHaveBeenCalledTimes(1); expect(app.start).toHaveBeenCalledWith(); }); }); - describe('runCli', (): void => { - it('starts the server with default settings.', async(): Promise => { - new AppRunner().runCli({ - argv: [ 'node', 'script' ], - }); - - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); + describe('createCli', (): void => { + it('creates the server with default settings.', async(): Promise => { + await expect(new AppRunner().createCli([ 'node', 'script' ])).resolves.toBe(app); expect(ComponentsManager.build).toHaveBeenCalledTimes(1); expect(ComponentsManager.build).toHaveBeenCalledWith({ @@ -98,133 +141,21 @@ describe('AppRunner', (): void => { expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); expect(manager.configRegistry.register) .toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json')); - expect(manager.instantiate).toHaveBeenCalledTimes(1); - expect(manager.instantiate).toHaveBeenCalledWith( + expect(manager.instantiate).toHaveBeenCalledTimes(2); + expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server-app-setup:default:CliResolver', {}); + expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(1); + expect(cliExtractor.handleSafe).toHaveBeenCalledWith([ 'node', 'script' ]); + expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(1); + expect(settingsResolver.handleSafe).toHaveBeenCalledWith(defaultParameters); + expect(manager.instantiate).toHaveBeenNthCalledWith(2, 'urn:solid-server:default:App', - { - variables: { - 'urn:solid-server:default:variable:port': 3000, - 'urn:solid-server:default:variable:baseUrl': 'http://localhost:3000/', - 'urn:solid-server:default:variable:rootFilePath': '/var/cwd/', - 'urn:solid-server:default:variable:sparqlEndpoint': undefined, - 'urn:solid-server:default:variable:loggingLevel': 'info', - 'urn:solid-server:default:variable:showStackTrace': false, - 'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json', - }, - }, - ); - expect(app.start).toHaveBeenCalledTimes(1); - expect(app.start).toHaveBeenCalledWith(); - }); - - it('accepts abbreviated flags.', async(): Promise => { - new AppRunner().runCli({ - argv: [ - 'node', 'script', - '-b', 'http://pod.example/', - '-c', 'myconfig.json', - '-f', '/root', - '-l', 'debug', - '-m', 'module/path', - '-p', '4000', - '-s', 'http://localhost:5000/sparql', - '-t', - '--podConfigJson', '/different-path.json', - ], - }); - - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); - - expect(ComponentsManager.build).toHaveBeenCalledTimes(1); - expect(ComponentsManager.build).toHaveBeenCalledWith({ - dumpErrorState: true, - logLevel: 'debug', - mainModulePath: '/var/cwd/module/path', - }); - expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); - expect(manager.configRegistry.register).toHaveBeenCalledWith('/var/cwd/myconfig.json'); - expect(manager.instantiate).toHaveBeenCalledWith( - 'urn:solid-server:default:App', - { - variables: { - 'urn:solid-server:default:variable:baseUrl': 'http://pod.example/', - 'urn:solid-server:default:variable:loggingLevel': 'debug', - 'urn:solid-server:default:variable:port': 4000, - 'urn:solid-server:default:variable:rootFilePath': '/root', - 'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql', - 'urn:solid-server:default:variable:showStackTrace': true, - 'urn:solid-server:default:variable:podConfigJson': '/different-path.json', - }, - }, - ); - }); - - it('accepts full flags.', async(): Promise => { - new AppRunner().runCli({ - argv: [ - 'node', 'script', - '--baseUrl', 'http://pod.example/', - '--config', 'myconfig.json', - '--loggingLevel', 'debug', - '--mainModulePath', 'module/path', - '--port', '4000', - '--rootFilePath', 'root', - '--sparqlEndpoint', 'http://localhost:5000/sparql', - '--showStackTrace', - '--podConfigJson', '/different-path.json', - ], - }); - - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); - - expect(ComponentsManager.build).toHaveBeenCalledTimes(1); - expect(ComponentsManager.build).toHaveBeenCalledWith({ - dumpErrorState: true, - logLevel: 'debug', - mainModulePath: '/var/cwd/module/path', - }); - expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); - expect(manager.configRegistry.register).toHaveBeenCalledWith('/var/cwd/myconfig.json'); - expect(manager.instantiate).toHaveBeenCalledWith( - 'urn:solid-server:default:App', - { - variables: { - 'urn:solid-server:default:variable:baseUrl': 'http://pod.example/', - 'urn:solid-server:default:variable:loggingLevel': 'debug', - 'urn:solid-server:default:variable:port': 4000, - 'urn:solid-server:default:variable:rootFilePath': '/var/cwd/root', - 'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql', - 'urn:solid-server:default:variable:showStackTrace': true, - 'urn:solid-server:default:variable:podConfigJson': '/different-path.json', - }, - }, - ); - }); - - it('accepts asset paths for the config flag.', async(): Promise => { - new AppRunner().runCli({ - argv: [ - 'node', 'script', - '--config', '@css:config/file.json', - ], - }); - await new Promise(setImmediate); - - expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); - expect(manager.configRegistry.register).toHaveBeenCalledWith( - joinFilePath(__dirname, '../../../config/file.json'), - ); + { variables: defaultVariables }); + expect(app.start).toHaveBeenCalledTimes(0); }); it('uses the default process.argv in case none are provided.', async(): Promise => { const { argv } = process; - process.argv = [ + const argvParameters = [ 'node', 'script', '-b', 'http://pod.example/', '-c', 'myconfig.json', @@ -236,13 +167,9 @@ describe('AppRunner', (): void => { '-t', '--podConfigJson', '/different-path.json', ]; + process.argv = argvParameters; - new AppRunner().runCli(); - - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); + await expect(new AppRunner().createCli()).resolves.toBe(app); expect(ComponentsManager.build).toHaveBeenCalledTimes(1); expect(ComponentsManager.build).toHaveBeenCalledWith({ @@ -252,120 +179,181 @@ describe('AppRunner', (): void => { }); expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); expect(manager.configRegistry.register).toHaveBeenCalledWith('/var/cwd/myconfig.json'); - expect(manager.instantiate).toHaveBeenCalledWith( + expect(manager.instantiate).toHaveBeenCalledTimes(2); + expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server-app-setup:default:CliResolver', {}); + expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(1); + expect(cliExtractor.handleSafe).toHaveBeenCalledWith(argvParameters); + expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(1); + expect(settingsResolver.handleSafe).toHaveBeenCalledWith(defaultParameters); + expect(manager.instantiate).toHaveBeenNthCalledWith(2, 'urn:solid-server:default:App', - { - variables: { - 'urn:solid-server:default:variable:baseUrl': 'http://pod.example/', - 'urn:solid-server:default:variable:loggingLevel': 'debug', - 'urn:solid-server:default:variable:port': 4000, - 'urn:solid-server:default:variable:rootFilePath': '/root', - 'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql', - 'urn:solid-server:default:variable:showStackTrace': true, - 'urn:solid-server:default:variable:podConfigJson': '/different-path.json', - }, - }, - ); + { variables: defaultVariables }); + expect(app.start).toHaveBeenCalledTimes(0); process.argv = argv; }); - it('exits with output to stderr when instantiation fails.', async(): Promise => { - manager.instantiate.mockRejectedValueOnce(new Error('Fatal')); - new AppRunner().runCli({ - argv: [ 'node', 'script' ], - }); + it('throws an error if creating a ComponentsManager fails.', async(): Promise => { + (manager.configRegistry.register as jest.Mock).mockRejectedValueOnce(new Error('Fatal')); - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); + let caughtError: Error = new Error('should disappear'); + try { + await new AppRunner().createCli([ 'node', 'script' ]); + } catch (error: unknown) { + caughtError = error as Error; + } + expect(caughtError.message).toMatch(/^Could not build the config files from .*default\.json/mu); + expect(caughtError.message).toMatch(/^Cause: Fatal/mu); - expect(write).toHaveBeenCalledTimes(2); - expect(write).toHaveBeenNthCalledWith(1, - expect.stringMatching(/^Error: could not instantiate server from .*default\.json/u)); - expect(write).toHaveBeenNthCalledWith(2, - expect.stringMatching(/^Error: Fatal/u)); - - expect(exit).toHaveBeenCalledTimes(1); - expect(exit).toHaveBeenCalledWith(1); + expect(write).toHaveBeenCalledTimes(0); + expect(exit).toHaveBeenCalledTimes(0); }); - it('exits without output to stderr when initialization fails.', async(): Promise => { - app.start.mockRejectedValueOnce(new Error('Fatal')); - new AppRunner().runCli({ - argv: [ 'node', 'script' ], - }); + it('throws an error if instantiating the CliResolver fails.', async(): Promise => { + manager.instantiate.mockRejectedValueOnce(new Error('Fatal')); - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); + let caughtError: Error = new Error('should disappear'); + try { + await new AppRunner().createCli([ 'node', 'script' ]); + } catch (error: unknown) { + caughtError = error as Error; + } + expect(caughtError.message).toMatch(/^Could not load the config variables/mu); + expect(caughtError.message).toMatch(/^Cause: Fatal/mu); + + expect(write).toHaveBeenCalledTimes(0); + expect(exit).toHaveBeenCalledTimes(0); + }); + + it('throws an error if instantiating the server fails.', async(): Promise => { + // We want the second call to fail + manager.instantiate + .mockResolvedValueOnce({ cliExtractor, settingsResolver }) + .mockRejectedValueOnce(new Error('Fatal')); + + let caughtError: Error = new Error('should disappear'); + try { + await new AppRunner().createCli([ 'node', 'script' ]); + } catch (error: unknown) { + caughtError = error as Error; + } + expect(caughtError.message).toMatch(/^Could not create the server/mu); + expect(caughtError.message).toMatch(/^Cause: Fatal/mu); + + expect(write).toHaveBeenCalledTimes(0); + expect(exit).toHaveBeenCalledTimes(0); + }); + + it('throws an error if non-error objects get thrown.', async(): Promise => { + (manager.configRegistry.register as jest.Mock).mockRejectedValueOnce('NotAnError'); + + let caughtError: Error = new Error('should disappear'); + try { + await new AppRunner().createCli([ 'node', 'script' ]); + } catch (error: unknown) { + caughtError = error as Error; + } + expect(caughtError.message).toMatch(/^Cause: Unknown error: NotAnError$/mu); + + expect(write).toHaveBeenCalledTimes(0); + expect(exit).toHaveBeenCalledTimes(0); + }); + }); + + describe('runCli', (): void => { + it('runs the server.', async(): Promise => { + await expect(new AppRunner().runCli([ 'node', 'script' ])).resolves.toBeUndefined(); + + expect(ComponentsManager.build).toHaveBeenCalledTimes(1); + expect(ComponentsManager.build).toHaveBeenCalledWith({ + dumpErrorState: true, + logLevel: 'info', + mainModulePath: joinFilePath(__dirname, '../../../'), }); + expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); + expect(manager.configRegistry.register) + .toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json')); + expect(manager.instantiate).toHaveBeenCalledTimes(2); + expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server-app-setup:default:CliResolver', {}); + expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(1); + expect(cliExtractor.handleSafe).toHaveBeenCalledWith([ 'node', 'script' ]); + expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(1); + expect(settingsResolver.handleSafe).toHaveBeenCalledWith(defaultParameters); + expect(manager.instantiate).toHaveBeenNthCalledWith(2, + 'urn:solid-server:default:App', + { variables: defaultVariables }); + expect(app.start).toHaveBeenCalledTimes(1); + expect(app.start).toHaveBeenLastCalledWith(); + }); + + it('throws an error if the server could not start.', async(): Promise => { + app.start.mockRejectedValueOnce(new Error('Fatal')); + + let caughtError: Error = new Error('should disappear'); + try { + await new AppRunner().runCli([ 'node', 'script' ]); + } catch (error: unknown) { + caughtError = error as Error; + } + expect(caughtError.message).toMatch(/^Could not start the server/mu); + expect(caughtError.message).toMatch(/^Cause: Fatal/mu); + + expect(app.start).toHaveBeenCalledTimes(1); expect(write).toHaveBeenCalledTimes(0); - expect(exit).toHaveBeenCalledWith(1); + expect(exit).toHaveBeenCalledTimes(0); }); + }); - it('exits when unknown options are passed to the main executable.', async(): Promise => { - new AppRunner().runCli({ - argv: [ 'node', 'script', '--foo' ], - }); + describe('runCliSync', (): void => { + it('starts the server.', async(): Promise => { + // eslint-disable-next-line no-sync + new AppRunner().runCliSync({ argv: [ 'node', 'script' ]}); // Wait until app.start has been called, because we can't await AppRunner.run. await new Promise((resolve): void => { setImmediate(resolve); }); - expect(error).toHaveBeenCalledWith('Unknown argument: foo'); + expect(ComponentsManager.build).toHaveBeenCalledTimes(1); + expect(ComponentsManager.build).toHaveBeenCalledWith({ + dumpErrorState: true, + logLevel: 'info', + mainModulePath: joinFilePath(__dirname, '../../../'), + }); + expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); + expect(manager.configRegistry.register) + .toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json')); + expect(manager.instantiate).toHaveBeenCalledTimes(2); + expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server-app-setup:default:CliResolver', {}); + expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(1); + expect(cliExtractor.handleSafe).toHaveBeenCalledWith([ 'node', 'script' ]); + expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(1); + expect(settingsResolver.handleSafe).toHaveBeenCalledWith(defaultParameters); + expect(manager.instantiate).toHaveBeenNthCalledWith(2, + 'urn:solid-server:default:App', + { variables: defaultVariables }); + expect(app.start).toHaveBeenCalledTimes(1); + expect(app.start).toHaveBeenLastCalledWith(); + }); + + it('exits the process and writes to stderr if there was an error.', async(): Promise => { + manager.instantiate.mockRejectedValueOnce(new Error('Fatal')); + + // eslint-disable-next-line no-sync + new AppRunner().runCliSync({ argv: [ 'node', 'script' ]}); + + // Wait until app.start has been called, because we can't await AppRunner.runCli. + await new Promise((resolve): void => { + setImmediate(resolve); + }); + + expect(write).toHaveBeenCalledTimes(1); + expect(write).toHaveBeenLastCalledWith(expect.stringMatching(/Cause: Fatal/mu)); + expect(exit).toHaveBeenCalledTimes(1); - expect(exit).toHaveBeenCalledWith(1); - }); - - it('exits when no value is passed to the main executable for an argument.', async(): Promise => { - new AppRunner().runCli({ - argv: [ 'node', 'script', '-s' ], - }); - - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); - - expect(error).toHaveBeenCalledWith('Not enough arguments following: s'); - expect(exit).toHaveBeenCalledTimes(1); - expect(exit).toHaveBeenCalledWith(1); - }); - - it('exits when unknown parameters are passed to the main executable.', async(): Promise => { - new AppRunner().runCli({ - argv: [ 'node', 'script', 'foo', 'bar', 'foo.txt', 'bar.txt' ], - }); - - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); - - expect(error).toHaveBeenCalledWith('Unknown arguments: foo, bar, foo.txt, bar.txt'); - // Yargs also calls process.exit in this case - expect(exit).toHaveBeenCalledTimes(2); - expect(exit).toHaveBeenCalledWith(1); - }); - - it('exits when multiple values for a parameter are passed.', async(): Promise => { - new AppRunner().runCli({ - argv: [ 'node', 'script', '-l', 'info', '-l', 'debug' ], - }); - - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); - - expect(error).toHaveBeenCalledWith('Multiple values were provided for: "l": "info", "debug"'); - expect(exit).toHaveBeenCalledTimes(1); - expect(exit).toHaveBeenCalledWith(1); + expect(exit).toHaveBeenLastCalledWith(1); }); }); }); diff --git a/test/unit/init/BaseUrlVerifier.test.ts b/test/unit/init/BaseUrlVerifier.test.ts new file mode 100644 index 000000000..a669caf6a --- /dev/null +++ b/test/unit/init/BaseUrlVerifier.test.ts @@ -0,0 +1,38 @@ +import { BaseUrlVerifier } from '../../../src/init/BaseUrlVerifier'; +import type { Logger } from '../../../src/logging/Logger'; +import { getLoggerFor } from '../../../src/logging/LogUtil'; +import type { KeyValueStorage } from '../../../src/storage/keyvalue/KeyValueStorage'; + +jest.mock('../../../src/logging/LogUtil', (): any => { + const logger: Logger = { warn: jest.fn() } as any; + return { getLoggerFor: (): Logger => logger }; +}); + +describe('A BaseUrlVerifier', (): void => { + const logger: jest.Mocked = getLoggerFor(BaseUrlVerifier) as any; + const baseUrl1 = 'http://base1.example.com/'; + const baseUrl2 = 'http://base2.example.com/'; + const storageKey = 'uniqueKey'; + let storage: KeyValueStorage; + + beforeEach(async(): Promise => { + storage = new Map() as any; + jest.clearAllMocks(); + }); + + it('stores the value if no value was stored yet.', async(): Promise => { + const initializer = new BaseUrlVerifier(baseUrl1, storageKey, storage); + await expect(initializer.handle()).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalledTimes(0); + }); + + it('logs a warning in case the value changes.', async(): Promise => { + let initializer = new BaseUrlVerifier(baseUrl1, storageKey, storage); + await expect(initializer.handle()).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalledTimes(0); + + initializer = new BaseUrlVerifier(baseUrl2, storageKey, storage); + await expect(initializer.handle()).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/unit/init/CliResolver.test.ts b/test/unit/init/CliResolver.test.ts new file mode 100644 index 000000000..65a45f036 --- /dev/null +++ b/test/unit/init/CliResolver.test.ts @@ -0,0 +1,13 @@ +import type { CliExtractor } from '../../../src/init/cli/CliExtractor'; +import { CliResolver } from '../../../src/init/CliResolver'; +import type { SettingsResolver } from '../../../src/init/variables/SettingsResolver'; + +describe('A CliResolver', (): void => { + it('stores a CliExtractor and SettingsResolver.', async(): Promise => { + const cliExtractor: CliExtractor = { canHandle: jest.fn().mockResolvedValue('CLI!') } as any; + const settingsResolver: SettingsResolver = { canHandle: jest.fn().mockResolvedValue('Settings!') } as any; + const cliResolver = new CliResolver(cliExtractor, settingsResolver); + expect(cliResolver.cliExtractor).toBe(cliExtractor); + expect(cliResolver.settingsResolver).toBe(settingsResolver); + }); +}); diff --git a/test/unit/init/ModuleVersionVerifier.test.ts b/test/unit/init/ModuleVersionVerifier.test.ts new file mode 100644 index 000000000..f524c0340 --- /dev/null +++ b/test/unit/init/ModuleVersionVerifier.test.ts @@ -0,0 +1,17 @@ +import { ModuleVersionVerifier } from '../../../src/init/ModuleVersionVerifier'; + +describe('A ModuleVersionVerifier', (): void => { + const storageKey = 'uniqueVersionKey'; + let storageMap: Map; + let initializer: ModuleVersionVerifier; + + beforeEach(async(): Promise => { + storageMap = new Map(); + initializer = new ModuleVersionVerifier(storageKey, storageMap as any); + }); + + it('stores the latest version.', async(): Promise => { + await expect(initializer.handle()).resolves.toBeUndefined(); + expect(storageMap.get(storageKey)).toMatch(/^\d+\.\d+\.\d+(?:-.+)?/u); + }); +}); diff --git a/test/unit/init/cli/YargsCliExtractor.test.ts b/test/unit/init/cli/YargsCliExtractor.test.ts new file mode 100644 index 000000000..312aa0c82 --- /dev/null +++ b/test/unit/init/cli/YargsCliExtractor.test.ts @@ -0,0 +1,83 @@ +import type { YargsArgOptions } from '../../../../src/init/cli/YargsCliExtractor'; +import { YargsCliExtractor } from '../../../../src/init/cli/YargsCliExtractor'; + +const error = jest.spyOn(console, 'error').mockImplementation(jest.fn()); +const log = jest.spyOn(console, 'log').mockImplementation(jest.fn()); +const exit = jest.spyOn(process, 'exit').mockImplementation(jest.fn() as any); +describe('A YargsCliExtractor', (): void => { + const parameters: YargsArgOptions = { + baseUrl: { alias: 'b', requiresArg: true, type: 'string' }, + port: { alias: 'p', requiresArg: true, type: 'number' }, + }; + let extractor: YargsCliExtractor; + + beforeEach(async(): Promise => { + extractor = new YargsCliExtractor(parameters); + }); + + afterEach(async(): Promise => { + jest.clearAllMocks(); + }); + + it('returns parsed results.', async(): Promise => { + const argv = [ 'node', 'script', '-b', 'http://localhost:3000/', '-p', '3000' ]; + await expect(extractor.handle(argv)).resolves.toEqual(expect.objectContaining({ + baseUrl: 'http://localhost:3000/', + port: 3000, + })); + }); + + it('accepts full flags.', async(): Promise => { + const argv = [ 'node', 'script', '--baseUrl', 'http://localhost:3000/', '--port', '3000' ]; + await expect(extractor.handle(argv)).resolves.toEqual(expect.objectContaining({ + baseUrl: 'http://localhost:3000/', + port: 3000, + })); + }); + + it('defaults to no parameters if none are provided.', async(): Promise => { + extractor = new YargsCliExtractor(); + const argv = [ 'node', 'script', '-b', 'http://localhost:3000/', '-p', '3000' ]; + await expect(extractor.handle(argv)).resolves.toEqual(expect.objectContaining({})); + }); + + it('prints usage if defined.', async(): Promise => { + extractor = new YargsCliExtractor(parameters, { usage: 'node ./bin/server.js [args]' }); + const argv = [ 'node', 'script', '--help' ]; + await extractor.handle(argv); + expect(exit).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenLastCalledWith(expect.stringMatching(/^node \.\/bin\/server\.js \[args\]/u)); + }); + + it('can error on undefined parameters.', async(): Promise => { + extractor = new YargsCliExtractor(parameters, { strictMode: true }); + const argv = [ 'node', 'script', '--unsupported' ]; + await extractor.handle(argv); + expect(exit).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith('Unknown argument: unsupported'); + }); + + it('can parse environment variables.', async(): Promise => { + // While the code below does go into the corresponding values, + // yargs does not see the new environment variable for some reason. + // It does see all the env variables that were already in there + // (which can be tested by setting envVarPrefix to ''). + // This can probably be fixed by changing jest setup to already load the custom env before loading the tests, + // but does not seem worth it just for this test. + const { env } = process; + // eslint-disable-next-line @typescript-eslint/naming-convention + process.env = { ...env, TEST_ENV_PORT: '3333' }; + extractor = new YargsCliExtractor(parameters, { loadFromEnv: true, envVarPrefix: 'TEST_ENV' }); + const argv = [ 'node', 'script', '-b', 'http://localhost:3333/' ]; + await expect(extractor.handle(argv)).resolves.toEqual(expect.objectContaining({ + baseUrl: 'http://localhost:3333/', + })); + process.env = env; + + // This part is here for the case of envVarPrefix being defined + // since it doesn't make much sense to test it if the above doesn't work + extractor = new YargsCliExtractor(parameters, { loadFromEnv: true }); + await extractor.handle(argv); + }); +}); diff --git a/test/unit/init/setup/SetupHandler.test.ts b/test/unit/init/setup/SetupHandler.test.ts new file mode 100644 index 000000000..e703e273b --- /dev/null +++ b/test/unit/init/setup/SetupHandler.test.ts @@ -0,0 +1,88 @@ +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { RegistrationResponse, + RegistrationManager } from '../../../../src/identity/interaction/email-password/util/RegistrationManager'; +import type { Initializer } from '../../../../src/init/Initializer'; +import { SetupHandler } from '../../../../src/init/setup/SetupHandler'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import { readJsonStream } from '../../../../src/util/StreamUtil'; + +describe('A SetupHandler', (): void => { + let operation: Operation; + let details: RegistrationResponse; + let registrationManager: jest.Mocked; + let initializer: jest.Mocked; + let handler: SetupHandler; + + beforeEach(async(): Promise => { + operation = { + method: 'POST', + target: { path: 'http://example.com/setup' }, + preferences: {}, + body: new BasicRepresentation(), + }; + + initializer = { + handleSafe: jest.fn(), + } as any; + + details = { + email: 'alice@test.email', + createWebId: true, + register: true, + createPod: true, + }; + + registrationManager = { + validateInput: jest.fn((input): any => input), + register: jest.fn().mockResolvedValue(details), + } as any; + + handler = new SetupHandler({ registrationManager, initializer }); + }); + + it('error if no Initializer is defined and initialization is requested.', async(): Promise => { + handler = new SetupHandler({}); + operation.body = new BasicRepresentation(JSON.stringify({ initialize: true }), 'application/json'); + await expect(handler.handle({ operation })).rejects.toThrow(NotImplementedHttpError); + }); + + it('error if no RegistrationManager is defined and registration is requested.', async(): Promise => { + handler = new SetupHandler({}); + operation.body = new BasicRepresentation(JSON.stringify({ registration: true }), 'application/json'); + await expect(handler.handle({ operation })).rejects.toThrow(NotImplementedHttpError); + }); + + it('calls the Initializer when requested.', async(): Promise => { + operation.body = new BasicRepresentation(JSON.stringify({ initialize: true }), 'application/json'); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ initialize: true, registration: false }); + expect(result.metadata.contentType).toBe('application/json'); + expect(initializer.handleSafe).toHaveBeenCalledTimes(1); + expect(registrationManager.validateInput).toHaveBeenCalledTimes(0); + expect(registrationManager.register).toHaveBeenCalledTimes(0); + }); + + it('calls the RegistrationManager when requested.', async(): Promise => { + const body = { registration: true, email: 'test@example.com' }; + operation.body = new BasicRepresentation(JSON.stringify(body), 'application/json'); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ initialize: false, registration: true, ...details }); + expect(result.metadata.contentType).toBe('application/json'); + expect(initializer.handleSafe).toHaveBeenCalledTimes(0); + expect(registrationManager.validateInput).toHaveBeenCalledTimes(1); + expect(registrationManager.register).toHaveBeenCalledTimes(1); + expect(registrationManager.validateInput).toHaveBeenLastCalledWith(body, true); + expect(registrationManager.register).toHaveBeenLastCalledWith(body, true); + }); + + it('defaults to an empty JSON body if no data is provided.', async(): Promise => { + operation.body = new BasicRepresentation(); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ initialize: false, registration: false }); + expect(result.metadata.contentType).toBe('application/json'); + expect(initializer.handleSafe).toHaveBeenCalledTimes(0); + expect(registrationManager.validateInput).toHaveBeenCalledTimes(0); + expect(registrationManager.register).toHaveBeenCalledTimes(0); + }); +}); diff --git a/test/unit/init/setup/SetupHttpHandler.test.ts b/test/unit/init/setup/SetupHttpHandler.test.ts index d58bcf510..b1cf83b33 100644 --- a/test/unit/init/setup/SetupHttpHandler.test.ts +++ b/test/unit/init/setup/SetupHttpHandler.test.ts @@ -1,13 +1,8 @@ import type { Operation } from '../../../../src/http/Operation'; -import type { ErrorHandler, ErrorHandlerArgs } from '../../../../src/http/output/error/ErrorHandler'; -import type { ResponseDescription } from '../../../../src/http/output/response/ResponseDescription'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../../src/http/representation/Representation'; import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; -import type { RegistrationManager, - RegistrationResponse } from '../../../../src/identity/interaction/email-password/util/RegistrationManager'; -import type { Initializer } from '../../../../src/init/Initializer'; -import type { SetupInput } from '../../../../src/init/setup/SetupHttpHandler'; +import type { InteractionHandler } from '../../../../src/identity/interaction/InteractionHandler'; import { SetupHttpHandler } from '../../../../src/init/setup/SetupHttpHandler'; import type { HttpRequest } from '../../../../src/server/HttpRequest'; import type { HttpResponse } from '../../../../src/server/HttpResponse'; @@ -15,25 +10,20 @@ import { getBestPreference } from '../../../../src/storage/conversion/Conversion import type { RepresentationConverterArgs, RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter'; import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; -import { APPLICATION_JSON } from '../../../../src/util/ContentTypes'; -import type { HttpError } from '../../../../src/util/errors/HttpError'; -import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; +import { APPLICATION_JSON, APPLICATION_X_WWW_FORM_URLENCODED } from '../../../../src/util/ContentTypes'; import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError'; -import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; -import { guardedStreamFrom, readableToString } from '../../../../src/util/StreamUtil'; -import { CONTENT_TYPE, SOLID_META } from '../../../../src/util/Vocabularies'; +import { readableToString } from '../../../../src/util/StreamUtil'; +import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; +import { CONTENT_TYPE } from '../../../../src/util/Vocabularies'; describe('A SetupHttpHandler', (): void => { - let request: HttpRequest; + const request: HttpRequest = {} as any; const response: HttpResponse = {} as any; let operation: Operation; - const viewTemplate = '/templates/view'; - const responseTemplate = '/templates/response'; const storageKey = 'completed'; - let details: RegistrationResponse; - let errorHandler: jest.Mocked; - let registrationManager: jest.Mocked; - let initializer: jest.Mocked; + let representation: Representation; + let interactionHandler: jest.Mocked; + let templateEngine: jest.Mocked; let converter: jest.Mocked; let storage: jest.Mocked>; let handler: SetupHttpHandler; @@ -41,32 +31,15 @@ describe('A SetupHttpHandler', (): void => { beforeEach(async(): Promise => { operation = { method: 'GET', - target: { path: 'http://test.com/setup' }, - preferences: { type: { 'text/html': 1 }}, + target: { path: 'http://example.com/setup' }, + preferences: {}, body: new BasicRepresentation(), }; - errorHandler = { handleSafe: jest.fn(({ error }: ErrorHandlerArgs): ResponseDescription => ({ - statusCode: 400, - data: guardedStreamFrom(`{ "name": "${error.name}", "message": "${error.message}" }`), - })) } as any; - - initializer = { - handleSafe: jest.fn(), - } as any; - - details = { - email: 'alice@test.email', - createWebId: true, - register: true, - createPod: true, + templateEngine = { + render: jest.fn().mockReturnValue(Promise.resolve('')), }; - registrationManager = { - validateInput: jest.fn((input): any => input), - register: jest.fn().mockResolvedValue(details), - } as any; - converter = { handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => { // Just find the best match; @@ -76,148 +49,71 @@ describe('A SetupHttpHandler', (): void => { }), } as any; + representation = new BasicRepresentation(); + interactionHandler = { + handleSafe: jest.fn().mockResolvedValue(representation), + } as any; + storage = new Map() as any; handler = new SetupHttpHandler({ - initializer, - registrationManager, converter, storageKey, storage, - viewTemplate, - responseTemplate, - errorHandler, + handler: interactionHandler, + templateEngine, }); }); - // Since all tests check similar things, the test functionality is generalized in here - async function testPost(input: SetupInput, error?: HttpError): Promise { - operation.method = 'POST'; - const initialize = Boolean(input.initialize); - const registration = Boolean(input.registration); - const requestBody = { initialize, registration }; - if (Object.keys(input).length > 0) { - operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json'); - } + it('only accepts GET and POST operations.', async(): Promise => { + operation = { + method: 'DELETE', + target: { path: 'http://example.com/setup' }, + preferences: {}, + body: new BasicRepresentation(), + }; + await expect(handler.handle({ operation, request, response })).rejects.toThrow(MethodNotAllowedHttpError); + }); + it('calls the template engine for GET requests.', async(): Promise => { const result = await handler.handle({ operation, request, response }); - expect(result).toBeDefined(); - expect(initializer.handleSafe).toHaveBeenCalledTimes(!error && initialize ? 1 : 0); - expect(registrationManager.validateInput).toHaveBeenCalledTimes(!error && registration ? 1 : 0); - expect(registrationManager.register).toHaveBeenCalledTimes(!error && registration ? 1 : 0); - let expectedResult: any = { initialize, registration }; - if (error) { - expectedResult = { name: error.name, message: error.message }; - } else if (registration) { - Object.assign(expectedResult, details); - } - expect(JSON.parse(await readableToString(result.data!))).toEqual(expectedResult); - expect(result.statusCode).toBe(error?.statusCode ?? 200); + expect(result.data).toBeDefined(); + await expect(readableToString(result.data!)).resolves.toBe(''); expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe(error ? viewTemplate : responseTemplate); - - if (!error && registration) { - expect(registrationManager.validateInput).toHaveBeenLastCalledWith(requestBody, true); - expect(registrationManager.register).toHaveBeenLastCalledWith(requestBody, true); - } - } - - it('returns the view template on GET requests.', async(): Promise => { - const result = await handler.handle({ operation, request, response }); - expect(result).toBeDefined(); - expect(JSON.parse(await readableToString(result.data!))).toEqual({}); - expect(result.statusCode).toBe(200); - expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe(viewTemplate); // Setup is still enabled since this was a GET request expect(storage.get(storageKey)).toBeUndefined(); }); - it('simply disables the handler if no setup is requested.', async(): Promise => { - await expect(testPost({ initialize: false, registration: false })).resolves.toBeUndefined(); + it('returns the handler result as 200 response.', async(): Promise => { + operation.method = 'POST'; + const result = await handler.handle({ operation, request, response }); + expect(result.statusCode).toBe(200); + expect(result.data).toBe(representation.data); + expect(result.metadata).toBe(representation.metadata); + expect(interactionHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(interactionHandler.handleSafe).toHaveBeenLastCalledWith({ operation }); // Handler is now disabled due to successful POST expect(storage.get(storageKey)).toBe(true); }); - it('defaults to an empty body if there is none.', async(): Promise => { - await expect(testPost({})).resolves.toBeUndefined(); - }); - - it('calls the initializer when requested.', async(): Promise => { - await expect(testPost({ initialize: true, registration: false })).resolves.toBeUndefined(); - }); - - it('calls the registrationManager when requested.', async(): Promise => { - await expect(testPost({ initialize: false, registration: true })).resolves.toBeUndefined(); - }); - - it('converts non-HTTP errors to internal errors.', async(): Promise => { - converter.handleSafe.mockRejectedValueOnce(new Error('bad data')); - const error = new InternalServerError('bad data'); - await expect(testPost({ initialize: true, registration: false }, error)).resolves.toBeUndefined(); - }); - - it('errors on non-GET/POST requests.', async(): Promise => { - operation.method = 'PUT'; - const requestBody = { initialize: true, registration: true }; - operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json'); - const error = new MethodNotAllowedHttpError(); - + it('converts input bodies to JSON.', async(): Promise => { + operation.method = 'POST'; + operation.body.metadata.contentType = APPLICATION_X_WWW_FORM_URLENCODED; const result = await handler.handle({ operation, request, response }); - expect(result).toBeDefined(); - expect(initializer.handleSafe).toHaveBeenCalledTimes(0); - expect(registrationManager.register).toHaveBeenCalledTimes(0); - expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1); - expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { [APPLICATION_JSON]: 1 }}}); + expect(result.statusCode).toBe(200); + expect(result.data).toBe(representation.data); + expect(result.metadata).toBe(representation.metadata); + expect(interactionHandler.handleSafe).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { body, ...partialOperation } = operation; + expect(interactionHandler.handleSafe).toHaveBeenLastCalledWith( + { operation: expect.objectContaining(partialOperation) }, + ); + expect(interactionHandler.handleSafe.mock.calls[0][0].operation.body.metadata.contentType).toBe(APPLICATION_JSON); - expect(JSON.parse(await readableToString(result.data!))).toEqual({ name: error.name, message: error.message }); - expect(result.statusCode).toBe(405); - expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe(viewTemplate); - - // Setup is not disabled since there was an error - expect(storage.get(storageKey)).toBeUndefined(); - }); - - it('errors when attempting registration when no RegistrationManager is defined.', async(): Promise => { - handler = new SetupHttpHandler({ - errorHandler, - initializer, - converter, - storageKey, - storage, - viewTemplate, - responseTemplate, - }); - operation.method = 'POST'; - const requestBody = { initialize: false, registration: true }; - operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json'); - const error = new NotImplementedHttpError('This server is not configured to support registration during setup.'); - await expect(testPost({ initialize: false, registration: true }, error)).resolves.toBeUndefined(); - - // Setup is not disabled since there was an error - expect(storage.get(storageKey)).toBeUndefined(); - }); - - it('errors when attempting initialization when no Initializer is defined.', async(): Promise => { - handler = new SetupHttpHandler({ - errorHandler, - registrationManager, - converter, - storageKey, - storage, - viewTemplate, - responseTemplate, - }); - operation.method = 'POST'; - const requestBody = { initialize: true, registration: false }; - operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json'); - const error = new NotImplementedHttpError('This server is not configured with a setup initializer.'); - await expect(testPost({ initialize: true, registration: false }, error)).resolves.toBeUndefined(); - - // Setup is not disabled since there was an error - expect(storage.get(storageKey)).toBeUndefined(); + // Handler is now disabled due to successful POST + expect(storage.get(storageKey)).toBe(true); }); }); diff --git a/test/unit/init/variables/CombinedSettingsResolver.test.ts b/test/unit/init/variables/CombinedSettingsResolver.test.ts new file mode 100644 index 000000000..8f262e14a --- /dev/null +++ b/test/unit/init/variables/CombinedSettingsResolver.test.ts @@ -0,0 +1,38 @@ +import { CombinedSettingsResolver } from '../../../../src/init/variables/CombinedSettingsResolver'; +import type { SettingsExtractor } from '../../../../src/init/variables/extractors/SettingsExtractor'; + +describe('A CombinedSettingsResolver', (): void => { + const values = { test: 'data' }; + const varPort = 'urn:solid-server:default:variable:port'; + const varLog = 'urn:solid-server:default:variable:loggingLevel'; + let computerPort: jest.Mocked; + let computerLog: jest.Mocked; + let resolver: CombinedSettingsResolver; + + beforeEach(async(): Promise => { + computerPort = { + handleSafe: jest.fn().mockResolvedValue(3000), + } as any; + + computerLog = { + handleSafe: jest.fn().mockResolvedValue('info'), + } as any; + + resolver = new CombinedSettingsResolver({ + [varPort]: computerPort, + [varLog]: computerLog, + }); + }); + + it('assigns variable values based on the Computers output.', async(): Promise => { + await expect(resolver.handle(values)).resolves.toEqual({ + [varPort]: 3000, + [varLog]: 'info', + }); + }); + + it('rethrows the error if something goes wrong.', async(): Promise => { + computerPort.handleSafe.mockRejectedValueOnce(new Error('bad data')); + await expect(resolver.handle(values)).rejects.toThrow(`Error in computing value for variable ${varPort}: bad data`); + }); +}); diff --git a/test/unit/init/variables/extractors/AssetPathExtractor.test.ts b/test/unit/init/variables/extractors/AssetPathExtractor.test.ts new file mode 100644 index 000000000..352d5d5d8 --- /dev/null +++ b/test/unit/init/variables/extractors/AssetPathExtractor.test.ts @@ -0,0 +1,28 @@ +import { AssetPathExtractor } from '../../../../../src/init/variables/extractors/AssetPathExtractor'; +import { joinFilePath } from '../../../../../src/util/PathUtil'; + +describe('An AssetPathExtractor', (): void => { + let resolver: AssetPathExtractor; + + beforeEach(async(): Promise => { + resolver = new AssetPathExtractor('path'); + }); + + it('resolves the asset path.', async(): Promise => { + await expect(resolver.handle({ path: '/var/data' })).resolves.toBe('/var/data'); + }); + + it('errors if the path is not a string.', async(): Promise => { + await expect(resolver.handle({ path: 1234 })).rejects.toThrow('Invalid path argument'); + }); + + it('converts paths containing the module path placeholder.', async(): Promise => { + await expect(resolver.handle({ path: '@css:config/file.json' })) + .resolves.toEqual(joinFilePath(__dirname, '../../../../../config/file.json')); + }); + + it('defaults to the given path if none is provided.', async(): Promise => { + resolver = new AssetPathExtractor('path', '/root'); + await expect(resolver.handle({ otherPath: '/var/data' })).resolves.toBe('/root'); + }); +}); diff --git a/test/unit/init/variables/extractors/BaseUrlExtractor.test.ts b/test/unit/init/variables/extractors/BaseUrlExtractor.test.ts new file mode 100644 index 000000000..21b46e36a --- /dev/null +++ b/test/unit/init/variables/extractors/BaseUrlExtractor.test.ts @@ -0,0 +1,22 @@ +import { BaseUrlExtractor } from '../../../../../src/init/variables/extractors/BaseUrlExtractor'; + +describe('A BaseUrlExtractor', (): void => { + let computer: BaseUrlExtractor; + + beforeEach(async(): Promise => { + computer = new BaseUrlExtractor(); + }); + + it('extracts the baseUrl parameter.', async(): Promise => { + await expect(computer.handle({ baseUrl: 'http://example.com/', port: 3333 })) + .resolves.toBe('http://example.com/'); + }); + + it('uses the port parameter if baseUrl is not defined.', async(): Promise => { + await expect(computer.handle({ port: 3333 })).resolves.toBe('http://localhost:3333/'); + }); + + it('defaults to port 3000.', async(): Promise => { + await expect(computer.handle({})).resolves.toBe('http://localhost:3000/'); + }); +}); diff --git a/test/unit/init/variables/extractors/KeyExtractor.test.ts b/test/unit/init/variables/extractors/KeyExtractor.test.ts new file mode 100644 index 000000000..21a25ce09 --- /dev/null +++ b/test/unit/init/variables/extractors/KeyExtractor.test.ts @@ -0,0 +1,19 @@ +import { KeyExtractor } from '../../../../../src/init/variables/extractors/KeyExtractor'; + +describe('An KeyExtractor', (): void => { + const key = 'test'; + let extractor: KeyExtractor; + + beforeEach(async(): Promise => { + extractor = new KeyExtractor(key); + }); + + it('extracts the value.', async(): Promise => { + await expect(extractor.handle({ test: 'data', notTest: 'notData' })).resolves.toBe('data'); + }); + + it('defaults to a given value if none is defined.', async(): Promise => { + extractor = new KeyExtractor(key, 'defaultData'); + await expect(extractor.handle({ notTest: 'notData' })).resolves.toBe('defaultData'); + }); +}); diff --git a/test/unit/quota/GlobalQuotaStrategy.test.ts b/test/unit/quota/GlobalQuotaStrategy.test.ts new file mode 100644 index 000000000..650d11e97 --- /dev/null +++ b/test/unit/quota/GlobalQuotaStrategy.test.ts @@ -0,0 +1,37 @@ +import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; +import { GlobalQuotaStrategy } from '../../../src/storage/quota/GlobalQuotaStrategy'; +import { UNIT_BYTES } from '../../../src/storage/size-reporter/Size'; +import type { Size } from '../../../src/storage/size-reporter/Size'; +import type { SizeReporter } from '../../../src/storage/size-reporter/SizeReporter'; + +describe('GlobalQuotaStrategy', (): void => { + let strategy: GlobalQuotaStrategy; + let mockSize: Size; + let mockReporter: jest.Mocked>; + let mockBase: string; + + beforeEach((): void => { + mockSize = { amount: 2000, unit: UNIT_BYTES }; + mockBase = ''; + mockReporter = { + getSize: jest.fn(async(identifier: ResourceIdentifier): Promise => ({ + unit: mockSize.unit, + // This mock will return 1000 as size of the root and 50 for any other resource + amount: identifier.path === mockBase ? 1000 : 50, + })), + getUnit: jest.fn().mockReturnValue(mockSize.unit), + calculateChunkSize: jest.fn(async(chunk: any): Promise => chunk.length), + estimateSize: jest.fn().mockResolvedValue(5), + }; + strategy = new GlobalQuotaStrategy(mockSize, mockReporter, mockBase); + }); + + describe('getAvailableSpace()', (): void => { + it('should return the correct amount of available space left.', async(): Promise => { + const result = strategy.getAvailableSpace({ path: 'any/path' }); + await expect(result).resolves.toEqual( + expect.objectContaining({ amount: mockSize.amount - 950 }), + ); + }); + }); +}); diff --git a/test/unit/quota/PodQuotaStrategy.test.ts b/test/unit/quota/PodQuotaStrategy.test.ts new file mode 100644 index 000000000..64ef455b2 --- /dev/null +++ b/test/unit/quota/PodQuotaStrategy.test.ts @@ -0,0 +1,77 @@ +import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; +import type { DataAccessor } from '../../../src/storage/accessors/DataAccessor'; +import { PodQuotaStrategy } from '../../../src/storage/quota/PodQuotaStrategy'; +import { UNIT_BYTES } from '../../../src/storage/size-reporter/Size'; +import type { Size } from '../../../src/storage/size-reporter/Size'; +import type { SizeReporter } from '../../../src/storage/size-reporter/SizeReporter'; +import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; +import type { IdentifierStrategy } from '../../../src/util/identifiers/IdentifierStrategy'; +import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy'; +import { PIM, RDF } from '../../../src/util/Vocabularies'; +import { mockFs } from '../../util/Util'; + +jest.mock('fs'); + +describe('PodQuotaStrategy', (): void => { + let strategy: PodQuotaStrategy; + let mockSize: Size; + let mockReporter: jest.Mocked>; + let identifierStrategy: IdentifierStrategy; + let accessor: jest.Mocked; + const base = 'http://localhost:3000/'; + const rootFilePath = 'folder'; + + beforeEach((): void => { + jest.restoreAllMocks(); + mockFs(rootFilePath, new Date()); + mockSize = { amount: 2000, unit: UNIT_BYTES }; + identifierStrategy = new SingleRootIdentifierStrategy(base); + mockReporter = { + getSize: jest.fn().mockResolvedValue({ unit: mockSize.unit, amount: 50 }), + getUnit: jest.fn().mockReturnValue(mockSize.unit), + calculateChunkSize: jest.fn(async(chunk: any): Promise => chunk.length), + estimateSize: jest.fn().mockResolvedValue(5), + }; + accessor = { + // Assume that the pod is called "nested" + getMetadata: jest.fn().mockImplementation( + async(identifier: ResourceIdentifier): Promise => { + const res = new RepresentationMetadata(); + if (identifier.path === `${base}nested/`) { + res.add(RDF.type, PIM.Storage); + } + return res; + }, + ), + } as any; + strategy = new PodQuotaStrategy(mockSize, mockReporter, identifierStrategy, accessor); + }); + + describe('getAvailableSpace()', (): void => { + it('should return a Size containing MAX_SAFE_INTEGER when writing outside a pod.', async(): Promise => { + const result = strategy.getAvailableSpace({ path: `${base}file.txt` }); + await expect(result).resolves.toEqual(expect.objectContaining({ amount: Number.MAX_SAFE_INTEGER })); + }); + it('should ignore the size of the existing resource when writing inside a pod.', async(): Promise => { + const result = strategy.getAvailableSpace({ path: `${base}nested/nested2/file.txt` }); + await expect(result).resolves.toEqual(expect.objectContaining({ amount: mockSize.amount })); + expect(mockReporter.getSize).toHaveBeenCalledTimes(2); + }); + it('should return a Size containing the available space when writing inside a pod.', async(): Promise => { + accessor.getMetadata.mockImplementationOnce((): any => { + throw new NotFoundHttpError(); + }); + const result = strategy.getAvailableSpace({ path: `${base}nested/nested2/file.txt` }); + await expect(result).resolves.toEqual(expect.objectContaining({ amount: mockSize.amount })); + expect(mockReporter.getSize).toHaveBeenCalledTimes(2); + }); + it('should throw when looking for pim:Storage errors.', async(): Promise => { + accessor.getMetadata.mockImplementationOnce((): any => { + throw new Error('error'); + }); + const result = strategy.getAvailableSpace({ path: `${base}nested/nested2/file.txt` }); + await expect(result).rejects.toThrow('error'); + }); + }); +}); diff --git a/test/unit/quota/QuotaStrategy.test.ts b/test/unit/quota/QuotaStrategy.test.ts new file mode 100644 index 000000000..c43c62367 --- /dev/null +++ b/test/unit/quota/QuotaStrategy.test.ts @@ -0,0 +1,88 @@ +import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata'; +import { QuotaStrategy } from '../../../src/storage/quota/QuotaStrategy'; +import { UNIT_BYTES } from '../../../src/storage/size-reporter/Size'; +import type { Size } from '../../../src/storage/size-reporter/Size'; +import type { SizeReporter } from '../../../src/storage/size-reporter/SizeReporter'; +import { guardedStreamFrom, pipeSafely } from '../../../src/util/StreamUtil'; +import { mockFs } from '../../util/Util'; + +jest.mock('fs'); + +class QuotaStrategyWrapper extends QuotaStrategy { + public constructor(reporter: SizeReporter, limit: Size) { + super(reporter, limit); + } + + public getAvailableSpace = async(): Promise => ({ unit: UNIT_BYTES, amount: 5 }); + protected getTotalSpaceUsed = async(): Promise => ({ unit: UNIT_BYTES, amount: 5 }); +} + +describe('A QuotaStrategy', (): void => { + let strategy: QuotaStrategyWrapper; + let mockSize: Size; + let mockReporter: jest.Mocked>; + const base = 'http://localhost:3000/'; + const rootFilePath = 'folder'; + + beforeEach((): void => { + jest.restoreAllMocks(); + mockFs(rootFilePath, new Date()); + mockSize = { amount: 2000, unit: UNIT_BYTES }; + mockReporter = { + getSize: jest.fn().mockResolvedValue({ unit: mockSize.unit, amount: 50 }), + getUnit: jest.fn().mockReturnValue(mockSize.unit), + calculateChunkSize: jest.fn(async(chunk: any): Promise => chunk.length), + estimateSize: jest.fn().mockResolvedValue(5), + }; + strategy = new QuotaStrategyWrapper(mockReporter, mockSize); + }); + + describe('constructor()', (): void => { + it('should set the passed parameters as properties.', async(): Promise => { + expect(strategy.limit).toEqual(mockSize); + expect(strategy.reporter).toEqual(mockReporter); + }); + }); + + describe('estimateSize()', (): void => { + it('should return a Size object containing the correct unit and amount.', async(): Promise => { + await expect(strategy.estimateSize(new RepresentationMetadata())).resolves.toEqual( + // This '5' comes from the reporter mock a little up in this file + expect.objectContaining({ unit: mockSize.unit, amount: 5 }), + ); + }); + it('should return undefined when the reporter returns undefined.', async(): Promise => { + mockReporter.estimateSize.mockResolvedValueOnce(undefined); + await expect(strategy.estimateSize(new RepresentationMetadata())).resolves.toBeUndefined(); + }); + }); + + describe('createQuotaGuard()', (): void => { + it('should return a passthrough that destroys the stream when quota is exceeded.', async(): Promise => { + strategy.getAvailableSpace = jest.fn().mockReturnValue({ amount: 50, unit: mockSize.unit }); + const fiftyChars = 'A'.repeat(50); + const stream = guardedStreamFrom(fiftyChars); + const track = await strategy.createQuotaGuard({ path: `${base}nested/file2.txt` }); + const piped = pipeSafely(stream, track); + + for (let i = 0; i < 10; i++) { + stream.push(fiftyChars); + } + + expect(piped.destroyed).toBe(false); + + for (let i = 0; i < 10; i++) { + stream.push(fiftyChars); + } + + expect(piped.destroyed).toBe(false); + + stream.push(fiftyChars); + + const destroy = new Promise((resolve): void => { + piped.on('error', (): void => resolve()); + }); + await expect(destroy).resolves.toBeUndefined(); + }); + }); +}); diff --git a/test/unit/storage/accessors/AtomicFileDataAccessor.test.ts b/test/unit/storage/accessors/AtomicFileDataAccessor.test.ts new file mode 100644 index 000000000..15be725a8 --- /dev/null +++ b/test/unit/storage/accessors/AtomicFileDataAccessor.test.ts @@ -0,0 +1,97 @@ +import 'jest-rdf'; +import type { Readable } from 'stream'; +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; +import { AtomicFileDataAccessor } from '../../../../src/storage/accessors/AtomicFileDataAccessor'; +import { ExtensionBasedMapper } from '../../../../src/storage/mapping/ExtensionBasedMapper'; +import { APPLICATION_OCTET_STREAM } from '../../../../src/util/ContentTypes'; +import type { Guarded } from '../../../../src/util/GuardedStream'; +import { guardedStreamFrom } from '../../../../src/util/StreamUtil'; +import { CONTENT_TYPE } from '../../../../src/util/Vocabularies'; +import { mockFs } from '../../../util/Util'; + +jest.mock('fs'); + +describe('AtomicFileDataAccessor', (): void => { + const rootFilePath = 'uploads'; + const base = 'http://test.com/'; + let accessor: AtomicFileDataAccessor; + let cache: { data: any }; + let metadata: RepresentationMetadata; + let data: Guarded; + + beforeEach(async(): Promise => { + cache = mockFs(rootFilePath, new Date()); + accessor = new AtomicFileDataAccessor( + new ExtensionBasedMapper(base, rootFilePath), + rootFilePath, + './.internal/tempFiles/', + ); + // The 'mkdirSync' in AtomicFileDataAccessor's constructor does not seem to create the folder in the + // cache object used for mocking fs. + // This line creates what represents a folder in the cache object + cache.data['.internal'] = { tempFiles: {}}; + metadata = new RepresentationMetadata(APPLICATION_OCTET_STREAM); + data = guardedStreamFrom([ 'data' ]); + }); + + describe('writing a document', (): void => { + it('writes the data to the corresponding file.', async(): Promise => { + await expect(accessor.writeDocument({ path: `${base}resource` }, data, metadata)).resolves.toBeUndefined(); + expect(cache.data.resource).toBe('data'); + }); + + it('writes metadata to the corresponding metadata file.', async(): Promise => { + metadata = new RepresentationMetadata({ path: `${base}res.ttl` }, + { [CONTENT_TYPE]: 'text/turtle', likes: 'apples' }); + await expect(accessor.writeDocument({ path: `${base}res.ttl` }, data, metadata)).resolves.toBeUndefined(); + expect(cache.data['res.ttl']).toBe('data'); + expect(cache.data['res.ttl.meta']).toMatch(`<${base}res.ttl> "apples".`); + }); + + it('should delete temp file when done writing.', async(): Promise => { + await expect(accessor.writeDocument({ path: `${base}resource` }, data, metadata)).resolves.toBeUndefined(); + expect(Object.keys(cache.data['.internal'].tempFiles)).toHaveLength(0); + expect(cache.data.resource).toBe('data'); + }); + + it('should throw an error when writing the data goes wrong.', async(): Promise => { + data.read = jest.fn((): any => { + data.emit('error', new Error('error')); + return null; + }); + jest.requireMock('fs').promises.stat = jest.fn((): any => ({ + isFile: (): boolean => false, + })); + await expect(accessor.writeDocument({ path: `${base}res.ttl` }, data, metadata)).rejects.toThrow('error'); + }); + + it('should throw when renaming / moving the file goes wrong.', async(): Promise => { + jest.requireMock('fs').promises.rename = jest.fn((): any => { + throw new Error('error'); + }); + jest.requireMock('fs').promises.stat = jest.fn((): any => ({ + isFile: (): boolean => true, + })); + await expect(accessor.writeDocument({ path: `${base}res.ttl` }, data, metadata)).rejects.toThrow('error'); + }); + + it('should (on error) not unlink the temp file if it does not exist.', async(): Promise => { + jest.requireMock('fs').promises.rename = jest.fn((): any => { + throw new Error('error'); + }); + jest.requireMock('fs').promises.stat = jest.fn((): any => ({ + isFile: (): boolean => false, + })); + await expect(accessor.writeDocument({ path: `${base}res.ttl` }, data, metadata)).rejects.toThrow('error'); + }); + + it('should throw when renaming / moving the file goes wrong and the temp file does not exist.', + async(): Promise => { + jest.requireMock('fs').promises.rename = jest.fn((): any => { + throw new Error('error'); + }); + jest.requireMock('fs').promises.stat = jest.fn(); + await expect(accessor.writeDocument({ path: `${base}res.ttl` }, data, metadata)).rejects.toThrow('error'); + }); + }); +}); diff --git a/test/unit/storage/accessors/PassthroughDataAccessor.test.ts b/test/unit/storage/accessors/PassthroughDataAccessor.test.ts new file mode 100644 index 000000000..923d8b67f --- /dev/null +++ b/test/unit/storage/accessors/PassthroughDataAccessor.test.ts @@ -0,0 +1,80 @@ +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; +import type { DataAccessor } from '../../../../src/storage/accessors/DataAccessor'; +import { PassthroughDataAccessor } from '../../../../src/storage/accessors/PassthroughDataAccessor'; +import { guardedStreamFrom } from '../../../../src/util/StreamUtil'; + +describe('ValidatingDataAccessor', (): void => { + let passthrough: PassthroughDataAccessor; + let childAccessor: jest.Mocked; + + const mockIdentifier = { path: 'http://localhost/test.txt' }; + const mockMetadata = new RepresentationMetadata(); + const mockData = guardedStreamFrom('test string'); + const mockRepresentation = new BasicRepresentation(mockData, mockMetadata); + + beforeEach(async(): Promise => { + jest.clearAllMocks(); + childAccessor = { + canHandle: jest.fn(), + writeDocument: jest.fn(), + getData: jest.fn(), + getChildren: jest.fn(), + writeContainer: jest.fn(), + deleteResource: jest.fn(), + getMetadata: jest.fn(), + }; + childAccessor.getChildren = jest.fn(); + passthrough = new PassthroughDataAccessor(childAccessor); + }); + + describe('writeDocument()', (): void => { + it('should call the accessors writeDocument() function.', async(): Promise => { + await passthrough.writeDocument(mockIdentifier, mockData, mockMetadata); + expect(childAccessor.writeDocument).toHaveBeenCalledTimes(1); + expect(childAccessor.writeDocument).toHaveBeenCalledWith(mockIdentifier, mockData, mockMetadata); + }); + }); + describe('canHandle()', (): void => { + it('should call the accessors canHandle() function.', async(): Promise => { + await passthrough.canHandle(mockRepresentation); + expect(childAccessor.canHandle).toHaveBeenCalledTimes(1); + expect(childAccessor.canHandle).toHaveBeenCalledWith(mockRepresentation); + }); + }); + describe('getData()', (): void => { + it('should call the accessors getData() function.', async(): Promise => { + await passthrough.getData(mockIdentifier); + expect(childAccessor.getData).toHaveBeenCalledTimes(1); + expect(childAccessor.getData).toHaveBeenCalledWith(mockIdentifier); + }); + }); + describe('getMetadata()', (): void => { + it('should call the accessors getMetadata() function.', async(): Promise => { + await passthrough.getMetadata(mockIdentifier); + expect(childAccessor.getMetadata).toHaveBeenCalledTimes(1); + expect(childAccessor.getMetadata).toHaveBeenCalledWith(mockIdentifier); + }); + }); + describe('getChildren()', (): void => { + it('should call the accessors getChildren() function.', async(): Promise => { + passthrough.getChildren(mockIdentifier); + expect(childAccessor.getChildren).toHaveBeenCalledTimes(1); + expect(childAccessor.getChildren).toHaveBeenCalledWith(mockIdentifier); + }); + }); + describe('deleteResource()', (): void => { + it('should call the accessors deleteResource() function.', async(): Promise => { + await passthrough.deleteResource(mockIdentifier); + expect(childAccessor.deleteResource).toHaveBeenCalledTimes(1); + expect(childAccessor.deleteResource).toHaveBeenCalledWith(mockIdentifier); + }); + }); + describe('writeContainer()', (): void => { + it('should call the accessors writeContainer() function.', async(): Promise => { + await passthrough.writeContainer(mockIdentifier, mockMetadata); + expect(childAccessor.writeContainer).toHaveBeenCalledTimes(1); + expect(childAccessor.writeContainer).toHaveBeenCalledWith(mockIdentifier, mockMetadata); + }); + }); +}); diff --git a/test/unit/storage/accessors/ValidatingDataAccessor.test.ts b/test/unit/storage/accessors/ValidatingDataAccessor.test.ts new file mode 100644 index 000000000..645526c63 --- /dev/null +++ b/test/unit/storage/accessors/ValidatingDataAccessor.test.ts @@ -0,0 +1,54 @@ +import type { Validator, ValidatorInput } from '../../../../src/http/auxiliary/Validator'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { Representation } from '../../../../src/http/representation/Representation'; +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; +import type { DataAccessor } from '../../../../src/storage/accessors/DataAccessor'; +import { ValidatingDataAccessor } from '../../../../src/storage/accessors/ValidatingDataAccessor'; +import { guardedStreamFrom } from '../../../../src/util/StreamUtil'; + +describe('ValidatingDataAccessor', (): void => { + let validatingAccessor: ValidatingDataAccessor; + let childAccessor: jest.Mocked; + let validator: jest.Mocked; + + const mockIdentifier = { path: 'http://localhost/test.txt' }; + const mockMetadata = new RepresentationMetadata(); + const mockData = guardedStreamFrom('test string'); + const mockRepresentation = new BasicRepresentation(mockData, mockMetadata); + + beforeEach(async(): Promise => { + jest.clearAllMocks(); + childAccessor = { + writeDocument: jest.fn(), + writeContainer: jest.fn(), + } as any; + childAccessor.getChildren = jest.fn(); + validator = { + handleSafe: jest.fn(async(input: ValidatorInput): Promise => input.representation), + } as any; + validatingAccessor = new ValidatingDataAccessor(childAccessor, validator); + }); + + describe('writeDocument()', (): void => { + it('should call the validator\'s handleSafe() function.', async(): Promise => { + await validatingAccessor.writeDocument(mockIdentifier, mockData, mockMetadata); + expect(validator.handleSafe).toHaveBeenCalledTimes(1); + expect(validator.handleSafe).toHaveBeenCalledWith({ + representation: mockRepresentation, + identifier: mockIdentifier, + }); + }); + it('should call the accessors writeDocument() function.', async(): Promise => { + await validatingAccessor.writeDocument(mockIdentifier, mockData, mockMetadata); + expect(childAccessor.writeDocument).toHaveBeenCalledTimes(1); + expect(childAccessor.writeDocument).toHaveBeenCalledWith(mockIdentifier, mockData, mockMetadata); + }); + }); + describe('writeContainer()', (): void => { + it('should call the accessors writeContainer() function.', async(): Promise => { + await validatingAccessor.writeContainer(mockIdentifier, mockMetadata); + expect(childAccessor.writeContainer).toHaveBeenCalledTimes(1); + expect(childAccessor.writeContainer).toHaveBeenCalledWith(mockIdentifier, mockMetadata); + }); + }); +}); diff --git a/test/unit/storage/conversion/TypedRepresentationConverter.test.ts b/test/unit/storage/conversion/BaseTypedRepresentationConverter.test.ts similarity index 68% rename from test/unit/storage/conversion/TypedRepresentationConverter.test.ts rename to test/unit/storage/conversion/BaseTypedRepresentationConverter.test.ts index f9d5561a3..3f25a68de 100644 --- a/test/unit/storage/conversion/TypedRepresentationConverter.test.ts +++ b/test/unit/storage/conversion/BaseTypedRepresentationConverter.test.ts @@ -1,62 +1,43 @@ +import { BaseTypedRepresentationConverter } from '../../../../src/storage/conversion/BaseTypedRepresentationConverter'; import type { RepresentationConverterArgs } from '../../../../src/storage/conversion/RepresentationConverter'; -import { TypedRepresentationConverter } from '../../../../src/storage/conversion/TypedRepresentationConverter'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; -class CustomTypedRepresentationConverter extends TypedRepresentationConverter { +class CustomTypedRepresentationConverter extends BaseTypedRepresentationConverter { public handle = jest.fn(); } -describe('A TypedRepresentationConverter', (): void => { - it('defaults to allowing everything.', async(): Promise => { - const converter = new CustomTypedRepresentationConverter(); - await expect(converter.getInputTypes()).resolves.toEqual({ - }); - await expect(converter.getOutputTypes()).resolves.toEqual({ - }); - }); - +describe('A BaseTypedRepresentationConverter', (): void => { it('accepts strings.', async(): Promise => { const converter = new CustomTypedRepresentationConverter('a/b', 'c/d'); - await expect(converter.getInputTypes()).resolves.toEqual({ - 'a/b': 1, - }); - await expect(converter.getOutputTypes()).resolves.toEqual({ + await expect(converter.getOutputTypes('a/b')).resolves.toEqual({ 'c/d': 1, }); }); it('accepts string arrays.', async(): Promise => { const converter = new CustomTypedRepresentationConverter([ 'a/b', 'c/d' ], [ 'e/f', 'g/h' ]); - await expect(converter.getInputTypes()).resolves.toEqual({ - 'a/b': 1, - 'c/d': 1, - }); - await expect(converter.getOutputTypes()).resolves.toEqual({ - 'e/f': 1, - 'g/h': 1, - }); + const output = { 'e/f': 1, 'g/h': 1 }; + await expect(converter.getOutputTypes('a/b')).resolves.toEqual(output); + await expect(converter.getOutputTypes('c/d')).resolves.toEqual(output); }); it('accepts records.', async(): Promise => { const converter = new CustomTypedRepresentationConverter({ 'a/b': 0.5 }, { 'c/d': 0.5 }); - await expect(converter.getInputTypes()).resolves.toEqual({ - 'a/b': 0.5, - }); - await expect(converter.getOutputTypes()).resolves.toEqual({ - 'c/d': 0.5, + await expect(converter.getOutputTypes('a/b')).resolves.toEqual({ + 'c/d': 0.5 * 0.5, }); }); it('can not handle input without a Content-Type.', async(): Promise => { const args: RepresentationConverterArgs = { representation: { metadata: { }}, preferences: {}} as any; - const converter = new CustomTypedRepresentationConverter('*/*'); + const converter = new CustomTypedRepresentationConverter('*/*', 'b/b'); await expect(converter.canHandle(args)).rejects.toThrow(NotImplementedHttpError); }); it('can not handle a type that does not match the input types.', async(): Promise => { const args: RepresentationConverterArgs = { representation: { metadata: { contentType: 'b/b' }}, preferences: {}} as any; - const converter = new CustomTypedRepresentationConverter('a/a'); + const converter = new CustomTypedRepresentationConverter('a/a', 'b/b'); await expect(converter.canHandle(args)).rejects.toThrow(NotImplementedHttpError); }); diff --git a/test/unit/storage/conversion/ChainedConverter.test.ts b/test/unit/storage/conversion/ChainedConverter.test.ts index fa7d35c04..01d1dbf4e 100644 --- a/test/unit/storage/conversion/ChainedConverter.test.ts +++ b/test/unit/storage/conversion/ChainedConverter.test.ts @@ -4,30 +4,22 @@ import type { RepresentationPreferences, ValuePreferences, } from '../../../../src/http/representation/RepresentationPreferences'; +import { BaseTypedRepresentationConverter } from '../../../../src/storage/conversion/BaseTypedRepresentationConverter'; import { ChainedConverter } from '../../../../src/storage/conversion/ChainedConverter'; import { matchesMediaType } from '../../../../src/storage/conversion/ConversionUtil'; import type { RepresentationConverterArgs } from '../../../../src/storage/conversion/RepresentationConverter'; -import { TypedRepresentationConverter } from '../../../../src/storage/conversion/TypedRepresentationConverter'; import { CONTENT_TYPE } from '../../../../src/util/Vocabularies'; -class DummyConverter extends TypedRepresentationConverter { +class DummyConverter extends BaseTypedRepresentationConverter { private readonly inTypes: ValuePreferences; private readonly outTypes: ValuePreferences; public constructor(inTypes: ValuePreferences, outTypes: ValuePreferences) { - super(); + super(inTypes, outTypes); this.inTypes = inTypes; this.outTypes = outTypes; } - public async getInputTypes(): Promise { - return this.inTypes; - } - - public async getOutputTypes(): Promise { - return this.outTypes; - } - public async handle(input: RepresentationConverterArgs): Promise { // Make sure the input type is supported const inType = input.representation.metadata.contentType!; @@ -78,19 +70,29 @@ describe('A ChainedConverter', (): void => { args.representation.metadata.contentType = 'b/b'; await expect(converter.handle(args)).rejects - .toThrow('No conversion path could be made from b/b to x/x,x/*,internal/*.'); + .toThrow('No conversion path could be made from b/b to x/x:1,x/*:0.8,internal/*:0.'); }); it('can handle situations where no conversion is required.', async(): Promise => { - const converters = [ new DummyConverter({ 'a/a': 1 }, { 'x/x': 1 }) ]; + const converters = [ new DummyConverter({ 'b/b': 1 }, { 'x/x': 1 }) ]; args.representation.metadata.contentType = 'b/b'; - args.preferences.type = { 'b/*': 0.5 }; + args.preferences.type = { 'b/*': 1, 'x/x': 0.5 }; const converter = new ChainedConverter(converters); const result = await converter.handle(args); expect(result.metadata.contentType).toBe('b/b'); }); + it('converts input matching the output preferences if a better output can be found.', async(): Promise => { + const converters = [ new DummyConverter({ 'b/b': 1 }, { 'x/x': 1 }) ]; + args.representation.metadata.contentType = 'b/b'; + args.preferences.type = { 'b/*': 0.5, 'x/x': 1 }; + const converter = new ChainedConverter(converters); + + const result = await converter.handle(args); + expect(result.metadata.contentType).toBe('x/x'); + }); + it('interprets no preferences as */*.', async(): Promise => { const converters = [ new DummyConverter({ 'a/a': 1 }, { 'x/x': 1 }) ]; const converter = new ChainedConverter(converters); @@ -218,4 +220,15 @@ describe('A ChainedConverter', (): void => { expect(converter.handle).toHaveBeenCalledTimes(1); expect(converter.handle).toHaveBeenLastCalledWith(args); }); + + it('does not get stuck in infinite conversion loops.', async(): Promise => { + const converters = [ + new DummyConverter({ 'a/a': 1 }, { 'b/b': 1 }), + new DummyConverter({ 'b/b': 1 }, { 'a/a': 1 }), + ]; + const converter = new ChainedConverter(converters); + + await expect(converter.handle(args)).rejects + .toThrow('No conversion path could be made from a/a to x/x:1,x/*:0.8,internal/*:0.'); + }); }); diff --git a/test/unit/storage/conversion/ContentTypeReplacer.test.ts b/test/unit/storage/conversion/ContentTypeReplacer.test.ts index 782d5b53b..77768d332 100644 --- a/test/unit/storage/conversion/ContentTypeReplacer.test.ts +++ b/test/unit/storage/conversion/ContentTypeReplacer.test.ts @@ -97,4 +97,14 @@ describe('A ContentTypeReplacer', (): void => { expect(result.data).toBe(data); expect(result.metadata.contentType).toBe('application/trig'); }); + + it('returns all matching output types.', async(): Promise => { + await expect(converter.getOutputTypes('application/n-triples')).resolves.toEqual({ + 'text/turtle': 1, + 'application/trig': 1, + 'application/n-quads': 1, + 'application/octet-stream': 1, + 'internal/anything': 1, + }); + }); }); diff --git a/test/unit/storage/conversion/ConversionUtil.test.ts b/test/unit/storage/conversion/ConversionUtil.test.ts index 4d28ad65f..121af58d0 100644 --- a/test/unit/storage/conversion/ConversionUtil.test.ts +++ b/test/unit/storage/conversion/ConversionUtil.test.ts @@ -6,7 +6,7 @@ import { getTypeWeight, getWeightedPreferences, isInternalContentType, matchesMediaPreferences, - matchesMediaType, + matchesMediaType, preferencesToString, } from '../../../../src/storage/conversion/ConversionUtil'; import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; @@ -153,4 +153,11 @@ describe('ConversionUtil', (): void => { expect(isInternalContentType('text/turtle')).toBeFalsy(); }); }); + + describe('#preferencesToString', (): void => { + it('returns a string serialization.', async(): Promise => { + const preferences: ValuePreferences = { 'a/*': 1, 'b/b': 0.8, 'c/c': 0 }; + expect(preferencesToString(preferences)).toBe('a/*:1,b/b:0.8,c/c:0'); + }); + }); }); diff --git a/test/unit/storage/conversion/DynamicJsonToTemplateConverter.test.ts b/test/unit/storage/conversion/DynamicJsonToTemplateConverter.test.ts index e63ce6a9e..17e59f7a3 100644 --- a/test/unit/storage/conversion/DynamicJsonToTemplateConverter.test.ts +++ b/test/unit/storage/conversion/DynamicJsonToTemplateConverter.test.ts @@ -1,9 +1,11 @@ import { DataFactory } from 'n3'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../../src/http/representation/Representation'; +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; import type { RepresentationPreferences } from '../../../../src/http/representation/RepresentationPreferences'; import { DynamicJsonToTemplateConverter } from '../../../../src/storage/conversion/DynamicJsonToTemplateConverter'; import type { RepresentationConverterArgs } from '../../../../src/storage/conversion/RepresentationConverter'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { readableToString } from '../../../../src/util/StreamUtil'; import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; import { CONTENT_TYPE_TERM, SOLID_META } from '../../../../src/util/Vocabularies'; @@ -50,6 +52,12 @@ describe('A DynamicJsonToTemplateConverter', (): void => { await expect(converter.canHandle(input)).resolves.toBeUndefined(); }); + it('rejects JSON input if no templates are defined.', async(): Promise => { + preferences.type = { 'application/json': 1 }; + representation.metadata = new RepresentationMetadata('application/json'); + await expect(converter.canHandle(input)).rejects.toThrow(NotImplementedHttpError); + }); + it('uses the input JSON as parameters for the matching template.', async(): Promise => { const result = await converter.handle(input); await expect(readableToString(result.data)).resolves.toBe(''); @@ -64,4 +72,19 @@ describe('A DynamicJsonToTemplateConverter', (): void => { const result = await converter.handle(input); await expect(readableToString(result.data)).resolves.toBe(''); }); + + it('returns the input representation if JSON is preferred.', async(): Promise => { + input.preferences.type = { 'application/json': 1, 'text/html': 0.5 }; + await expect(converter.handle(input)).resolves.toBe(input.representation); + }); + + it('still converts if JSON is preferred but there is a JSON template.', async(): Promise => { + input.preferences.type = { 'application/json': 1 }; + const templateNode = namedNode(templateFile); + representation.metadata.add(SOLID_META.terms.template, templateNode); + representation.metadata.addQuad(templateNode, CONTENT_TYPE_TERM, 'application/json'); + const result = await converter.handle(input); + await expect(readableToString(result.data)).resolves.toBe(''); + expect(result.metadata.contentType).toBe('application/json'); + }); }); diff --git a/test/unit/storage/conversion/ErrorToJsonConverter.test.ts b/test/unit/storage/conversion/ErrorToJsonConverter.test.ts index a7c0fc4f8..1b9ca1f01 100644 --- a/test/unit/storage/conversion/ErrorToJsonConverter.test.ts +++ b/test/unit/storage/conversion/ErrorToJsonConverter.test.ts @@ -9,8 +9,7 @@ describe('An ErrorToJsonConverter', (): void => { const preferences = {}; it('supports going from errors to json.', async(): Promise => { - await expect(converter.getInputTypes()).resolves.toEqual({ 'internal/error': 1 }); - await expect(converter.getOutputTypes()).resolves.toEqual({ 'application/json': 1 }); + await expect(converter.getOutputTypes('internal/error')).resolves.toEqual({ 'application/json': 1 }); }); it('adds all HttpError fields.', async(): Promise => { diff --git a/test/unit/storage/conversion/ErrorToQuadConverter.test.ts b/test/unit/storage/conversion/ErrorToQuadConverter.test.ts index 3def938e0..68736d7e8 100644 --- a/test/unit/storage/conversion/ErrorToQuadConverter.test.ts +++ b/test/unit/storage/conversion/ErrorToQuadConverter.test.ts @@ -13,8 +13,7 @@ describe('An ErrorToQuadConverter', (): void => { const preferences = {}; it('supports going from errors to quads.', async(): Promise => { - await expect(converter.getInputTypes()).resolves.toEqual({ 'internal/error': 1 }); - await expect(converter.getOutputTypes()).resolves.toEqual({ 'internal/quads': 1 }); + await expect(converter.getOutputTypes('internal/error')).resolves.toEqual({ 'internal/quads': 1 }); }); it('adds triples for all error fields.', async(): Promise => { diff --git a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts index ecc3fe861..31e190fcb 100644 --- a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts +++ b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts @@ -1,6 +1,7 @@ import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import { ErrorToTemplateConverter } from '../../../../src/storage/conversion/ErrorToTemplateConverter'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; +import { resolveModulePath } from '../../../../src/util/PathUtil'; import { readableToString } from '../../../../src/util/StreamUtil'; import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; @@ -24,8 +25,7 @@ describe('An ErrorToTemplateConverter', (): void => { }); it('supports going from errors to the given content type.', async(): Promise => { - await expect(converter.getInputTypes()).resolves.toEqual({ 'internal/error': 1 }); - await expect(converter.getOutputTypes()).resolves.toEqual({ 'text/html': 1 }); + await expect(converter.getOutputTypes('internal/error')).resolves.toEqual({ 'text/html': 1 }); }); it('works with non-HTTP errors.', async(): Promise => { @@ -156,9 +156,9 @@ describe('An ErrorToTemplateConverter', (): void => { expect(templateEngine.render).toHaveBeenCalledTimes(2); expect(templateEngine.render).toHaveBeenNthCalledWith(1, { key: 'val' }, - { templatePath: '@css:templates/error/descriptions/', templateFile: 'E0001.md.hbs' }); + { templatePath: resolveModulePath('templates/error/descriptions/'), templateFile: 'E0001.md.hbs' }); expect(templateEngine.render).toHaveBeenNthCalledWith(2, { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '' }, - { templateFile: '@css:templates/error/main.md.hbs' }); + { templateFile: resolveModulePath('templates/error/main.md.hbs') }); }); }); diff --git a/test/unit/storage/conversion/FormToJsonConverter.test.ts b/test/unit/storage/conversion/FormToJsonConverter.test.ts index 11fbb5e4b..4f7d89d35 100644 --- a/test/unit/storage/conversion/FormToJsonConverter.test.ts +++ b/test/unit/storage/conversion/FormToJsonConverter.test.ts @@ -8,8 +8,8 @@ describe('A FormToJsonConverter', (): void => { const converter = new FormToJsonConverter(); it('supports going from form data to json.', async(): Promise => { - await expect(converter.getInputTypes()).resolves.toEqual({ 'application/x-www-form-urlencoded': 1 }); - await expect(converter.getOutputTypes()).resolves.toEqual({ 'application/json': 1 }); + await expect(converter.getOutputTypes('application/x-www-form-urlencoded')) + .resolves.toEqual({ 'application/json': 1 }); }); it('converts form data to JSON.', async(): Promise => { diff --git a/test/unit/storage/conversion/IfNeededConverter.test.ts b/test/unit/storage/conversion/IfNeededConverter.test.ts deleted file mode 100644 index af5642d79..000000000 --- a/test/unit/storage/conversion/IfNeededConverter.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { Representation } from '../../../../src/http/representation/Representation'; -import { IfNeededConverter } from '../../../../src/storage/conversion/IfNeededConverter'; -import type { - RepresentationConverter, -} from '../../../../src/storage/conversion/RepresentationConverter'; - -describe('An IfNeededConverter', (): void => { - const identifier = { path: 'identifier' }; - const representation: Representation = { - metadata: { contentType: 'text/turtle' }, - } as any; - const converted = { - metadata: { contentType: 'application/ld+json' }, - }; - - const innerConverter: jest.Mocked = { - canHandle: jest.fn().mockResolvedValue(true), - handle: jest.fn().mockResolvedValue(converted), - handleSafe: jest.fn().mockResolvedValue(converted), - } as any; - - const converter = new IfNeededConverter(innerConverter); - - afterEach((): void => { - jest.clearAllMocks(); - }); - - it('performs no conversion when there are no content type preferences.', async(): Promise => { - const preferences = {}; - const args = { identifier, representation, preferences }; - - await expect(converter.canHandle(args)).resolves.toBeUndefined(); - await expect(converter.handle(args)).resolves.toBe(representation); - await expect(converter.handleSafe(args)).resolves.toBe(representation); - - expect(innerConverter.canHandle).toHaveBeenCalledTimes(0); - expect(innerConverter.handle).toHaveBeenCalledTimes(0); - expect(innerConverter.handleSafe).toHaveBeenCalledTimes(0); - }); - - it('performs conversion when there are no preferences but the content-type is internal.', async(): Promise => { - const preferences = {}; - const internalRepresentation = { - metadata: { contentType: 'internal/quads' }, - } as any; - const args = { identifier, representation: internalRepresentation, preferences }; - - await expect(converter.handleSafe(args)).resolves.toBe(converted); - - expect(innerConverter.canHandle).toHaveBeenCalledTimes(0); - expect(innerConverter.handle).toHaveBeenCalledTimes(0); - expect(innerConverter.handleSafe).toHaveBeenCalledTimes(1); - expect(innerConverter.handleSafe).toHaveBeenCalledWith(args); - }); - - it('errors if no content type is specified on the representation.', async(): Promise => { - const preferences = { type: { 'text/turtle': 1 }}; - const args = { identifier, representation: { metadata: {}} as any, preferences }; - - await expect(converter.handleSafe(args)).rejects - .toThrow('Content-Type is required for data conversion.'); - - expect(innerConverter.canHandle).toHaveBeenCalledTimes(0); - expect(innerConverter.handle).toHaveBeenCalledTimes(0); - expect(innerConverter.handleSafe).toHaveBeenCalledTimes(0); - }); - - it('performs no conversion when the content type matches the preferences.', async(): Promise => { - const preferences = { type: { 'text/turtle': 1 }}; - const args = { identifier, representation, preferences }; - - await expect(converter.handleSafe(args)).resolves.toBe(representation); - - expect(innerConverter.canHandle).toHaveBeenCalledTimes(0); - expect(innerConverter.handle).toHaveBeenCalledTimes(0); - expect(innerConverter.handleSafe).toHaveBeenCalledTimes(0); - }); - - it('performs a conversion when the content type matches the preferences.', async(): Promise => { - const preferences = { type: { 'text/turtle': 0 }}; - const args = { identifier, representation, preferences }; - - await expect(converter.handleSafe(args)).resolves.toBe(converted); - - expect(innerConverter.canHandle).toHaveBeenCalledTimes(0); - expect(innerConverter.handle).toHaveBeenCalledTimes(0); - expect(innerConverter.handleSafe).toHaveBeenCalledTimes(1); - expect(innerConverter.handleSafe).toHaveBeenCalledWith(args); - }); - - it('does not support conversion when the inner converter does not support it.', async(): Promise => { - const preferences = { type: { 'text/turtle': 0 }}; - const args = { identifier, representation, preferences }; - const error = new Error('unsupported'); - innerConverter.canHandle.mockRejectedValueOnce(error); - - await expect(converter.canHandle(args)).rejects.toThrow(error); - - expect(innerConverter.canHandle).toHaveBeenCalledTimes(1); - expect(innerConverter.canHandle).toHaveBeenCalledWith(args); - }); - - it('supports conversion when the inner converter supports it.', async(): Promise => { - const preferences = { type: { 'text/turtle': 0 }}; - const args = { identifier, representation, preferences }; - - await expect(converter.canHandle(args)).resolves.toBeUndefined(); - - expect(innerConverter.canHandle).toHaveBeenCalledTimes(1); - expect(innerConverter.canHandle).toHaveBeenCalledWith(args); - - await expect(converter.handle(args)).resolves.toBe(converted); - - expect(innerConverter.canHandle).toHaveBeenCalledTimes(1); - expect(innerConverter.handle).toHaveBeenCalledTimes(1); - expect(innerConverter.handle).toHaveBeenCalledWith(args); - }); - - it('does not support conversion when there is no inner converter.', async(): Promise => { - const emptyConverter = new IfNeededConverter(); - const preferences = { type: { 'text/turtle': 0 }}; - const args = { identifier, representation, preferences }; - - await expect(emptyConverter.canHandle(args)).rejects - .toThrow('The content type does not match the preferences'); - await expect(emptyConverter.handle(args)).rejects - .toThrow('The content type does not match the preferences'); - await expect(emptyConverter.handleSafe(args)).rejects - .toThrow('The content type does not match the preferences'); - }); -}); diff --git a/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts b/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts index 733142b1f..7c3cb7c7c 100644 --- a/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts +++ b/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts @@ -17,8 +17,7 @@ describe('A MarkdownToHtmlConverter', (): void => { }); it('supports going from markdown to html.', async(): Promise => { - await expect(converter.getInputTypes()).resolves.toEqual({ 'text/markdown': 1 }); - await expect(converter.getOutputTypes()).resolves.toEqual({ 'text/html': 1 }); + await expect(converter.getOutputTypes('text/markdown')).resolves.toEqual({ 'text/html': 1 }); }); it('converts markdown and inserts it in the template.', async(): Promise => { diff --git a/test/unit/storage/conversion/QuadToRdfConverter.test.ts b/test/unit/storage/conversion/QuadToRdfConverter.test.ts index 25000a9c9..8b3ff2b3a 100644 --- a/test/unit/storage/conversion/QuadToRdfConverter.test.ts +++ b/test/unit/storage/conversion/QuadToRdfConverter.test.ts @@ -20,19 +20,14 @@ describe('A QuadToRdfConverter', (): void => { metadata = new RepresentationMetadata(identifier, INTERNAL_QUADS); }); - it('supports parsing quads.', async(): Promise => { - await expect(new QuadToRdfConverter().getInputTypes()) - .resolves.toEqual({ [INTERNAL_QUADS]: 1 }); - }); - it('defaults to rdfSerializer preferences when given no output preferences.', async(): Promise => { - await expect(new QuadToRdfConverter().getOutputTypes()) + await expect(new QuadToRdfConverter().getOutputTypes(INTERNAL_QUADS)) .resolves.toEqual(await rdfSerializer.getContentTypesPrioritized()); }); it('supports overriding output preferences.', async(): Promise => { const outputPreferences = { 'text/turtle': 1 }; - await expect(new QuadToRdfConverter({ outputPreferences }).getOutputTypes()) + await expect(new QuadToRdfConverter({ outputPreferences }).getOutputTypes(INTERNAL_QUADS)) .resolves.toEqual(outputPreferences); }); diff --git a/test/unit/storage/conversion/RdfToQuadConverter.test.ts b/test/unit/storage/conversion/RdfToQuadConverter.test.ts index 728ef5524..a9a4be871 100644 --- a/test/unit/storage/conversion/RdfToQuadConverter.test.ts +++ b/test/unit/storage/conversion/RdfToQuadConverter.test.ts @@ -17,12 +17,11 @@ describe('A RdfToQuadConverter', (): void => { const converter = new RdfToQuadConverter(); const identifier: ResourceIdentifier = { path: 'path' }; - it('supports parsing the same types as rdfParser.', async(): Promise => { - await expect(converter.getInputTypes()).resolves.toEqual(await rdfParser.getContentTypesPrioritized()); - }); - it('supports serializing as quads.', async(): Promise => { - await expect(converter.getOutputTypes()).resolves.toEqual({ [INTERNAL_QUADS]: 1 }); + const types = await rdfParser.getContentTypes(); + for (const type of types) { + await expect(converter.getOutputTypes(type)).resolves.toEqual({ [INTERNAL_QUADS]: 1 }); + } }); it('can handle turtle to quad conversions.', async(): Promise => { diff --git a/test/unit/storage/patch/N3Patcher.test.ts b/test/unit/storage/patch/N3Patcher.test.ts new file mode 100644 index 000000000..f0a14c517 --- /dev/null +++ b/test/unit/storage/patch/N3Patcher.test.ts @@ -0,0 +1,155 @@ +import 'jest-rdf'; +import arrayifyStream from 'arrayify-stream'; +import { DataFactory } from 'n3'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { N3Patch } from '../../../../src/http/representation/N3Patch'; +import { N3Patcher } from '../../../../src/storage/patch/N3Patcher'; +import type { RepresentationPatcherInput } from '../../../../src/storage/patch/RepresentationPatcher'; +import { ConflictHttpError } from '../../../../src/util/errors/ConflictHttpError'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +const { namedNode, quad, variable } = DataFactory; + +describe('An N3Patcher', (): void => { + let patch: N3Patch; + let input: RepresentationPatcherInput; + const patcher = new N3Patcher(); + + beforeEach(async(): Promise => { + patch = new BasicRepresentation() as N3Patch; + patch.deletes = []; + patch.inserts = []; + patch.conditions = []; + + input = { + patch, + identifier: { path: 'http://example.com/foo' }, + }; + }); + + it('can only handle N3 Patches.', async(): Promise => { + await expect(patcher.canHandle(input)).resolves.toBeUndefined(); + input.patch = new BasicRepresentation() as N3Patch; + await expect(patcher.canHandle(input)).rejects.toThrow(NotImplementedHttpError); + }); + + it('returns an empty representation for an empty patch for new resources.', async(): Promise => { + patch.deletes = []; + patch.inserts = []; + patch.conditions = []; + const result = await patcher.handle(input); + expect(result.metadata.contentType).toBe('internal/quads'); + await expect(arrayifyStream(result.data)).resolves.toEqual([]); + }); + + it('returns the input representation for an empty patch.', async(): Promise => { + patch.deletes = []; + patch.inserts = []; + patch.conditions = []; + const representation = new BasicRepresentation([], 'internal/quads'); + input.representation = representation; + const result = await patcher.handle(input); + expect(result).toBe(representation); + }); + + it('errors if the input representation has the wrong content-type.', async(): Promise => { + // Just need a non-empty patch + patch.deletes = [ quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')) ]; + input.representation = new BasicRepresentation(); + await expect(patcher.handle(input)).rejects.toThrow('Quad stream was expected for patching.'); + }); + + it('can delete and insert triples.', async(): Promise => { + patch.deletes = [ quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')) ]; + patch.inserts = [ quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')) ]; + input.representation = new BasicRepresentation([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')), + ], 'internal/quads', false); + const result = await patcher.handle(input); + expect(result.metadata.contentType).toBe('internal/quads'); + await expect(arrayifyStream(result.data)).resolves.toBeRdfIsomorphic([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')), + ]); + }); + + it('can create new representations using insert.', async(): Promise => { + patch.inserts = [ quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')) ]; + const result = await patcher.handle(input); + expect(result.metadata.contentType).toBe('internal/quads'); + await expect(arrayifyStream(result.data)).resolves.toBeRdfIsomorphic([ + quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')), + ]); + }); + + it('can use conditions to target specific triples.', async(): Promise => { + patch.conditions = [ quad(variable('v'), namedNode('ex:p1'), namedNode('ex:o1')) ]; + patch.deletes = [ quad(variable('v'), namedNode('ex:p1'), namedNode('ex:o1')) ]; + patch.inserts = [ quad(variable('v'), namedNode('ex:p2'), namedNode('ex:o2')) ]; + input.representation = new BasicRepresentation([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')), + ], 'internal/quads', false); + const result = await patcher.handle(input); + expect(result.metadata.contentType).toBe('internal/quads'); + await expect(arrayifyStream(result.data)).resolves.toBeRdfIsomorphic([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + quad(namedNode('ex:s1'), namedNode('ex:p2'), namedNode('ex:o2')), + ]); + }); + + it('errors if the conditions find no match.', async(): Promise => { + patch.conditions = [ quad(variable('v'), namedNode('ex:p3'), namedNode('ex:o3')) ]; + input.representation = new BasicRepresentation([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')), + ], 'internal/quads', false); + const prom = patcher.handle(input); + await expect(prom).rejects.toThrow(ConflictHttpError); + await expect(prom).rejects.toThrow( + 'The document does not contain any matches for the N3 Patch solid:where condition.', + ); + }); + + it('errors if the conditions find multiple matches.', async(): Promise => { + patch.conditions = [ quad(variable('v'), namedNode('ex:p0'), namedNode('ex:o0')) ]; + input.representation = new BasicRepresentation([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + quad(namedNode('ex:s1'), namedNode('ex:p0'), namedNode('ex:o0')), + ], 'internal/quads', false); + const prom = patcher.handle(input); + await expect(prom).rejects.toThrow(ConflictHttpError); + await expect(prom).rejects.toThrow( + 'The document contains multiple matches for the N3 Patch solid:where condition, which is not allowed.', + ); + }); + + it('errors if the delete triples have no match.', async(): Promise => { + patch.deletes = [ quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')) ]; + input.representation = new BasicRepresentation([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + ], 'internal/quads', false); + const prom = patcher.handle(input); + await expect(prom).rejects.toThrow(ConflictHttpError); + await expect(prom).rejects.toThrow( + 'The document does not contain all triples the N3 Patch requests to delete, which is required for patching.', + ); + }); + + it('works correctly if there are duplicate delete triples.', async(): Promise => { + patch.conditions = [ quad(variable('v'), namedNode('ex:p1'), namedNode('ex:o1')) ]; + patch.deletes = [ + quad(variable('v'), namedNode('ex:p1'), namedNode('ex:o1')), + quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')), + ]; + input.representation = new BasicRepresentation([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')), + ], 'internal/quads', false); + const result = await patcher.handle(input); + expect(result.metadata.contentType).toBe('internal/quads'); + await expect(arrayifyStream(result.data)).resolves.toBeRdfIsomorphic([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + ]); + }); +}); diff --git a/test/unit/storage/size-reporter/FileSizeReporter.test.ts b/test/unit/storage/size-reporter/FileSizeReporter.test.ts new file mode 100644 index 000000000..8a3eff734 --- /dev/null +++ b/test/unit/storage/size-reporter/FileSizeReporter.test.ts @@ -0,0 +1,132 @@ +import { promises as fsPromises } from 'fs'; +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; +import type { FileIdentifierMapper, ResourceLink } from '../../../../src/storage/mapping/FileIdentifierMapper'; +import { FileSizeReporter } from '../../../../src/storage/size-reporter/FileSizeReporter'; +import { UNIT_BYTES } from '../../../../src/storage/size-reporter/Size'; +import { joinFilePath } from '../../../../src/util/PathUtil'; +import { mockFs } from '../../../util/Util'; + +jest.mock('fs'); + +describe('A FileSizeReporter', (): void => { + // Folder size is fixed to 4 in the mock + const folderSize = 4; + const mapper: jest.Mocked = { + mapFilePathToUrl: jest.fn(), + mapUrlToFilePath: jest.fn().mockImplementation((id: ResourceIdentifier): ResourceLink => ({ + filePath: id.path, + identifier: id, + isMetadata: false, + })), + }; + const fileRoot = joinFilePath(process.cwd(), '/test-folder/'); + const fileSizeReporter = new FileSizeReporter( + mapper, + fileRoot, + [ '^/\\.internal$' ], + ); + + beforeEach(async(): Promise => { + mockFs(fileRoot); + }); + + it('should work without the ignoreFolders constructor parameter.', async(): Promise => { + const tempFileSizeReporter = new FileSizeReporter( + mapper, + fileRoot, + ); + + const testFile = joinFilePath(fileRoot, '/test.txt'); + await fsPromises.writeFile(testFile, 'A'.repeat(20)); + + const result = tempFileSizeReporter.getSize({ path: testFile }); + await expect(result).resolves.toBeDefined(); + expect((await result).amount).toBe(20); + }); + + it('should report the right file size.', async(): Promise => { + const testFile = joinFilePath(fileRoot, '/test.txt'); + await fsPromises.writeFile(testFile, 'A'.repeat(20)); + + const result = fileSizeReporter.getSize({ path: testFile }); + await expect(result).resolves.toBeDefined(); + expect((await result).amount).toBe(20); + }); + + it('should work recursively.', async(): Promise => { + const containerFile = joinFilePath(fileRoot, '/test-folder-1/'); + await fsPromises.mkdir(containerFile, { recursive: true }); + const testFile = joinFilePath(containerFile, '/test.txt'); + await fsPromises.writeFile(testFile, 'A'.repeat(20)); + + const fileSize = fileSizeReporter.getSize({ path: testFile }); + const containerSize = fileSizeReporter.getSize({ path: containerFile }); + + await expect(fileSize).resolves.toEqual(expect.objectContaining({ amount: 20 })); + await expect(containerSize).resolves.toEqual(expect.objectContaining({ amount: 20 + folderSize })); + }); + + it('should not count files located in an ignored folder.', async(): Promise => { + const containerFile = joinFilePath(fileRoot, '/test-folder-2/'); + await fsPromises.mkdir(containerFile, { recursive: true }); + const testFile = joinFilePath(containerFile, '/test.txt'); + await fsPromises.writeFile(testFile, 'A'.repeat(20)); + + const internalContainerFile = joinFilePath(fileRoot, '/.internal/'); + await fsPromises.mkdir(internalContainerFile, { recursive: true }); + const internalTestFile = joinFilePath(internalContainerFile, '/test.txt'); + await fsPromises.writeFile(internalTestFile, 'A'.repeat(30)); + + const fileSize = fileSizeReporter.getSize({ path: testFile }); + const containerSize = fileSizeReporter.getSize({ path: containerFile }); + const rootSize = fileSizeReporter.getSize({ path: fileRoot }); + + const expectedFileSize = 20; + const expectedContainerSize = 20 + folderSize; + const expectedRootSize = expectedContainerSize + folderSize; + + await expect(fileSize).resolves.toEqual(expect.objectContaining({ amount: expectedFileSize })); + await expect(containerSize).resolves.toEqual(expect.objectContaining({ amount: expectedContainerSize })); + await expect(rootSize).resolves.toEqual(expect.objectContaining({ amount: expectedRootSize })); + }); + + it('should have the unit in its return value.', async(): Promise => { + const testFile = joinFilePath(fileRoot, '/test2.txt'); + await fsPromises.writeFile(testFile, 'A'.repeat(20)); + + const result = fileSizeReporter.getSize({ path: testFile }); + await expect(result).resolves.toBeDefined(); + expect((await result).unit).toBe(UNIT_BYTES); + }); + + it('getUnit() should return UNIT_BYTES.', (): void => { + expect(fileSizeReporter.getUnit()).toBe(UNIT_BYTES); + }); + + it('should return 0 when the size of a non existent file is requested.', async(): Promise => { + const result = fileSizeReporter.getSize({ path: joinFilePath(fileRoot, '/test.txt') }); + await expect(result).resolves.toEqual(expect.objectContaining({ amount: 0 })); + }); + + it('should calculate the chunk size correctly.', async(): Promise => { + const testString = 'testesttesttesttest==testtest'; + const result = fileSizeReporter.calculateChunkSize(testString); + await expect(result).resolves.toEqual(testString.length); + }); + + describe('estimateSize()', (): void => { + it('should return the content-length.', async(): Promise => { + const metadata = new RepresentationMetadata(); + metadata.contentLength = 100; + await expect(fileSizeReporter.estimateSize(metadata)).resolves.toBe(100); + }); + it( + 'should return undefined if no content-length is present in the metadata.', + async(): Promise => { + const metadata = new RepresentationMetadata(); + await expect(fileSizeReporter.estimateSize(metadata)).resolves.toBeUndefined(); + }, + ); + }); +}); diff --git a/test/unit/storage/validators/QuotaValidator.test.ts b/test/unit/storage/validators/QuotaValidator.test.ts new file mode 100644 index 000000000..e496f5842 --- /dev/null +++ b/test/unit/storage/validators/QuotaValidator.test.ts @@ -0,0 +1,120 @@ +import type { Readable } from 'stream'; +import { PassThrough } from 'stream'; +import type { ValidatorInput } from '../../../../src/http/auxiliary/Validator'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; +import type { QuotaStrategy } from '../../../../src/storage/quota/QuotaStrategy'; +import { UNIT_BYTES } from '../../../../src/storage/size-reporter/Size'; +import type { SizeReporter } from '../../../../src/storage/size-reporter/SizeReporter'; +import { QuotaValidator } from '../../../../src/storage/validators/QuotaValidator'; +import { guardStream } from '../../../../src/util/GuardedStream'; +import type { Guarded } from '../../../../src/util/GuardedStream'; +import { guardedStreamFrom, readableToString } from '../../../../src/util/StreamUtil'; + +describe('QuotaValidator', (): void => { + let mockedStrategy: jest.Mocked; + let validator: QuotaValidator; + let identifier: ResourceIdentifier; + let mockMetadata: RepresentationMetadata; + let mockData: Guarded; + let mockInput: ValidatorInput; + let mockReporter: jest.Mocked>; + + beforeEach((): void => { + jest.clearAllMocks(); + identifier = { path: 'http://localhost/' }; + mockMetadata = new RepresentationMetadata(); + mockData = guardedStreamFrom([ 'test string' ]); + mockInput = { + representation: new BasicRepresentation(mockData, mockMetadata), + identifier, + }; + mockReporter = { + getSize: jest.fn(), + getUnit: jest.fn(), + calculateChunkSize: jest.fn(), + estimateSize: jest.fn().mockResolvedValue(8), + }; + mockedStrategy = { + reporter: mockReporter, + limit: { unit: UNIT_BYTES, amount: 8 }, + getAvailableSpace: jest.fn().mockResolvedValue({ unit: UNIT_BYTES, amount: 10 }), + estimateSize: jest.fn().mockResolvedValue({ unit: UNIT_BYTES, amount: 8 }), + createQuotaGuard: jest.fn().mockResolvedValue(guardStream(new PassThrough())), + } as any; + validator = new QuotaValidator(mockedStrategy); + }); + + describe('handle()', (): void => { + // Step 2 + it('should destroy the stream when estimated size is larger than the available size.', async(): Promise => { + mockedStrategy.estimateSize.mockResolvedValueOnce({ unit: UNIT_BYTES, amount: 11 }); + + const result = validator.handle(mockInput); + await expect(result).resolves.toBeDefined(); + const awaitedResult = await result; + + const prom = new Promise((resolve, reject): void => { + awaitedResult.data.on('error', (): void => resolve()); + awaitedResult.data.on('end', (): void => reject(new Error('reject'))); + }); + + // Consume the stream + await expect(readableToString(awaitedResult.data)) + .rejects.toThrow('Quota exceeded: Advertised Content-Length is'); + await expect(prom).resolves.toBeUndefined(); + }); + + // Step 3 + it('should destroy the stream when quota is exceeded during write.', async(): Promise => { + mockedStrategy.createQuotaGuard.mockResolvedValueOnce(guardStream(new PassThrough({ + async transform(this): Promise { + this.destroy(new Error('error')); + }, + }))); + + const result = validator.handle(mockInput); + await expect(result).resolves.toBeDefined(); + const awaitedResult = await result; + + const prom = new Promise((resolve, reject): void => { + awaitedResult.data.on('error', (): void => resolve()); + awaitedResult.data.on('end', (): void => reject(new Error('reject'))); + }); + + // Consume the stream + await expect(readableToString(awaitedResult.data)).rejects.toThrow('error'); + expect(mockedStrategy.createQuotaGuard).toHaveBeenCalledTimes(1); + await expect(prom).resolves.toBeUndefined(); + }); + + // Step 4 + it('should throw when quota were exceeded after stream was finished.', async(): Promise => { + const result = validator.handle(mockInput); + + // Putting this after the handle / before consuming the stream will only effect + // this function in the flush part of the code. + mockedStrategy.getAvailableSpace.mockResolvedValueOnce({ unit: UNIT_BYTES, amount: -100 }); + + await expect(result).resolves.toBeDefined(); + const awaitedResult = await result; + + const prom = new Promise((resolve, reject): void => { + awaitedResult.data.on('error', (): void => resolve()); + awaitedResult.data.on('end', (): void => reject(new Error('reject'))); + }); + + // Consume the stream + await expect(readableToString(awaitedResult.data)).rejects.toThrow('Quota exceeded after write completed'); + await expect(prom).resolves.toBeUndefined(); + }); + + it('should return a stream that is consumable without error if quota isn\'t exceeded.', async(): Promise => { + const result = validator.handle(mockInput); + await expect(result).resolves.toBeDefined(); + const awaitedResult = await result; + await expect(readableToString(awaitedResult.data)).resolves.toBe('test string'); + }); + }); +}); diff --git a/test/unit/util/FetchUtil.test.ts b/test/unit/util/FetchUtil.test.ts index d08fa4c22..16e0d9782 100644 --- a/test/unit/util/FetchUtil.test.ts +++ b/test/unit/util/FetchUtil.test.ts @@ -1,55 +1,81 @@ +import { Readable } from 'stream'; +import type { Quad } from '@rdfjs/types'; import arrayifyStream from 'arrayify-stream'; -import { fetch } from 'cross-fetch'; +import type { Response } from 'cross-fetch'; import { DataFactory } from 'n3'; +import rdfDereferencer from 'rdf-dereference'; import { RdfToQuadConverter } from '../../../src/storage/conversion/RdfToQuadConverter'; -import { fetchDataset } from '../../../src/util/FetchUtil'; +import { fetchDataset, responseToDataset } from '../../../src/util/FetchUtil'; const { namedNode, quad } = DataFactory; -jest.mock('cross-fetch'); +jest.mock('rdf-dereference', (): any => ({ + dereference: jest.fn(), +})); describe('FetchUtil', (): void => { - describe('#fetchDataset', (): void => { - const fetchMock: jest.Mock = fetch as any; - const url = 'http://test.com/foo'; - const converter = new RdfToQuadConverter(); + const url = 'http://test.com/foo'; - function mockFetch(body: string, status = 200): void { - fetchMock.mockImplementation((input: string): any => ({ - text: (): any => body, - url: input, - status, - headers: { get: (): any => 'text/turtle' }, - })); + function mockResponse(body: string, contentType: string | null, status = 200): Response { + return ({ + text: (): any => body, + url, + status, + headers: { get: (): any => contentType }, + }) as any; + } + + describe('#fetchDataset', (): void => { + const rdfDereferenceMock: jest.Mocked = rdfDereferencer as any; + + function mockDereference(quads?: Quad[]): any { + rdfDereferenceMock.dereference.mockImplementation((uri: string): any => { + if (!quads) { + throw new Error('Throws error because url does not exist'); + } + return { + uri, + quads: Readable.from(quads), + exists: true, + }; + }); } - it('errors if the status code is not 200.', async(): Promise => { - mockFetch('Invalid URL!', 404); - await expect(fetchDataset(url, converter)).rejects.toThrow(`Unable to access data at ${url}`); - expect(fetchMock).toHaveBeenCalledWith(url); - }); - - it('errors if there is no content-type.', async(): Promise => { - fetchMock.mockResolvedValueOnce({ url, text: (): any => '', status: 200, headers: { get: jest.fn() }}); - await expect(fetchDataset(url, converter)).rejects.toThrow(`Unable to access data at ${url}`); - expect(fetchMock).toHaveBeenCalledWith(url); + it('errors if the URL does not exist.', async(): Promise => { + mockDereference(); + await expect(fetchDataset(url)).rejects.toThrow(`Could not parse resource at URL (${url})!`); + expect(rdfDereferenceMock.dereference).toHaveBeenCalledWith(url); }); it('returns a Representation with quads.', async(): Promise => { - mockFetch(' .'); - const representation = await fetchDataset(url, converter); - await expect(arrayifyStream(representation.data)).resolves.toEqual([ - quad(namedNode('http://test.com/s'), namedNode('http://test.com/p'), namedNode('http://test.com/o')), - ]); - }); - - it('accepts Response objects as input.', async(): Promise => { - mockFetch(' .'); - const response = await fetch(url); - const body = await response.text(); - const representation = await fetchDataset(response, converter, body); + const quads = [ quad(namedNode('http://test.com/s'), namedNode('http://test.com/p'), namedNode('http://test.com/o')) ]; + mockDereference(quads); + const representation = await fetchDataset(url); await expect(arrayifyStream(representation.data)).resolves.toEqual([ quad(namedNode('http://test.com/s'), namedNode('http://test.com/p'), namedNode('http://test.com/o')), ]); }); }); + + describe('#responseToDataset', (): void => { + const converter = new RdfToQuadConverter(); + + it('accepts Response objects as input.', async(): Promise => { + const response = mockResponse(' .', 'text/turtle'); + const body = await response.text(); + const representation = await responseToDataset(response, converter, body); + await expect(arrayifyStream(representation.data)).resolves.toEqual([ + quad(namedNode('http://test.com/s'), namedNode('http://test.com/p'), namedNode('http://test.com/o')), + ]); + }); + + it('errors if the status code is not 200.', async(): Promise => { + const response = mockResponse('Incorrect status!', null, 400); + await expect(responseToDataset(response, converter)).rejects.toThrow(`Unable to access data at ${url}`); + }); + + it('errors if there is no content-type.', async(): Promise => { + const response = mockResponse('No content-type!', null); + await expect(responseToDataset(response, converter)).rejects.toThrow(`Unable to access data at ${url}`); + }); + }); }); diff --git a/test/unit/util/PathUtil.test.ts b/test/unit/util/PathUtil.test.ts index 0d9e3c7cc..d809d20b0 100644 --- a/test/unit/util/PathUtil.test.ts +++ b/test/unit/util/PathUtil.test.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'fs'; +import { promises as fsPromises } from 'fs'; import type { TargetExtractor } from '../../../src/http/input/identifier/TargetExtractor'; import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; import type { HttpRequest } from '../../../src/server/HttpRequest'; @@ -16,49 +16,51 @@ import { isContainerPath, joinFilePath, joinUrl, + modulePath, normalizeFilePath, resolveAssetPath, + resolveModulePath, toCanonicalUriPath, trimTrailingSlashes, } from '../../../src/util/PathUtil'; describe('PathUtil', (): void => { describe('#normalizeFilePath', (): void => { - it('normalizes POSIX paths.', async(): Promise => { + it('normalizes POSIX paths.', (): void => { expect(normalizeFilePath('/foo/bar/../baz')).toBe('/foo/baz'); }); - it('normalizes Windows paths.', async(): Promise => { + it('normalizes Windows paths.', (): void => { expect(normalizeFilePath('c:\\foo\\bar\\..\\baz')).toBe('c:/foo/baz'); }); }); describe('#joinFilePath', (): void => { - it('joins POSIX paths.', async(): Promise => { + it('joins POSIX paths.', (): void => { expect(joinFilePath('/foo/bar/', '..', '/baz')).toBe('/foo/baz'); }); - it('joins Windows paths.', async(): Promise => { + it('joins Windows paths.', (): void => { expect(joinFilePath('c:\\foo\\bar\\', '..', '/baz')).toBe(`c:/foo/baz`); }); }); describe('#absoluteFilePath', (): void => { - it('does not change absolute posix paths.', async(): Promise => { + it('does not change absolute posix paths.', (): void => { expect(absoluteFilePath('/foo/bar/')).toBe('/foo/bar/'); }); - it('converts absolute win32 paths to posix paths.', async(): Promise => { + it('converts absolute win32 paths to posix paths.', (): void => { expect(absoluteFilePath('C:\\foo\\bar')).toBe('C:/foo/bar'); }); - it('makes relative paths absolute.', async(): Promise => { + it('makes relative paths absolute.', (): void => { expect(absoluteFilePath('foo/bar/')).toEqual(joinFilePath(process.cwd(), 'foo/bar/')); }); }); describe('#ensureTrailingSlash', (): void => { - it('makes sure there is always exactly 1 slash.', async(): Promise => { + it('makes sure there is always exactly 1 slash.', (): void => { expect(ensureTrailingSlash('http://test.com')).toBe('http://test.com/'); expect(ensureTrailingSlash('http://test.com/')).toBe('http://test.com/'); expect(ensureTrailingSlash('http://test.com//')).toBe('http://test.com/'); @@ -67,7 +69,7 @@ describe('PathUtil', (): void => { }); describe('#trimTrailingSlashes', (): void => { - it('removes all trailing slashes.', async(): Promise => { + it('removes all trailing slashes.', (): void => { expect(trimTrailingSlashes('http://test.com')).toBe('http://test.com'); expect(trimTrailingSlashes('http://test.com/')).toBe('http://test.com'); expect(trimTrailingSlashes('http://test.com//')).toBe('http://test.com'); @@ -76,58 +78,58 @@ describe('PathUtil', (): void => { }); describe('#getExtension', (): void => { - it('returns the extension of a path.', async(): Promise => { + it('returns the extension of a path.', (): void => { expect(getExtension('/a/b.txt')).toBe('txt'); expect(getExtension('/a/btxt')).toBe(''); }); }); describe('#toCanonicalUriPath', (): void => { - it('encodes only the necessary parts.', async(): Promise => { + it('encodes only the necessary parts.', (): void => { expect(toCanonicalUriPath('/a%20path&/name')).toBe('/a%20path%26/name'); }); - it('leaves the query string untouched.', async(): Promise => { + it('leaves the query string untouched.', (): void => { expect(toCanonicalUriPath('/a%20path&/name?abc=def&xyz')).toBe('/a%20path%26/name?abc=def&xyz'); }); }); describe('#decodeUriPathComponents', (): void => { - it('decodes all parts of a path.', async(): Promise => { + it('decodes all parts of a path.', (): void => { expect(decodeUriPathComponents('/a%20path&/name')).toBe('/a path&/name'); }); - it('leaves the query string untouched.', async(): Promise => { + it('leaves the query string untouched.', (): void => { expect(decodeUriPathComponents('/a%20path&/name?abc=def&xyz')).toBe('/a path&/name?abc=def&xyz'); }); }); describe('#encodeUriPathComponents', (): void => { - it('encodes all parts of a path.', async(): Promise => { + it('encodes all parts of a path.', (): void => { expect(encodeUriPathComponents('/a%20path&/name')).toBe('/a%2520path%26/name'); }); - it('leaves the query string untouched.', async(): Promise => { + it('leaves the query string untouched.', (): void => { expect(encodeUriPathComponents('/a%20path&/name?abc=def&xyz')).toBe('/a%2520path%26/name?abc=def&xyz'); }); }); describe('#isContainerPath', (): void => { - it('returns true if the path ends with a slash.', async(): Promise => { + it('returns true if the path ends with a slash.', (): void => { expect(isContainerPath('/a/b')).toBe(false); expect(isContainerPath('/a/b/')).toBe(true); }); }); describe('#isContainerIdentifier', (): void => { - it('works af isContainerPath but for identifiers.', async(): Promise => { + it('works af isContainerPath but for identifiers.', (): void => { expect(isContainerIdentifier({ path: '/a/b' })).toBe(false); expect(isContainerIdentifier({ path: '/a/b/' })).toBe(true); }); }); describe('#extractScheme', (): void => { - it('splits a URL.', async(): Promise => { + it('splits a URL.', (): void => { expect(extractScheme('http://test.com/foo')).toEqual({ scheme: 'http://', rest: 'test.com/foo' }); }); }); @@ -155,7 +157,7 @@ describe('PathUtil', (): void => { }); describe('#createSubdomainRegexp', (): void => { - it('creates a regex to match the URL and extract a subdomain.', async(): Promise => { + it('creates a regex to match the URL and extract a subdomain.', (): void => { const regex = createSubdomainRegexp('http://test.com/foo/'); expect(regex.exec('http://test.com/foo/')![1]).toBeUndefined(); expect(regex.exec('http://test.com/foo/bar')![1]).toBeUndefined(); @@ -171,28 +173,48 @@ describe('PathUtil', (): void => { // Note that this test only makes sense as long as the dist folder is on the same level as the src folder const root = getModuleRoot(); const packageJson = joinFilePath(root, 'package.json'); - expect(existsSync(packageJson)).toBe(true); + expect(await fsPromises.access(packageJson)).toBeUndefined(); }); }); - describe('#resolvePathInput', (): void => { - it('interprets paths relative to the module root when starting with @css:.', async(): Promise => { + describe('#modulePath', (): void => { + it('transforms the empty input into "@css:".', (): void => { + expect(modulePath()).toBe('@css:'); + }); + + it('prefixes a path with "@css".', (): void => { + expect(modulePath('foo/bar.json')).toBe('@css:foo/bar.json'); + }); + }); + + describe('#resolveModulePath', (): void => { + it('transforms the empty input into the module root path.', (): void => { + expect(resolveModulePath()).toBe(getModuleRoot()); + }); + + it('prefixes a path with the module root path.', (): void => { + expect(resolveModulePath('foo/bar.json')).toBe(`${getModuleRoot()}foo/bar.json`); + }); + }); + + describe('#resolveAssetPath', (): void => { + it('interprets paths relative to the module root when starting with "@css:".', (): void => { expect(resolveAssetPath('@css:foo/bar')).toBe(joinFilePath(getModuleRoot(), '/foo/bar')); }); - it('handles ../ paths with @css:.', async(): Promise => { + it('handles ../ paths with "@css":.', (): void => { expect(resolveAssetPath('@css:foo/bar/../baz')).toBe(joinFilePath(getModuleRoot(), '/foo/baz')); }); - it('leaves absolute paths as they are.', async(): Promise => { + it('leaves absolute paths as they are.', (): void => { expect(resolveAssetPath('/foo/bar/')).toBe('/foo/bar/'); }); - it('handles other paths relative to the cwd.', async(): Promise => { + it('handles other paths relative to the cwd.', (): void => { expect(resolveAssetPath('foo/bar/')).toBe(joinFilePath(process.cwd(), 'foo/bar/')); }); - it('handles other paths with ../.', async(): Promise => { + it('handles other paths with ../.', (): void => { expect(resolveAssetPath('foo/bar/../baz')).toBe(joinFilePath(process.cwd(), 'foo/baz')); }); }); diff --git a/test/unit/util/QuadUtil.test.ts b/test/unit/util/QuadUtil.test.ts index 8be63c58c..26856960c 100644 --- a/test/unit/util/QuadUtil.test.ts +++ b/test/unit/util/QuadUtil.test.ts @@ -1,6 +1,6 @@ import 'jest-rdf'; import { DataFactory } from 'n3'; -import { parseQuads, serializeQuads } from '../../../src/util/QuadUtil'; +import { parseQuads, serializeQuads, uniqueQuads } from '../../../src/util/QuadUtil'; import { guardedStreamFrom, readableToString } from '../../../src/util/StreamUtil'; const { literal, namedNode, quad } = DataFactory; @@ -36,4 +36,18 @@ describe('QuadUtil', (): void => { ) ]); }); }); + + describe('#uniqueQuads', (): void => { + it('filters out duplicate quads.', async(): Promise => { + const quads = [ + quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')), + quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')), + quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')), + ]; + expect(uniqueQuads(quads)).toBeRdfIsomorphic([ + quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')), + quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')), + ]); + }); + }); }); diff --git a/test/unit/util/errors/HttpError.test.ts b/test/unit/util/errors/HttpError.test.ts index 5853df215..7847bfea4 100644 --- a/test/unit/util/errors/HttpError.test.ts +++ b/test/unit/util/errors/HttpError.test.ts @@ -7,8 +7,10 @@ import { InternalServerError } from '../../../../src/util/errors/InternalServerE import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError'; import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import { PayloadHttpError } from '../../../../src/util/errors/PayloadHttpError'; import { PreconditionFailedHttpError } from '../../../../src/util/errors/PreconditionFailedHttpError'; import { UnauthorizedHttpError } from '../../../../src/util/errors/UnauthorizedHttpError'; +import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError'; import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError'; // Only used to make typings easier in the tests @@ -27,7 +29,9 @@ describe('HttpError', (): void => { [ 'MethodNotAllowedHttpError', 405, MethodNotAllowedHttpError ], [ 'ConflictHttpError', 409, ConflictHttpError ], [ 'PreconditionFailedHttpError', 412, PreconditionFailedHttpError ], + [ 'PayloadHttpError', 413, PayloadHttpError ], [ 'UnsupportedMediaTypeHttpError', 415, UnsupportedMediaTypeHttpError ], + [ 'UnprocessableEntityHttpError', 422, UnprocessableEntityHttpError ], [ 'InternalServerError', 500, InternalServerError ], [ 'NotImplementedHttpError', 501, NotImplementedHttpError ], ]; diff --git a/test/unit/util/errors/RedirectHttpError.test.ts b/test/unit/util/errors/RedirectHttpError.test.ts new file mode 100644 index 000000000..5536c86fc --- /dev/null +++ b/test/unit/util/errors/RedirectHttpError.test.ts @@ -0,0 +1,63 @@ +import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; +import type { HttpErrorOptions } from '../../../../src/util/errors/HttpError'; +import { MovedPermanentlyHttpError } from '../../../../src/util/errors/MovedPermanentlyHttpError'; +import { RedirectHttpError } from '../../../../src/util/errors/RedirectHttpError'; + +class FixedRedirectHttpError extends RedirectHttpError { + public constructor(location: string, message?: string, options?: HttpErrorOptions) { + super(0, location, '', message, options); + } +} + +describe('RedirectHttpError', (): void => { + const errors: [string, number, typeof FixedRedirectHttpError][] = [ + [ 'MovedPermanentlyHttpError', 301, MovedPermanentlyHttpError ], + [ 'FoundHttpError', 302, FoundHttpError ], + ]; + + describe.each(errors)('%s', (name, statusCode, constructor): void => { + const location = 'http://test.com/foo/bar'; + const options = { + cause: new Error('cause'), + errorCode: 'E1234', + details: {}, + }; + const instance = new constructor(location, 'my message', options); + + it(`is an instance of ${name}.`, (): void => { + expect(constructor.isInstance(instance)).toBeTruthy(); + }); + + it(`has name ${name}.`, (): void => { + expect(instance.name).toBe(name); + }); + + it(`has status code ${statusCode}.`, (): void => { + expect(instance.statusCode).toBe(statusCode); + }); + + it('sets the location.', (): void => { + expect(instance.location).toBe(location); + }); + + it('sets the message.', (): void => { + expect(instance.message).toBe('my message'); + }); + + it('sets the cause.', (): void => { + expect(instance.cause).toBe(options.cause); + }); + + it('sets the error code.', (): void => { + expect(instance.errorCode).toBe(options.errorCode); + }); + + it('defaults to an HTTP-specific error code.', (): void => { + expect(new constructor(location).errorCode).toBe(`H${statusCode}`); + }); + + it('sets the details.', (): void => { + expect(instance.details).toBe(options.details); + }); + }); +}); diff --git a/test/unit/util/handlers/MethodFilterHandler.test.ts b/test/unit/util/handlers/MethodFilterHandler.test.ts new file mode 100644 index 000000000..8f688144c --- /dev/null +++ b/test/unit/util/handlers/MethodFilterHandler.test.ts @@ -0,0 +1,63 @@ +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import type { AsyncHandler } from '../../../../src/util/handlers/AsyncHandler'; +import { + MethodFilterHandler, +} from '../../../../src/util/handlers/MethodFilterHandler'; + +describe('A MethodFilterHandler', (): void => { + const modes = [ 'PATCH', 'POST' ]; + const result = 'RESULT'; + let operation: Operation; + let source: jest.Mocked>; + let handler: MethodFilterHandler; + + beforeEach(async(): Promise => { + operation = { + method: 'PATCH', + preferences: {}, + permissionSet: {}, + target: { path: 'http://example.com/foo' }, + body: new BasicRepresentation(), + }; + + source = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue(result), + } as any; + + handler = new MethodFilterHandler(modes, source); + }); + + it('rejects unknown methods.', async(): Promise => { + operation.method = 'GET'; + await expect(handler.canHandle(operation)).rejects.toThrow(NotImplementedHttpError); + }); + + it('checks if the source handle supports the request.', async(): Promise => { + operation.method = 'PATCH'; + await expect(handler.canHandle(operation)).resolves.toBeUndefined(); + operation.method = 'POST'; + await expect(handler.canHandle(operation)).resolves.toBeUndefined(); + source.canHandle.mockRejectedValueOnce(new Error('not supported')); + await expect(handler.canHandle(operation)).rejects.toThrow('not supported'); + expect(source.canHandle).toHaveBeenLastCalledWith(operation); + }); + + it('supports multiple object formats.', async(): Promise => { + let input: any = { method: 'PATCH' }; + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + input = { operation: { method: 'PATCH' }}; + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + input = { request: { method: 'PATCH' }}; + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + input = { unknown: { method: 'PATCH' }}; + await expect(handler.canHandle(input)).rejects.toThrow('Could not find method in input object.'); + }); + + it('calls the source extractor.', async(): Promise => { + await expect(handler.handle(operation)).resolves.toBe(result); + expect(source.handle).toHaveBeenLastCalledWith(operation); + }); +}); diff --git a/test/unit/util/handlers/StaticThrowHandler.test.ts b/test/unit/util/handlers/StaticThrowHandler.test.ts new file mode 100644 index 000000000..3fc561558 --- /dev/null +++ b/test/unit/util/handlers/StaticThrowHandler.test.ts @@ -0,0 +1,15 @@ +import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; +import { StaticThrowHandler } from '../../../../src/util/handlers/StaticThrowHandler'; + +describe('A StaticThrowHandler', (): void => { + const error = new BadRequestHttpError(); + const handler = new StaticThrowHandler(error); + + it('can handle all requests.', async(): Promise => { + await expect(handler.canHandle({})).resolves.toBeUndefined(); + }); + + it('always throws the given error.', async(): Promise => { + await expect(handler.handle()).rejects.toThrow(error); + }); +}); diff --git a/test/unit/util/locking/VoidLocker.test.ts b/test/unit/util/locking/VoidLocker.test.ts new file mode 100644 index 000000000..7bb5c0885 --- /dev/null +++ b/test/unit/util/locking/VoidLocker.test.ts @@ -0,0 +1,27 @@ +import type { ResourceIdentifier } from '../../../../src'; +import { VoidLocker } from '../../../../src/util/locking/VoidLocker'; + +describe('A VoidLocker', (): void => { + it('invokes the whileLocked function immediately with readLock.', async(): Promise => { + const locker = new VoidLocker(); + const identifier: ResourceIdentifier = { path: 'http://test.com/res' }; + const whileLocked = jest.fn().mockImplementation((maintainLockFn: () => void): void => { + maintainLockFn(); + }); + + await locker.withReadLock(identifier, whileLocked); + + expect(whileLocked).toHaveBeenCalledTimes(1); + }); + + it('invokes the whileLocked function immediately with writeLock.', async(): Promise => { + const locker = new VoidLocker(); + const identifier: ResourceIdentifier = { path: 'http://test.com/res' }; + const whileLocked = jest.fn().mockImplementation((maintainLockFn: () => void): void => { + maintainLockFn(); + }); + await locker.withWriteLock(identifier, whileLocked); + + expect(whileLocked).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/util/FetchUtil.ts b/test/util/FetchUtil.ts index 31715c187..358470401 100644 --- a/test/util/FetchUtil.ts +++ b/test/util/FetchUtil.ts @@ -17,7 +17,7 @@ export async function getResource(url: string, expect(response.status).toBe(200); expect(response.headers.get('link')).toContain(`<${LDP.Resource}>; rel="type"`); expect(response.headers.get('link')).toContain(`<${url}.acl>; rel="acl"`); - expect(response.headers.get('accept-patch')).toBe('application/sparql-update'); + expect(response.headers.get('accept-patch')).toBe('application/sparql-update, text/n3'); expect(response.headers.get('ms-author-via')).toBe('SPARQL'); if (isContainer) { diff --git a/test/util/Util.ts b/test/util/Util.ts index 8acc2401e..4003d741f 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -5,11 +5,13 @@ import type { SystemError } from '../../src/util/errors/SystemError'; const portNames = [ // Integration 'Conditions', + 'ContentNegotiation', 'DynamicPods', 'Identity', 'LpdHandlerWithAuth', 'LpdHandlerWithoutAuth', 'Middleware', + 'N3Patch', 'PodCreation', 'RedisResourceLocker', 'RestrictedIdentity', @@ -18,6 +20,8 @@ const portNames = [ 'SparqlStorage', 'Subdomains', 'WebSocketsProtocol', + 'PodQuota', + 'GlobalQuota', // Unit 'BaseHttpServerFactory', ] as const; @@ -121,7 +125,7 @@ export function mockFs(rootFilepath?: string, time?: Date): { data: any } { isFile: (): boolean => typeof folder[name] === 'string', isDirectory: (): boolean => typeof folder[name] === 'object', isSymbolicLink: (): boolean => typeof folder[name] === 'symbol', - size: typeof folder[name] === 'string' ? folder[name].length : 0, + size: typeof folder[name] === 'string' ? folder[name].length : 4, mtime: time, } as Stats; }, @@ -198,6 +202,21 @@ export function mockFs(rootFilepath?: string, time?: Date): { data: any } { const { folder, name } = getFolder(path); folder[name] = data; }, + async rename(path: string, destination: string): Promise { + const { folder, name } = getFolder(path); + if (!folder[name]) { + throwSystemError('ENOENT'); + } + if (!(await this.lstat(path)).isFile()) { + throwSystemError('EISDIR'); + } + + const { folder: folderDest, name: nameDest } = getFolder(destination); + folderDest[nameDest] = folder[name]; + + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete folder[name]; + }, }, };