feat: Add migration for v6 account data

This commit is contained in:
Joachim Van Herwegen
2023-10-04 16:38:46 +02:00
parent fedd9e04d8
commit 0ac7d407bf
39 changed files with 1034 additions and 89 deletions

View File

@@ -8,7 +8,7 @@ import { ACCOUNT_SETTINGS_REMEMBER_LOGIN } from './AccountStore';
import type { AccountLoginStorage } from './LoginStorage';
import { ACCOUNT_TYPE } from './LoginStorage';
const STORAGE_DESCRIPTION = {
export const ACCOUNT_STORAGE_DESCRIPTION = {
[ACCOUNT_SETTINGS_REMEMBER_LOGIN]: 'boolean?',
} as const;
@@ -19,7 +19,7 @@ const STORAGE_DESCRIPTION = {
export class BaseAccountStore extends Initializer implements AccountStore {
private readonly logger = getLoggerFor(this);
private readonly storage: AccountLoginStorage<{ [ACCOUNT_TYPE]: typeof STORAGE_DESCRIPTION }>;
private readonly storage: AccountLoginStorage<{ [ACCOUNT_TYPE]: typeof ACCOUNT_STORAGE_DESCRIPTION }>;
private initialized = false;
public constructor(storage: AccountLoginStorage<any>) {
@@ -33,7 +33,7 @@ export class BaseAccountStore extends Initializer implements AccountStore {
return;
}
try {
await this.storage.defineType(ACCOUNT_TYPE, STORAGE_DESCRIPTION, false);
await this.storage.defineType(ACCOUNT_TYPE, ACCOUNT_STORAGE_DESCRIPTION, false);
this.initialized = true;
} catch (cause: unknown) {
throw new InternalServerError(`Error defining account in storage: ${createErrorMessage(cause)}`, { cause });
@@ -58,6 +58,6 @@ export class BaseAccountStore extends Initializer implements AccountStore {
public async updateSetting<T extends keyof AccountSettings>(id: string, setting: T, value: AccountSettings[T]):
Promise<void> {
await this.storage.setField(ACCOUNT_TYPE, id, setting, value as ValueType<typeof STORAGE_DESCRIPTION[T]>);
await this.storage.setField(ACCOUNT_TYPE, id, setting, value as ValueType<typeof ACCOUNT_STORAGE_DESCRIPTION[T]>);
}
}

View File

@@ -7,8 +7,8 @@ import { ACCOUNT_TYPE } from '../../account/util/LoginStorage';
import type { AccountLoginStorage } from '../../account/util/LoginStorage';
import type { ClientCredentials, ClientCredentialsStore } from './ClientCredentialsStore';
const STORAGE_TYPE = 'clientCredentials';
const STORAGE_DESCRIPTION = {
export const CLIENT_CREDENTIALS_STORAGE_TYPE = 'clientCredentials';
export const CLIENT_CREDENTIALS_STORAGE_DESCRIPTION = {
label: 'string',
accountId: `id:${ACCOUNT_TYPE}`,
secret: 'string',
@@ -22,7 +22,9 @@ const STORAGE_DESCRIPTION = {
export class BaseClientCredentialsStore extends Initializer implements ClientCredentialsStore {
private readonly logger = getLoggerFor(this);
private readonly storage: AccountLoginStorage<{ [STORAGE_TYPE]: typeof STORAGE_DESCRIPTION }>;
private readonly storage: AccountLoginStorage<{ [CLIENT_CREDENTIALS_STORAGE_TYPE]:
typeof CLIENT_CREDENTIALS_STORAGE_DESCRIPTION; }>;
private initialized = false;
public constructor(storage: AccountLoginStorage<any>) {
@@ -36,9 +38,9 @@ export class BaseClientCredentialsStore extends Initializer implements ClientCre
return;
}
try {
await this.storage.defineType(STORAGE_TYPE, STORAGE_DESCRIPTION, false);
await this.storage.createIndex(STORAGE_TYPE, 'accountId');
await this.storage.createIndex(STORAGE_TYPE, 'label');
await this.storage.defineType(CLIENT_CREDENTIALS_STORAGE_TYPE, CLIENT_CREDENTIALS_STORAGE_DESCRIPTION, false);
await this.storage.createIndex(CLIENT_CREDENTIALS_STORAGE_TYPE, 'accountId');
await this.storage.createIndex(CLIENT_CREDENTIALS_STORAGE_TYPE, 'label');
this.initialized = true;
} catch (cause: unknown) {
throw new InternalServerError(`Error defining client credentials in storage: ${createErrorMessage(cause)}`,
@@ -47,11 +49,11 @@ export class BaseClientCredentialsStore extends Initializer implements ClientCre
}
public async get(id: string): Promise<ClientCredentials | undefined> {
return this.storage.get(STORAGE_TYPE, id);
return this.storage.get(CLIENT_CREDENTIALS_STORAGE_TYPE, id);
}
public async findByLabel(label: string): Promise<ClientCredentials | undefined> {
const result = await this.storage.find(STORAGE_TYPE, { label });
const result = await this.storage.find(CLIENT_CREDENTIALS_STORAGE_TYPE, { label });
if (result.length === 0) {
return;
}
@@ -59,7 +61,7 @@ export class BaseClientCredentialsStore extends Initializer implements ClientCre
}
public async findByAccount(accountId: string): Promise<ClientCredentials[]> {
return this.storage.find(STORAGE_TYPE, { accountId });
return this.storage.find(CLIENT_CREDENTIALS_STORAGE_TYPE, { accountId });
}
public async create(label: string, webId: string, accountId: string): Promise<ClientCredentials> {
@@ -69,11 +71,11 @@ export class BaseClientCredentialsStore extends Initializer implements ClientCre
`Creating client credentials token with label ${label} for WebID ${webId} and account ${accountId}`,
);
return this.storage.create(STORAGE_TYPE, { accountId, label, webId, secret });
return this.storage.create(CLIENT_CREDENTIALS_STORAGE_TYPE, { accountId, label, webId, secret });
}
public async delete(id: string): Promise<void> {
this.logger.debug(`Deleting client credentials token with ID ${id}`);
return this.storage.delete(STORAGE_TYPE, id);
return this.storage.delete(CLIENT_CREDENTIALS_STORAGE_TYPE, id);
}
}

View File

@@ -9,8 +9,8 @@ import { ACCOUNT_TYPE } from '../../account/util/LoginStorage';
import type { AccountLoginStorage } from '../../account/util/LoginStorage';
import type { PasswordStore } from './PasswordStore';
const STORAGE_TYPE = 'password';
const STORAGE_DESCRIPTION = {
export const PASSWORD_STORAGE_TYPE = 'password';
export const PASSWORD_STORAGE_DESCRIPTION = {
email: 'string',
password: 'string',
verified: 'boolean',
@@ -25,7 +25,7 @@ const STORAGE_DESCRIPTION = {
export class BasePasswordStore extends Initializer implements PasswordStore {
private readonly logger = getLoggerFor(this);
private readonly storage: AccountLoginStorage<{ [STORAGE_TYPE]: typeof STORAGE_DESCRIPTION }>;
private readonly storage: AccountLoginStorage<{ [PASSWORD_STORAGE_TYPE]: typeof PASSWORD_STORAGE_DESCRIPTION }>;
private readonly saltRounds: number;
private initialized = false;
@@ -41,9 +41,9 @@ export class BasePasswordStore extends Initializer implements PasswordStore {
return;
}
try {
await this.storage.defineType(STORAGE_TYPE, STORAGE_DESCRIPTION, true);
await this.storage.createIndex(STORAGE_TYPE, 'accountId');
await this.storage.createIndex(STORAGE_TYPE, 'email');
await this.storage.defineType(PASSWORD_STORAGE_TYPE, PASSWORD_STORAGE_DESCRIPTION, true);
await this.storage.createIndex(PASSWORD_STORAGE_TYPE, 'accountId');
await this.storage.createIndex(PASSWORD_STORAGE_TYPE, 'email');
this.initialized = true;
} catch (cause: unknown) {
throw new InternalServerError(`Error defining email/password in storage: ${createErrorMessage(cause)}`,
@@ -56,7 +56,7 @@ export class BasePasswordStore extends Initializer implements PasswordStore {
this.logger.warn(`Trying to create duplicate login for email ${email}`);
throw new BadRequestHttpError('There already is a login for this e-mail address.');
}
const payload = await this.storage.create(STORAGE_TYPE, {
const payload = await this.storage.create(PASSWORD_STORAGE_TYPE, {
accountId,
email: email.toLowerCase(),
password: await hash(password, this.saltRounds),
@@ -66,7 +66,7 @@ export class BasePasswordStore extends Initializer implements PasswordStore {
}
public async get(id: string): Promise<{ email: string; accountId: string } | undefined> {
const result = await this.storage.get(STORAGE_TYPE, id);
const result = await this.storage.get(PASSWORD_STORAGE_TYPE, id);
if (!result) {
return;
}
@@ -74,7 +74,7 @@ export class BasePasswordStore extends Initializer implements PasswordStore {
}
public async findByEmail(email: string): Promise<{ accountId: string; id: string } | undefined> {
const payload = await this.storage.find(STORAGE_TYPE, { email: email.toLowerCase() });
const payload = await this.storage.find(PASSWORD_STORAGE_TYPE, { email: email.toLowerCase() });
if (payload.length === 0) {
return;
}
@@ -82,21 +82,21 @@ export class BasePasswordStore extends Initializer implements PasswordStore {
}
public async findByAccount(accountId: string): Promise<{ id: string; email: string }[]> {
return (await this.storage.find(STORAGE_TYPE, { accountId }))
return (await this.storage.find(PASSWORD_STORAGE_TYPE, { accountId }))
.map(({ id, email }): { id: string; email: string } => ({ id, email }));
}
public async confirmVerification(id: string): Promise<void> {
if (!await this.storage.has(STORAGE_TYPE, id)) {
if (!await this.storage.has(PASSWORD_STORAGE_TYPE, id)) {
this.logger.warn(`Trying to verify unknown password login ${id}`);
throw new ForbiddenHttpError('Login does not exist.');
}
await this.storage.setField(STORAGE_TYPE, id, 'verified', true);
await this.storage.setField(PASSWORD_STORAGE_TYPE, id, 'verified', true);
}
public async authenticate(email: string, password: string): Promise<{ accountId: string; id: string }> {
const payload = await this.storage.find(STORAGE_TYPE, { email: email.toLowerCase() });
const payload = await this.storage.find(PASSWORD_STORAGE_TYPE, { email: email.toLowerCase() });
if (payload.length === 0) {
this.logger.warn(`Trying to get account info for unknown email ${email}`);
throw new ForbiddenHttpError('Invalid email/password combination.');
@@ -114,14 +114,14 @@ export class BasePasswordStore extends Initializer implements PasswordStore {
}
public async update(id: string, password: string): Promise<void> {
if (!await this.storage.has(STORAGE_TYPE, id)) {
if (!await this.storage.has(PASSWORD_STORAGE_TYPE, id)) {
this.logger.warn(`Trying to update unknown password login ${id}`);
throw new ForbiddenHttpError('Login does not exist.');
}
await this.storage.setField(STORAGE_TYPE, id, 'password', await hash(password, this.saltRounds));
await this.storage.setField(PASSWORD_STORAGE_TYPE, id, 'password', await hash(password, this.saltRounds));
}
public async delete(id: string): Promise<void> {
return this.storage.delete(STORAGE_TYPE, id);
return this.storage.delete(PASSWORD_STORAGE_TYPE, id);
}
}

View File

@@ -9,17 +9,17 @@ import { ACCOUNT_TYPE } from '../../account/util/LoginStorage';
import type { AccountLoginStorage } from '../../account/util/LoginStorage';
import type { PodStore } from './PodStore';
const STORAGE_TYPE = 'pod';
const STORAGE_DESCRIPTION = {
export const POD_STORAGE_TYPE = 'pod';
export const POD_STORAGE_DESCRIPTION = {
baseUrl: 'string',
accountId: `id:${ACCOUNT_TYPE}`,
} as const;
const OWNER_TYPE = 'owner';
const OWNER_DESCRIPTION = {
export const OWNER_STORAGE_TYPE = 'owner';
export const OWNER_STORAGE_DESCRIPTION = {
webId: 'string',
visible: 'boolean',
podId: `id:${STORAGE_TYPE}`,
podId: `id:${POD_STORAGE_TYPE}`,
} as const;
/**
@@ -35,8 +35,8 @@ export class BasePodStore extends Initializer implements PodStore {
private readonly logger = getLoggerFor(this);
private readonly storage: AccountLoginStorage<{
[STORAGE_TYPE]: typeof STORAGE_DESCRIPTION;
[OWNER_TYPE]: typeof OWNER_DESCRIPTION; }>;
[POD_STORAGE_TYPE]: typeof POD_STORAGE_DESCRIPTION;
[OWNER_STORAGE_TYPE]: typeof OWNER_STORAGE_DESCRIPTION; }>;
private readonly manager: PodManager;
private readonly visible: boolean;
@@ -56,11 +56,11 @@ export class BasePodStore extends Initializer implements PodStore {
return;
}
try {
await this.storage.defineType(STORAGE_TYPE, STORAGE_DESCRIPTION, false);
await this.storage.createIndex(STORAGE_TYPE, 'accountId');
await this.storage.createIndex(STORAGE_TYPE, 'baseUrl');
await this.storage.defineType(OWNER_TYPE, OWNER_DESCRIPTION, false);
await this.storage.createIndex(OWNER_TYPE, 'podId');
await this.storage.defineType(POD_STORAGE_TYPE, POD_STORAGE_DESCRIPTION, false);
await this.storage.createIndex(POD_STORAGE_TYPE, 'accountId');
await this.storage.createIndex(POD_STORAGE_TYPE, 'baseUrl');
await this.storage.defineType(OWNER_STORAGE_TYPE, OWNER_STORAGE_DESCRIPTION, false);
await this.storage.createIndex(OWNER_STORAGE_TYPE, 'podId');
this.initialized = true;
} catch (cause: unknown) {
throw new InternalServerError(`Error defining pods in storage: ${createErrorMessage(cause)}`,
@@ -71,14 +71,14 @@ export class BasePodStore extends Initializer implements PodStore {
public async create(accountId: string, settings: PodSettings, overwrite: boolean): Promise<string> {
// Adding pod to storage first as we cannot undo creating the pod below.
// This call might also fail because there is no login method yet on the account.
const pod = await this.storage.create(STORAGE_TYPE, { baseUrl: settings.base.path, accountId });
await this.storage.create(OWNER_TYPE, { podId: pod.id, webId: settings.webId, visible: this.visible });
const pod = await this.storage.create(POD_STORAGE_TYPE, { baseUrl: settings.base.path, accountId });
await this.storage.create(OWNER_STORAGE_TYPE, { podId: pod.id, webId: settings.webId, visible: this.visible });
try {
await this.manager.createPod(settings, overwrite);
} catch (error: unknown) {
this.logger.warn(`Pod creation failed for account ${accountId}: ${createErrorMessage(error)}`);
await this.storage.delete(STORAGE_TYPE, pod.id);
await this.storage.delete(POD_STORAGE_TYPE, pod.id);
throw new BadRequestHttpError(`Pod creation failed: ${createErrorMessage(error)}`, { cause: error });
}
this.logger.debug(`Created pod ${settings.name} for account ${accountId}`);
@@ -87,7 +87,7 @@ export class BasePodStore extends Initializer implements PodStore {
}
public async get(id: string): Promise<{ baseUrl: string; accountId: string } | undefined> {
const pod = await this.storage.get(STORAGE_TYPE, id);
const pod = await this.storage.get(POD_STORAGE_TYPE, id);
if (!pod) {
return;
}
@@ -95,7 +95,7 @@ export class BasePodStore extends Initializer implements PodStore {
}
public async findByBaseUrl(baseUrl: string): Promise<{ id: string; accountId: string } | undefined> {
const result = await this.storage.find(STORAGE_TYPE, { baseUrl });
const result = await this.storage.find(POD_STORAGE_TYPE, { baseUrl });
if (result.length === 0) {
return;
}
@@ -103,12 +103,12 @@ export class BasePodStore extends Initializer implements PodStore {
}
public async findPods(accountId: string): Promise<{ id: string; baseUrl: string }[]> {
return (await this.storage.find(STORAGE_TYPE, { accountId }))
return (await this.storage.find(POD_STORAGE_TYPE, { accountId }))
.map(({ id, baseUrl }): { id: string; baseUrl: string } => ({ id, baseUrl }));
}
public async getOwners(id: string): Promise<{ webId: string; visible: boolean }[] | undefined> {
const results = await this.storage.find(OWNER_TYPE, { podId: id });
const results = await this.storage.find(OWNER_STORAGE_TYPE, { podId: id });
if (results.length === 0) {
return;
}
@@ -119,16 +119,16 @@ export class BasePodStore extends Initializer implements PodStore {
public async updateOwner(id: string, webId: string, visible: boolean): Promise<void> {
// Need to first check if there already is an owner with the given WebID
// so we know if we need to create or update.
const matches = await this.storage.find(OWNER_TYPE, { webId, podId: id });
const matches = await this.storage.find(OWNER_STORAGE_TYPE, { webId, podId: id });
if (matches.length === 0) {
await this.storage.create(OWNER_TYPE, { webId, visible, podId: id });
await this.storage.create(OWNER_STORAGE_TYPE, { webId, visible, podId: id });
} else {
await this.storage.setField(OWNER_TYPE, matches[0].id, 'visible', visible);
await this.storage.setField(OWNER_STORAGE_TYPE, matches[0].id, 'visible', visible);
}
}
public async removeOwner(id: string, webId: string): Promise<void> {
const owners = await this.storage.find(OWNER_TYPE, { podId: id });
const owners = await this.storage.find(OWNER_STORAGE_TYPE, { podId: id });
const match = owners.find((owner): boolean => owner.webId === webId);
if (!match) {
return;
@@ -136,6 +136,6 @@ export class BasePodStore extends Initializer implements PodStore {
if (owners.length === 1) {
throw new BadRequestHttpError('Unable to remove the last owner of a pod.');
}
await this.storage.delete(OWNER_TYPE, match.id);
await this.storage.delete(OWNER_STORAGE_TYPE, match.id);
}
}

View File

@@ -7,8 +7,8 @@ import { ACCOUNT_TYPE } from '../../account/util/LoginStorage';
import type { AccountLoginStorage } from '../../account/util/LoginStorage';
import type { WebIdStore } from './WebIdStore';
const STORAGE_TYPE = 'webIdLink';
const STORAGE_DESCRIPTION = {
export const WEBID_STORAGE_TYPE = 'webIdLink';
export const WEBID_STORAGE_DESCRIPTION = {
webId: 'string',
accountId: `id:${ACCOUNT_TYPE}`,
} as const;
@@ -20,7 +20,7 @@ const STORAGE_DESCRIPTION = {
export class BaseWebIdStore extends Initializer implements WebIdStore {
private readonly logger = getLoggerFor(this);
private readonly storage: AccountLoginStorage<{ [STORAGE_TYPE]: typeof STORAGE_DESCRIPTION }>;
private readonly storage: AccountLoginStorage<{ [WEBID_STORAGE_TYPE]: typeof WEBID_STORAGE_DESCRIPTION }>;
private initialized = false;
public constructor(storage: AccountLoginStorage<any>) {
@@ -34,9 +34,9 @@ export class BaseWebIdStore extends Initializer implements WebIdStore {
return;
}
try {
await this.storage.defineType(STORAGE_TYPE, STORAGE_DESCRIPTION, false);
await this.storage.createIndex(STORAGE_TYPE, 'accountId');
await this.storage.createIndex(STORAGE_TYPE, 'webId');
await this.storage.defineType(WEBID_STORAGE_TYPE, WEBID_STORAGE_DESCRIPTION, false);
await this.storage.createIndex(WEBID_STORAGE_TYPE, 'accountId');
await this.storage.createIndex(WEBID_STORAGE_TYPE, 'webId');
this.initialized = true;
} catch (cause: unknown) {
throw new InternalServerError(`Error defining WebID links in storage: ${createErrorMessage(cause)}`,
@@ -45,16 +45,16 @@ export class BaseWebIdStore extends Initializer implements WebIdStore {
}
public async get(id: string): Promise<{ accountId: string; webId: string } | undefined> {
return this.storage.get(STORAGE_TYPE, id);
return this.storage.get(WEBID_STORAGE_TYPE, id);
}
public async isLinked(webId: string, accountId: string): Promise<boolean> {
const result = await this.storage.find(STORAGE_TYPE, { webId, accountId });
const result = await this.storage.find(WEBID_STORAGE_TYPE, { webId, accountId });
return result.length > 0;
}
public async findLinks(accountId: string): Promise<{ id: string; webId: string }[]> {
return (await this.storage.find(STORAGE_TYPE, { accountId }))
return (await this.storage.find(WEBID_STORAGE_TYPE, { accountId }))
.map(({ id, webId }): { id: string; webId: string } => ({ id, webId }));
}
@@ -64,7 +64,7 @@ export class BaseWebIdStore extends Initializer implements WebIdStore {
throw new BadRequestHttpError(`${webId} is already registered to this account.`);
}
const result = await this.storage.create(STORAGE_TYPE, { webId, accountId });
const result = await this.storage.create(WEBID_STORAGE_TYPE, { webId, accountId });
this.logger.debug(`Linked WebID ${webId} to account ${accountId}`);
@@ -73,6 +73,6 @@ export class BaseWebIdStore extends Initializer implements WebIdStore {
public async delete(linkId: string): Promise<void> {
this.logger.debug(`Deleting WebID link with ID ${linkId}`);
return this.storage.delete(STORAGE_TYPE, linkId);
return this.storage.delete(WEBID_STORAGE_TYPE, linkId);
}
}

View File

@@ -275,6 +275,10 @@ export * from './init/final/Finalizer';
export * from './init/cli/CliExtractor';
export * from './init/cli/YargsCliExtractor';
// Init/Migration
export * from './init/migration/SingleContainerJsonStorage';
export * from './init/migration/V6MigrationInitializer';
// Init/Variables/Extractors
export * from './init/variables/extractors/KeyExtractor';
export * from './init/variables/extractors/AssetPathExtractor';

View File

@@ -0,0 +1,42 @@
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import { JsonResourceStorage } from '../../storage/keyvalue/JsonResourceStorage';
import { isContainerIdentifier } from '../../util/PathUtil';
import { readableToString } from '../../util/StreamUtil';
import { LDP } from '../../util/Vocabularies';
/**
* A variant of a {@link JsonResourceStorage} where the `entries()` call
* does not recursively iterate through all containers.
* Only the documents that are found in the root container are returned.
*
* This class was created to support migration where different storages are nested in one main `.internal` container,
* and we specifically want to only return entries of one storage.
*/
export class SingleContainerJsonStorage<T> extends JsonResourceStorage<T> {
protected async* getResourceEntries(containerId: ResourceIdentifier): AsyncIterableIterator<[string, T]> {
const container = await this.safelyGetResource(containerId);
if (!container) {
return;
}
// Only need the metadata
container.data.destroy();
const members = container.metadata.getAll(LDP.terms.contains).map((term): string => term.value);
for (const path of members) {
const documentId = { path };
if (isContainerIdentifier(documentId)) {
continue;
}
const document = await this.safelyGetResource(documentId);
if (!document) {
continue;
}
const json = JSON.parse(await readableToString(document.data));
const key = this.identifierToKey(documentId);
yield [ key, json ];
}
}
}

View File

@@ -0,0 +1,239 @@
import { createInterface } from 'readline';
import { ACCOUNT_STORAGE_DESCRIPTION } from '../../identity/interaction/account/util/BaseAccountStore';
import type { AccountLoginStorage } from '../../identity/interaction/account/util/LoginStorage';
import { ACCOUNT_TYPE } from '../../identity/interaction/account/util/LoginStorage';
import {
CLIENT_CREDENTIALS_STORAGE_DESCRIPTION,
CLIENT_CREDENTIALS_STORAGE_TYPE,
} from '../../identity/interaction/client-credentials/util/BaseClientCredentialsStore';
import {
PASSWORD_STORAGE_DESCRIPTION,
PASSWORD_STORAGE_TYPE,
} from '../../identity/interaction/password/util/BasePasswordStore';
import {
OWNER_STORAGE_DESCRIPTION,
OWNER_STORAGE_TYPE,
POD_STORAGE_DESCRIPTION,
POD_STORAGE_TYPE,
} from '../../identity/interaction/pod/util/BasePodStore';
import { WEBID_STORAGE_DESCRIPTION, WEBID_STORAGE_TYPE } from '../../identity/interaction/webid/util/BaseWebIdStore';
import { getLoggerFor } from '../../logging/LogUtil';
import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage';
import { Initializer } from '../Initializer';
type Account = {
webId: string;
email: string;
password: string;
verified: boolean;
};
type Settings = {
useIdp: boolean;
podBaseUrl?: string;
clientCredentials?: string[];
};
type ClientCredentials = {
webId: string;
secret: string;
};
const STORAGE_DESCRIPTION = {
[ACCOUNT_TYPE]: ACCOUNT_STORAGE_DESCRIPTION,
[PASSWORD_STORAGE_TYPE]: PASSWORD_STORAGE_DESCRIPTION,
[WEBID_STORAGE_TYPE]: WEBID_STORAGE_DESCRIPTION,
[POD_STORAGE_TYPE]: POD_STORAGE_DESCRIPTION,
[OWNER_STORAGE_TYPE]: OWNER_STORAGE_DESCRIPTION,
[CLIENT_CREDENTIALS_STORAGE_TYPE]: CLIENT_CREDENTIALS_STORAGE_DESCRIPTION,
} as const;
export interface V6MigrationInitializerArgs {
/**
* The storage in which the version is saved that was stored last time the server was started.
*/
versionStorage: KeyValueStorage<string, string>;
/**
* The key necessary to get the version from the `versionStorage`.
*/
versionKey: string;
/**
* The storage in which account data of the previous version is stored.
*/
accountStorage: KeyValueStorage<string, Account | Settings>;
/**
* The storage in which client credentials are stored from the previous version.
*/
clientCredentialsStorage: KeyValueStorage<string, ClientCredentials>;
/**
* The storage in which the forgot password entries of the previous version are stored.
* These will all just be removed, not migrated.
*/
forgotPasswordStorage: KeyValueStorage<string, unknown>;
/**
* The storage that will contain the account data in the new format.
*/
newStorage: AccountLoginStorage<any>;
/**
* If true, no confirmation prompt will be printed to the stdout.
*/
skipConfirmation?: boolean;
}
/**
* Handles migrating account data from v6 to the newer format.
* Will only trigger if it is detected that this server was previously started on an older version
* and at least one account was found.
* Confirmation will be asked to the user through a CLI prompt.
* After migration is complete the old data will be removed.
*/
export class V6MigrationInitializer extends Initializer {
private readonly logger = getLoggerFor(this);
private readonly skipConfirmation: boolean;
private readonly versionKey: string;
private readonly versionStorage: KeyValueStorage<string, string>;
private readonly accountStorage: KeyValueStorage<string, Account | Settings>;
private readonly clientCredentialsStorage: KeyValueStorage<string, ClientCredentials>;
private readonly forgotPasswordStorage: KeyValueStorage<string, unknown>;
private readonly newStorage: AccountLoginStorage<typeof STORAGE_DESCRIPTION>;
public constructor(args: V6MigrationInitializerArgs) {
super();
this.skipConfirmation = Boolean(args.skipConfirmation);
this.versionKey = args.versionKey;
this.versionStorage = args.versionStorage;
this.accountStorage = args.accountStorage;
this.clientCredentialsStorage = args.clientCredentialsStorage;
this.forgotPasswordStorage = args.forgotPasswordStorage;
this.newStorage = args.newStorage;
}
public async handle(): Promise<void> {
const previousVersion = await this.versionStorage.get(this.versionKey);
if (!previousVersion) {
// This happens if this is the first time the server is started
this.logger.debug('No previous version found');
return;
}
const [ prevMajor ] = previousVersion.split('.');
if (Number.parseInt(prevMajor, 10) > 6) {
return;
}
const accountIterator = this.accountStorage.entries();
const next = await accountIterator.next();
if (next.done) {
this.logger.debug('No account data was found so no migration is necessary.');
return;
}
// Ask the user for confirmation
if (!this.skipConfirmation) {
const readline = createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise<string>((resolve): void => {
readline.question([
'The server is now going to migrate v6 account data to the new storage format internally.',
'In case you have not yet done this,',
'it is recommended to cancel startup and first backup the existing account data,',
'in case something goes wrong.',
'When using default configurations with a file backend,',
'this data can be found in the ".internal/accounts" folder.',
'\n\nDo you want to migrate the data now? [y/N] ',
].join(' '), resolve);
});
readline.close();
if (!/^y(?:es)?$/ui.test(answer)) {
throw new Error('Stopping server as migration was cancelled.');
}
}
this.logger.info('Migrating v6 account data to the new format...');
const webIdAccountMap: Record<string, string> = {};
// Need to migrate the first entry we already extracted from the iterator above
const firstResult = await this.createAccount(next.value[1]);
if (firstResult) {
// Store link between WebID and account ID for client credentials
webIdAccountMap[firstResult.webId] = firstResult.accountId;
}
for await (const [ , account ] of accountIterator) {
const result = await this.createAccount(account);
if (result) {
// Store link between WebID and account ID for client credentials
webIdAccountMap[result.webId] = result.accountId;
}
}
// Convert the existing client credentials tokens
for await (const [ label, { webId, secret }] of this.clientCredentialsStorage.entries()) {
const accountId = webIdAccountMap[webId];
if (!accountId) {
this.logger.warn(`Unable to find account for client credentials ${label}. Skipping migration of this token.`);
continue;
}
await this.newStorage.create(CLIENT_CREDENTIALS_STORAGE_TYPE, { webId, label, secret, accountId });
}
// Delete all old entries
for await (const [ key ] of this.accountStorage.entries()) {
await this.accountStorage.delete(key);
}
for await (const [ key ] of this.clientCredentialsStorage.entries()) {
await this.clientCredentialsStorage.delete(key);
}
for await (const [ key ] of this.forgotPasswordStorage.entries()) {
await this.forgotPasswordStorage.delete(key);
}
this.logger.info('Finished migrating v6 account data.');
}
protected isAccount(data: Account | Settings): data is Account {
return Boolean((data as Account).email);
}
/**
* Creates a new account based on the account data found in the old storage.
* Will always create an account and password entry.
* In case `useIdp` is true, will create a WebID link entry.
* In case there is an associated `podBaseUrl`, will create a pod and owner entry.
*/
protected async createAccount(account: Account | Settings):
Promise<{ accountId: string; webId: string } | undefined> {
if (!this.isAccount(account)) {
return;
}
const { webId, email, password, verified } = account;
this.logger.debug(`Migrating account ${email} with WebID ${webId}`);
const settings = await this.accountStorage.get(webId) as Settings | undefined;
if (!settings) {
this.logger.warn(`Unable to find settings for account ${email}. Skipping migration of this account.`);
return;
}
const { id: accountId } = await this.newStorage.create(ACCOUNT_TYPE, {});
// The `toLowerCase` call is important here to have the expected value
await this.newStorage.create(PASSWORD_STORAGE_TYPE,
{ email: email.toLowerCase(), password, verified, accountId });
if (settings.useIdp) {
await this.newStorage.create(WEBID_STORAGE_TYPE, { webId, accountId });
}
if (settings.podBaseUrl) {
const { id: podId } = await this.newStorage.create(POD_STORAGE_TYPE,
{ baseUrl: settings.podBaseUrl, accountId });
await this.newStorage.create(OWNER_STORAGE_TYPE, { webId, podId, visible: false });
}
return { accountId, webId };
}
}

View File

@@ -27,8 +27,8 @@ const KEY_LENGTH_LIMIT = 255;
* All non-404 errors will be re-thrown.
*/
export class JsonResourceStorage<T> implements KeyValueStorage<string, T> {
private readonly source: ResourceStore;
private readonly container: string;
protected readonly source: ResourceStore;
protected readonly container: string;
public constructor(source: ResourceStore, baseUrl: string, container: string) {
this.source = source;
@@ -79,7 +79,7 @@ export class JsonResourceStorage<T> implements KeyValueStorage<string, T> {
/**
* Recursively iterates through the container to find all documents.
*/
private async* getResourceEntries(identifier: ResourceIdentifier): AsyncIterableIterator<[string, T]> {
protected async* getResourceEntries(identifier: ResourceIdentifier): AsyncIterableIterator<[string, T]> {
const representation = await this.safelyGetResource(identifier);
if (representation) {
if (isContainerIdentifier(identifier)) {
@@ -101,7 +101,7 @@ export class JsonResourceStorage<T> implements KeyValueStorage<string, T> {
* Returns undefined if a 404 error is thrown.
* Re-throws the error in all other cases.
*/
private async safelyGetResource(identifier: ResourceIdentifier): Promise<Representation | undefined> {
protected async safelyGetResource(identifier: ResourceIdentifier): Promise<Representation | undefined> {
let representation: Representation | undefined;
try {
const preferences = isContainerIdentifier(identifier) ? {} : { type: { 'application/json': 1 }};
@@ -119,7 +119,7 @@ export class JsonResourceStorage<T> implements KeyValueStorage<string, T> {
/**
* Converts a key into an identifier for internal storage.
*/
private keyToIdentifier(key: string): ResourceIdentifier {
protected keyToIdentifier(key: string): ResourceIdentifier {
// Parse the key as a file path
const parsedPath = parse(key);
// Hash long filenames to prevent issues with the underlying storage.
@@ -135,7 +135,7 @@ export class JsonResourceStorage<T> implements KeyValueStorage<string, T> {
/**
* Converts an internal identifier to an external key.
*/
private identifierToKey(identifier: ResourceIdentifier): string {
protected identifierToKey(identifier: ResourceIdentifier): string {
// Due to the usage of `joinUrl` we don't know for sure if there was a preceding slash,
// so we always add one for consistency.
// In practice this would only be an issue if a class depends
@@ -143,7 +143,7 @@ export class JsonResourceStorage<T> implements KeyValueStorage<string, T> {
return ensureLeadingSlash(identifier.path.slice(this.container.length));
}
private applyHash(key: string): string {
protected applyHash(key: string): string {
return createHash('sha256').update(key).digest('hex');
}
}