Skip to content

Commit

Permalink
feat: Simplify setup to be more in line with IDP behaviour
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Feb 11, 2022
1 parent 129d3c0 commit 9577791
Show file tree
Hide file tree
Showing 10 changed files with 418 additions and 431 deletions.
27 changes: 22 additions & 5 deletions config/app/setup/handlers/setup.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
}
},
{
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ export * from './init/final/Finalizable';
export * from './init/final/ParallelFinalizer';

// Init/Setup
export * from './init/setup/SetupHandler';
export * from './init/setup/SetupHttpHandler';

// Init/Cli
Expand Down
83 changes: 83 additions & 0 deletions src/init/setup/SetupHandler.ts
Original file line number Diff line number Diff line change
@@ -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<Representation> {
const json = operation.body.isEmpty ? {} : await readJsonStream(operation.body.data);

const output: Record<string, any> = { 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<void> {
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<any>): Promise<Record<string, any>> {
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);
}
}
177 changes: 40 additions & 137 deletions src/init/setup/SetupHttpHandler.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>{
/**
* 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.
* Used for converting the input data.
*/
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;
converter: RepresentationConverter;
/**
* Used for content negotiation.
* Handles the requests.
*/
converter: RepresentationConverter;
handler: InteractionHandler;
/**
* Key that is used to store the boolean in the storage indicating setup is finished.
*/
Expand All @@ -59,17 +30,9 @@ export interface SetupHttpHandlerArgs {
*/
storage: KeyValueStorage<string, boolean>;
/**
* Template to use for GET requests.
*/
viewTemplate: string;
/**
* Template to show when setup was completed successfully.
*/
responseTemplate: string;
/**
* Used for converting output errors.
* Renders the main view.
*/
errorHandler: ErrorHandler;
templateEngine: TemplateEngine;
}

/**
Expand All @@ -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<string, boolean>;
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<ResponseDescription> {
let json: Record<string, any>;
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;
}

// 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);
switch (operation.method) {
case 'GET': return this.handleGet(operation);
case 'POST': return this.handlePost(operation);
default: throw new MethodNotAllowedHttpError();
}

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<string, any>; 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<ResponseDescription> {
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<ResponseDescription> {
// 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)}`);
}

// 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();
}

let output: Record<string, any> = {};
// 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);
operation = {
...operation,
body: await this.converter.handleSafe(args),
};
}

// Add extra setup metadata
output.initialize = Boolean(json.initialize);
output.registration = Boolean(json.registration);
this.logger.debug(`Output: ${JSON.stringify(output)}`);
const representation = await this.handler.handleSafe({ operation });
await this.storage.set(this.storageKey, true);

return { json: output, template: this.responseTemplate };
return new OkResponseDescription(representation.metadata, representation.data);
}
}
Loading

0 comments on commit 9577791

Please sign in to comment.