diff --git a/config/app/init/initializers/prefilled-root.json b/config/app/init/initializers/prefilled-root.json index ab25afa90..c512aa2e7 100644 --- a/config/app/init/initializers/prefilled-root.json +++ b/config/app/init/initializers/prefilled-root.json @@ -4,14 +4,23 @@ { "comment": "Makes sure the root container exists and contains the necessary resources.", "@id": "urn:solid-server:default:RootInitializer", - "@type": "RootInitializer", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "store": { "@id": "urn:solid-server:default:ResourceStore" }, - "generator": { - "@type": "TemplatedResourcesGenerator", - "templateFolder": "@css:templates/root/prefilled", - "factory": { "@type": "ExtensionBasedMapperFactory" }, - "templateEngine": { "@type": "HandlebarsTemplateEngine" } + "@type": "ConditionalHandler", + "storageKey": "rootInitialized", + "storageValue": true, + "storage": { "@id": "urn:solid-server:default:SetupStorage" }, + "source": { + "@type": "ContainerInitializer", + "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "args_path": "/", + "args_store": { "@id": "urn:solid-server:default:ResourceStore" }, + "args_generator": { + "@type": "TemplatedResourcesGenerator", + "templateFolder": "@css:templates/root/prefilled", + "factory": { "@type": "ExtensionBasedMapperFactory" }, + "templateEngine": { "@type": "HandlebarsTemplateEngine" } + }, + "args_storageKey": "rootInitialized", + "args_storage": { "@id": "urn:solid-server:default:SetupStorage" } } } ] diff --git a/config/app/init/initializers/root.json b/config/app/init/initializers/root.json index 852c44771..e21895562 100644 --- a/config/app/init/initializers/root.json +++ b/config/app/init/initializers/root.json @@ -4,14 +4,23 @@ { "comment": "Makes sure the root container exists and contains the necessary resources.", "@id": "urn:solid-server:default:RootInitializer", - "@type": "RootInitializer", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "store": { "@id": "urn:solid-server:default:ResourceStore" }, - "generator": { - "@type": "TemplatedResourcesGenerator", - "templateFolder": "@css:templates/root/empty", - "factory": { "@type": "ExtensionBasedMapperFactory" }, - "templateEngine": { "@type": "HandlebarsTemplateEngine" } + "@type": "ConditionalHandler", + "storageKey": "rootInitialized", + "storageValue": true, + "storage": { "@id": "urn:solid-server:default:SetupStorage" }, + "source": { + "@type": "ContainerInitializer", + "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "args_path": "/", + "args_store": { "@id": "urn:solid-server:default:ResourceStore" }, + "args_generator": { + "@type": "TemplatedResourcesGenerator", + "templateFolder": "@css:templates/root/empty", + "factory": { "@type": "ExtensionBasedMapperFactory" }, + "templateEngine": { "@type": "HandlebarsTemplateEngine" } + }, + "args_storageKey": "rootInitialized", + "args_storage": { "@id": "urn:solid-server:default:SetupStorage" } } } ] diff --git a/src/index.ts b/src/index.ts index 8d24f7425..217dd2579 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,9 +82,9 @@ export * from './init/setup/SetupHttpHandler'; export * from './init/App'; export * from './init/AppRunner'; export * from './init/ConfigPodInitializer'; +export * from './init/ContainerInitializer'; export * from './init/Initializer'; export * from './init/LoggerInitializer'; -export * from './init/RootInitializer'; export * from './init/ServerInitializer'; // LDP/Authorization diff --git a/src/init/ContainerInitializer.ts b/src/init/ContainerInitializer.ts new file mode 100644 index 000000000..57dd38702 --- /dev/null +++ b/src/init/ContainerInitializer.ts @@ -0,0 +1,76 @@ +import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; +import { getLoggerFor } from '../logging/LogUtil'; +import type { ResourcesGenerator } from '../pods/generate/ResourcesGenerator'; +import type { KeyValueStorage } from '../storage/keyvalue/KeyValueStorage'; +import type { ResourceStore } from '../storage/ResourceStore'; +import { createErrorMessage } from '../util/errors/ErrorUtil'; +import { ensureTrailingSlash, joinUrl } from '../util/PathUtil'; +import { Initializer } from './Initializer'; + +export interface ContainerInitializerArgs { + /** + * Base URL of the server. + */ + baseUrl: string; + /** + * Relative path of the container. + */ + path: string; + /** + * ResourceStore where the container should be stored. + */ + store: ResourceStore; + /** + * Generator that should be used to generate container contents. + */ + generator: ResourcesGenerator; + /** + * Key that is used to store the boolean in the storage indicating the container is initialized. + */ + storageKey: string; + /** + * Used to store initialization status. + */ + storage: KeyValueStorage; +} + +/** + * Initializer that sets up a container. + * Will copy all the files and folders in the given path to the corresponding documents and containers. + */ +export class ContainerInitializer extends Initializer { + protected readonly logger = getLoggerFor(this); + + private readonly store: ResourceStore; + private readonly containerId: ResourceIdentifier; + private readonly generator: ResourcesGenerator; + private readonly storageKey: string; + private readonly storage: KeyValueStorage; + + public constructor(args: ContainerInitializerArgs) { + super(); + this.containerId = { path: ensureTrailingSlash(joinUrl(args.baseUrl, args.path)) }; + this.store = args.store; + this.generator = args.generator; + this.storageKey = args.storageKey; + this.storage = args.storage; + } + + public async handle(): Promise { + this.logger.info(`Initializing container ${this.containerId.path}`); + const resources = this.generator.generate(this.containerId, {}); + let count = 0; + for await (const { identifier: resourceId, representation } of resources) { + try { + await this.store.setRepresentation(resourceId, representation); + count += 1; + } catch (error: unknown) { + this.logger.warn(`Failed to create resource ${resourceId.path}: ${createErrorMessage(error)}`); + } + } + this.logger.info(`Initialized container ${this.containerId.path} with ${count} resources.`); + + // Mark the initialization as finished + await this.storage.set(this.storageKey, true); + } +} diff --git a/src/init/RootInitializer.ts b/src/init/RootInitializer.ts deleted file mode 100644 index fba350147..000000000 --- a/src/init/RootInitializer.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; -import { getLoggerFor } from '../logging/LogUtil'; -import type { ResourcesGenerator } from '../pods/generate/ResourcesGenerator'; -import type { ResourceStore } from '../storage/ResourceStore'; -import { createErrorMessage } from '../util/errors/ErrorUtil'; -import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; -import { ensureTrailingSlash } from '../util/PathUtil'; -import { PIM, RDF } from '../util/Vocabularies'; -import { Initializer } from './Initializer'; - -/** - * Initializer that sets up the root container. - * Will copy all the files and folders in the given path to the corresponding documents and containers. - * This will always happen when the server starts unless the following 2 conditions are both fulfilled: - * * The container already exists. - * * The container has metadata indicating it is a pim:Storage. - * - * It is important that the ResourcesGenerator generates a `<> a pim:Storage` triple for the root container: - * this prevents deletion of the root container as storage root containers can not be deleted. - * Solid, §4.1: "Servers exposing the storage resource MUST advertise by including the HTTP Link header - * with rel="type" targeting http://www.w3.org/ns/pim/space#Storage when responding to storage’s request URI." - * https://solid.github.io/specification/protocol#storage - */ -export class RootInitializer extends Initializer { - protected readonly logger = getLoggerFor(this); - - private readonly store: ResourceStore; - private readonly baseId: ResourceIdentifier; - private readonly generator: ResourcesGenerator; - - public constructor(baseUrl: string, store: ResourceStore, generator: ResourcesGenerator) { - super(); - this.baseId = { path: ensureTrailingSlash(baseUrl) }; - this.store = store; - this.generator = generator; - } - - public async handle(): Promise { - this.logger.debug(`Checking for valid root container at ${this.baseId.path}`); - if (!await this.rootContainerIsValid()) { - this.logger.info(`Root container not found; initializing it.`); - const resources = this.generator.generate(this.baseId, {}); - let count = 0; - for await (const { identifier: resourceId, representation } of resources) { - try { - await this.store.setRepresentation(resourceId, representation); - count += 1; - } catch (error: unknown) { - this.logger.warn(`Failed to create resource ${resourceId.path}: ${createErrorMessage(error)}`); - } - } - this.logger.info(`Initialized root container with ${count} resources.`); - } else { - this.logger.debug(`Valid root container found at ${this.baseId.path}`); - } - } - - /** - * Verifies if the root container already exists and has the pim:Storage type. - */ - private async rootContainerIsValid(): Promise { - try { - const representation = await this.store.getRepresentation(this.baseId, {}); - representation.data.destroy(); - return representation.metadata.getAll(RDF.terms.type).some((term): boolean => term.equals(PIM.terms.Storage)); - } catch (error: unknown) { - if (NotFoundHttpError.isInstance(error)) { - return false; - } - throw error; - } - } -} diff --git a/test/unit/init/ContainerInitializer.test.ts b/test/unit/init/ContainerInitializer.test.ts new file mode 100644 index 000000000..9019deb6e --- /dev/null +++ b/test/unit/init/ContainerInitializer.test.ts @@ -0,0 +1,73 @@ +import { ContainerInitializer } from '../../../src/init/ContainerInitializer'; +import type { Logger } from '../../../src/logging/Logger'; +import { getLoggerFor } from '../../../src/logging/LogUtil'; +import type { Resource, ResourcesGenerator } from '../../../src/pods/generate/ResourcesGenerator'; +import type { KeyValueStorage } from '../../../src/storage/keyvalue/KeyValueStorage'; +import type { ResourceStore } from '../../../src/storage/ResourceStore'; + +jest.mock('../../../src/logging/LogUtil', (): any => { + const logger: Logger = { warn: jest.fn(), debug: jest.fn(), info: jest.fn() } as any; + return { getLoggerFor: (): Logger => logger }; +}); + +describe('A ContainerInitializer', (): void => { + const baseUrl = 'http://test.com/'; + const path = 'foo/'; + let store: jest.Mocked; + let generatorData: Resource[]; + let generator: jest.Mocked; + const storageKey = 'done'; + let storage: jest.Mocked>; + let initializer: ContainerInitializer; + let logger: jest.Mocked; + + beforeEach(async(): Promise => { + store = { + setRepresentation: jest.fn(), + } as any; + + generatorData = [ + { identifier: { path: '/.acl' }, representation: '/.acl' as any }, + { identifier: { path: '/container/' }, representation: '/container/' as any }, + ]; + generator = { + generate: jest.fn(async function* (): any { + yield* generatorData; + }), + } as any; + + const map = new Map(); + storage = { + get: jest.fn((id: string): any => map.get(id)), + set: jest.fn((id: string, value: any): any => map.set(id, value)), + } as any; + + initializer = new ContainerInitializer({ + baseUrl, + path, + store, + generator, + storageKey, + storage, + }); + logger = getLoggerFor(initializer) as any; + jest.clearAllMocks(); + }); + + it('writes resources and sets the storage value to true.', async(): Promise => { + await expect(initializer.handle()).resolves.toBeUndefined(); + expect(generator.generate).toHaveBeenCalledTimes(1); + expect(store.setRepresentation).toHaveBeenCalledTimes(2); + expect(storage.get(storageKey)).toBe(true); + }); + + it('logs warnings if there was a problem creating a resource.', async(): Promise => { + store.setRepresentation.mockRejectedValueOnce(new Error('bad input')); + + await expect(initializer.handle()).resolves.toBeUndefined(); + expect(generator.generate).toHaveBeenCalledTimes(1); + expect(store.setRepresentation).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenLastCalledWith('Failed to create resource /.acl: bad input'); + }); +}); diff --git a/test/unit/init/RootInitializer.test.ts b/test/unit/init/RootInitializer.test.ts deleted file mode 100644 index 3aa81bf89..000000000 --- a/test/unit/init/RootInitializer.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { RootInitializer } from '../../../src/init/RootInitializer'; -import { BasicRepresentation } from '../../../src/ldp/representation/BasicRepresentation'; -import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata'; -import type { Logger } from '../../../src/logging/Logger'; -import { getLoggerFor } from '../../../src/logging/LogUtil'; -import type { Resource, ResourcesGenerator } from '../../../src/pods/generate/ResourcesGenerator'; -import type { ResourceStore } from '../../../src/storage/ResourceStore'; -import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; -import { PIM, RDF } from '../../../src/util/Vocabularies'; - -jest.mock('../../../src/logging/LogUtil', (): any => { - const logger: Logger = { warn: jest.fn(), debug: jest.fn(), info: jest.fn() } as any; - return { getLoggerFor: (): Logger => logger }; -}); - -describe('A RootInitializer', (): void => { - const baseUrl = 'http://test.com/foo/'; - let store: jest.Mocked; - let generatorData: Resource[]; - let generator: jest.Mocked; - let initializer: RootInitializer; - let logger: jest.Mocked; - - beforeEach(async(): Promise => { - store = { - getRepresentation: jest.fn().mockRejectedValue(new NotFoundHttpError()), - setRepresentation: jest.fn(), - } as any; - - generatorData = [ - { identifier: { path: '/.acl' }, representation: '/.acl' as any }, - { identifier: { path: '/container/' }, representation: '/container/' as any }, - ]; - generator = { - generate: jest.fn(async function* (): any { - yield* generatorData; - }), - } as any; - - initializer = new RootInitializer(baseUrl, store, generator); - logger = getLoggerFor(initializer) as any; - jest.clearAllMocks(); - }); - - it('does nothing is the root container already has pim:Storage metadata.', async(): Promise => { - const metadata = new RepresentationMetadata({ path: baseUrl }, { [RDF.type]: PIM.terms.Storage }); - store.getRepresentation.mockResolvedValueOnce(new BasicRepresentation('data', metadata)); - - await expect(initializer.handle()).resolves.toBeUndefined(); - expect(generator.generate).toHaveBeenCalledTimes(0); - expect(store.setRepresentation).toHaveBeenCalledTimes(0); - }); - - it('writes new resources if the container does not exist yet.', async(): Promise => { - await expect(initializer.handle()).resolves.toBeUndefined(); - expect(generator.generate).toHaveBeenCalledTimes(1); - expect(store.setRepresentation).toHaveBeenCalledTimes(2); - }); - - it('writes new resources if the container is not a pim:Storage.', async(): Promise => { - store.getRepresentation.mockResolvedValueOnce(new BasicRepresentation('data', 'text/string')); - - await expect(initializer.handle()).resolves.toBeUndefined(); - expect(generator.generate).toHaveBeenCalledTimes(1); - expect(store.setRepresentation).toHaveBeenCalledTimes(2); - }); - - it('throws an error if there is a problem accessing the root container.', async(): Promise => { - store.getRepresentation.mockRejectedValueOnce(new Error('bad data')); - await expect(initializer.handle()).rejects.toThrow('bad data'); - }); - - it('logs warnings if there was a problem creating a resource.', async(): Promise => { - store.setRepresentation.mockRejectedValueOnce(new Error('bad input')); - - await expect(initializer.handle()).resolves.toBeUndefined(); - expect(generator.generate).toHaveBeenCalledTimes(1); - expect(store.setRepresentation).toHaveBeenCalledTimes(2); - expect(logger.warn).toHaveBeenCalledTimes(1); - expect(logger.warn).toHaveBeenLastCalledWith('Failed to create resource /.acl: bad input'); - }); -});