UNPKG

baqend

Version:

Baqend JavaScript SDK

1,467 lines (1,252 loc) 44.1 kB
import * as messages from './message'; import { DeviceFactory, Entity, EntityFactory, FileFactory, LoginOption, ManagedFactory, OAuthOptions, UserFactory, } from './binding'; import { atob, Class, deprecated, isNode, JsonMap, Lockable, openWindow, uuid, } from './util'; import { Connector, Message, OAuthMessage, RestSpecification, StatusCode } from './connector'; import { BloomFilter } from './caching'; import { GeoPoint } from './GeoPoint'; import type { ConnectData, EntityManagerFactory } from './EntityManagerFactory'; import * as model from './model'; import type { Metamodel } from './metamodel'; import { EntityType, ManagedType, MapAttribute, PluralAttribute, } from './metamodel'; import {Builder, Query} from './query'; import { EntityExistsError, IllegalEntityError, PersistentError } from './error'; import { Code, Logger, Metadata, Modules, PushMessage, TokenStorage, ValidationResult, Validator, } from './intersection'; import { appendQueryParams, CACHE_REPLACEMENT_SUPPORTED } from './connector/Message'; import { MFAError } from './error/MFAError'; import { Base64 } from './util/Base64'; import { MFAResponse } from './util/Mfa'; import * as message from "./message"; const DB_PREFIX = '/db/'; type MessageFactory = (state: Metadata, json: JsonMap) => Message; export class EntityManager extends Lockable { /** * Constructor for a new List collection */ public readonly List = Array; /** * Constructor for a new Set collection */ public readonly Set = Set; /** * Constructor for a new Map collection */ public readonly Map = Map; /** * Constructor for a new GeoPoint */ public readonly GeoPoint = GeoPoint; /** * Determine whether the entity manager is open. * true until the entity manager has been closed */ get isOpen(): boolean { return !!this.connection; } /** * The authentication token if the user is logged in currently */ get token(): string | null { return this.tokenStorage.token; } /** * Whether caching is disabled * @deprecated */ @deprecated('EntityManager.isCachingEnabled()') get isCachingDisabled() { return !this.isCachingEnabled(); } /** * Whether caching is enabled */ isCachingEnabled(): this is { bloomFilter: BloomFilter, cacheWhiteList: Set<string>, cacheBlackList: Set<string> } { return !!this.bloomFilter; } /** * Returns true if the device token is already registered, otherwise false. */ get isDeviceRegistered(): boolean { return !!this.deviceMe; } /** * The authentication token if the user is logged in currently * @param value */ set token(value: string | null) { this.tokenStorage!.update(value); } /** * Log messages can created by calling log directly as function, with a specific log level or with the helper * methods, which a members of the log method. * * Logs will be filtered by the client logger and the before they persisted. The default log level is * 'info' therefore all log messages below the given message aren't persisted. * * Examples: * <pre class="prettyprint"> // default log level ist info db.log('test message %s', 'my string'); // info: test message my string // pass a explicit log level as the first argument, one of ('trace', 'debug', 'info', 'warn', 'error') db.log('warn', 'test message %d', 123); // warn: test message 123 // debug log level will not be persisted by default, since the default logging level is info db.log('debug', 'test message %j', {number: 123}, {}); // debug: test message {"number":123} // data = {} // One additional json object can be provided, which will be persisted together with the log entry db.log('info', 'test message %s, %s', 'first', 'second', {number: 123}); // info: test message first, second // data = {number: 123} //use the log level helper db.log.info('test message', 'first', 'second', {number: 123}); // info: test message first second // data = {number: 123} //change the default log level to trace, i.e. all log levels will be persisted, note that the log level can be //additionally configured in the baqend db.log.level = 'trace'; //trace will be persisted now db.log.trace('test message', 'first', 'second', {number: 123}); // info: test message first second // data = {number: 123} * </pre> */ public readonly log: Logger = Logger.create(this); /** * The connector used for requests */ public connection : Connector | null = null; /** * All managed and cached entity instances * @type Map<String,Entity> * @private */ private entities: { [id: string]: Entity } = {}; public readonly entityManagerFactory: EntityManagerFactory; public readonly metamodel: Metamodel; public readonly code: Code; public readonly modules: Modules = new Modules(this); /** * The current logged in user object */ public me: model.User | null = null; /** * The current registered device object */ public deviceMe: model.Device | null = null; /** * Returns the tokenStorage which will be used to authorize all requests. */ public tokenStorage: TokenStorage = null as any; // is never null after em is ready /** * The bloom filter which contains staleness information of cached objects */ public bloomFilter: BloomFilter | null = null; /** * Set of object ids that were revalidated after the Bloom filter was loaded. */ public cacheWhiteList: Set<string> | null = null; /** * Set of object ids that were updated but are not yet included in the bloom filter. * This set essentially implements revalidation by side effect which does not work in Chrome. */ public cacheBlackList: Set<string> | null = null; /** * Bloom filter refresh interval in seconds. */ public bloomFilterRefresh: number = 60; /** * Bloom filter refresh Promise */ public readonly bloomFilterLock = new Lockable(); /** * A File factory for file objects. * The file factory can be called to create new instances for files. * The created instances implements the {@link File} interface */ public File: FileFactory = null as any; // is never null after the em is ready /** * the shared connection data if this EntityManager shares the credentials with the EntityManagerFactory * @private */ private connectData : ConnectData | null = null; /** * @param entityManagerFactory The factory which of this entityManager instance */ constructor(entityManagerFactory: EntityManagerFactory) { super(); this.entityManagerFactory = entityManagerFactory; this.metamodel = entityManagerFactory.metamodel; this.code = entityManagerFactory.code; } /** * Connects this entityManager, used for synchronous and asynchronous initialization * @param useSharedTokenStorage Indicates if the shared credentials should be used */ connected(useSharedTokenStorage?: boolean) { this.connection = this.entityManagerFactory.connection; this.bloomFilterRefresh = this.entityManagerFactory.staleness!; this.tokenStorage = useSharedTokenStorage ? this.entityManagerFactory.tokenStorage! : new TokenStorage(this.connection!.origin); this.connectData = useSharedTokenStorage ? this.entityManagerFactory.connectData : null; this.File = FileFactory.create(this); this._createObjectFactory(this.metamodel.embeddables); this._createObjectFactory(this.metamodel.entities); if (this.connectData) { if (this.connectData.device) { this._updateDevice(this.connectData.device); } if (this.connectData.user) { this._updateUser(this.connectData.user, true); } } const bloomFilter = this.entityManagerFactory.connectData?.bloomFilter; if (this.bloomFilterRefresh > 0 && bloomFilter && typeof atob !== 'undefined' && !isNode) { this._updateBloomFilter(bloomFilter); } } /** * @param types * @return * @private */ private _createObjectFactory(types: { [type: string]: ManagedType<any> }): void { const values = Object.values(types); for (let i = 0; i < values.length; i += 1) { const type = values[i]; const { name } = type; if (this[name]) { type.typeConstructor = this[name]; Object.defineProperty(this, name, { value: type.createObjectFactory(this), }); } else { Object.defineProperty(this, name, { get() { Object.defineProperty(this, name, { value: type.createObjectFactory(this), }); return this[name]; }, set(typeConstructor) { type.typeConstructor = typeConstructor; }, configurable: true, }); } } } send(message: Message, ignoreCredentialError = true) { if (!this.connection) { throw new Error('This EntityManager is not connected.'); } const msg = message; msg.tokenStorage(this.tokenStorage); let result = this.connection.send(msg); if (!ignoreCredentialError) { result = result.catch((e) => { if (e.status === StatusCode.BAD_CREDENTIALS) { this._logout(); } throw e; }); } return result; } /** * Get an instance whose state may be lazily fetched * * If the requested instance does not exist in the database, the * EntityNotFoundError is thrown when the instance state is first accessed. * The application should not expect that the instance state will be available upon detachment, * unless it was accessed by the application while the entity manager was open. * * @param entityClass * @param key * @return */ getReference<T extends Entity>(entityClass: Class<T> | string, key?: string): T { let id: string | null = null; let type: EntityType<any> | null; if (key) { const keyAsStr = key; type = this.metamodel.entity(entityClass)!!; if (keyAsStr.indexOf(DB_PREFIX) === 0) { id = keyAsStr; } else { id = `${type.ref}/${encodeURIComponent(keyAsStr)}`; } } else if (typeof entityClass === 'string') { const keyIndex = entityClass.indexOf('/', DB_PREFIX.length); // skip /db/ if (keyIndex !== -1) { id = entityClass; } type = this.metamodel.entity(keyIndex !== -1 ? entityClass.substring(0, keyIndex) : entityClass); } else { type = this.metamodel.entity(entityClass); } let entity = this.entities[id as string] as T; if (!entity) { entity = type!!.create(); const metadata = Metadata.get(entity); if (id) { metadata.id = id; } metadata.setUnavailable(); this._attach(entity); } return entity; } /** * Creates an instance of {@link Builder<T>} for query creation and execution * * The query results are instances of the resultClass argument. * * @param resultClass - the type of the query result * @return A query builder to create one ore more queries for the specified class */ createQueryBuilder<T extends Entity>(resultClass: Class<T>): Builder<T> { return new Builder(this, resultClass); } /** * Clear the persistence context, causing all managed entities to become detached * * Changes made to entities that have not been flushed to the database will not be persisted. * * @return */ clear(): void { this.entities = {}; } /** * Close an application-managed entity manager * * After the close method has been invoked, all methods on the EntityManager instance * and any Query and TypedQuery objects obtained from it will throw the IllegalStateError * except for transaction, and isOpen (which will return false). If this method * is called when the entity manager is associated with an active transaction, * the persistence context remains managed until the transaction completes. * * @return */ close(): void { this.connection = null; return this.clear(); } /** * Check if the instance is a managed entity instance belonging to the current persistence context * * @param entity - entity instance * @return boolean indicating if entity is in persistence context */ contains(entity: Entity): boolean { return !!entity && !!entity.id && this.entities[entity.id] === entity; } /** * Check if an object with the id from the given entity is already attached * * @param entity - entity instance * @return boolean indicating if entity with same id is attached */ containsById(entity: Entity): boolean { return !!(entity && entity.id && this.entities[entity.id]); } /** * Remove the given entity from the persistence context, causing a managed entity to become detached * * Unflushed changes made to the entity if any (including removal of the entity), * will not be synchronized to the database. Entities which previously referenced the detached entity will continue * to reference it. * * @param entity The entity instance to detach. * @return */ detach(entity: Entity): Promise<Entity> { const state = Metadata.get(entity); return state.withLock(() => { this.removeReference(entity); return Promise.resolve(entity); }); } /** * Resolve the depth by loading the referenced objects of the given entity * * @param entity - entity instance * @param [options] The load options * @return */ resolveDepth<T extends Entity>(entity: T, options?: { refresh?: boolean, local?: boolean, depth?: number | boolean, resolved?: Entity[] }): Promise<T> { if (!options || !options.depth) { return Promise.resolve(entity); } const resolved = options.resolved || []; const promises: Promise<Entity | null>[] = []; const subOptions = { ...options, resolved, depth: options.depth === true ? true : options.depth - 1, }; this.getSubEntities(entity, 1).forEach((subEntity: Entity) => { if (subEntity !== null && resolved!.indexOf(subEntity) === -1 && subEntity.id) { resolved!.push(subEntity); promises.push(this.load(subEntity.id, undefined, subOptions)); } }); return Promise.all(promises).then(() => entity); } /** * Search for an entity of the specified oid * * If the entity instance is contained in the persistence context, it is returned from there. * * @param entityClass - entity class * @param oid - Object ID * @param [options] The load options. * @return the loaded entity or null */ load<T extends Entity>( entityClass: Class<T> | string, oid?: string, options?: { refresh?: boolean, local?: boolean, resolved?: Entity[] }, ) : Promise<T | null> { const opt = options || {}; const entity = this.getReference(entityClass, oid); const state = Metadata.get(entity); if (!opt.refresh && opt.local && state.isAvailable) { return this.resolveDepth(entity, opt); } const msg = new messages.GetObject(state.bucket, state.key!); this.ensureCacheHeader(entity.id, msg, opt.refresh); return this.send(msg).then((response) => { // refresh object if loaded older version from cache // chrome doesn't using cache when ifNoneMatch is set if (entity.version && entity.version > response.entity.version) { opt.refresh = true; return this.load(entityClass, oid, opt); } this.addToWhiteList(response.entity.id); if (response.status !== StatusCode.NOT_MODIFIED) { state.type.fromJsonValue(state, response.entity, entity, { persisting: true }); } return this.resolveDepth(entity, opt); }, (e) => { if (e.status === StatusCode.OBJECT_NOT_FOUND) { this.removeReference(entity); state.setRemoved(); return null; } throw e; }); } /** * @param entity * @param options * @return */ insert(entity: Entity, options?: { depth?: number | boolean, refresh?: boolean }): Promise<Entity> { const opt = options || {}; let isNew: boolean; return this._save(entity, opt, (state, json) => { if (state.version) { throw new PersistentError('Existing objects can\'t be inserted.'); } isNew = !state.id; return new messages.CreateObject(state.bucket, json); }).then((val) => { if (isNew) { this._attach(entity); } return val; }); } /** * @param entity * @param options * @return */ update(entity: Entity, options?: { force?: boolean, depth?: number | boolean, refresh?: boolean }): Promise<Entity> { const opt = options || {}; return this._save(entity, opt, (state, json) => { if (!state.version) { throw new PersistentError('New objects can\'t be inserted.'); } if (opt.force) { const { version, ...jsonWithoutVersion } = json; return new messages.ReplaceObject(state.bucket, state.key!, jsonWithoutVersion) .ifMatch('*'); } return new messages.ReplaceObject(state.bucket, state.key!, json) .ifMatch(`${state.version}`); }); } /** * @param entity * @param options The save options * @param withoutLock Set true to save the entity without locking * @return */ save<E extends Entity>(entity: E, options?: { force?: boolean, depth?: number | boolean, refresh?: boolean }, withoutLock = false): Promise<E> { const opt = options || {}; const msgFactory = (state: Metadata, json: JsonMap) => { if (opt.force) { if (!state.id) { throw new PersistentError('New special objects can\'t be forcefully saved.'); } const { version, ...jsonWithoutVersion } = json; return new messages.ReplaceObject(state.bucket, state.key!, jsonWithoutVersion); } if (state.version) { return new messages.ReplaceObject(state.bucket, state.key!, json) .ifMatch(state.version); } return new messages.CreateObject(state.bucket, json); }; return withoutLock ? this._locklessSave(entity, opt, msgFactory) : this._save(entity, opt, msgFactory); } /** * @param entity * @param cb pre-safe callback * @return */ optimisticSave<E extends Entity>(entity: E, cb: (entity: E, abort: () => void) => any): Promise<E> { return Metadata.get(entity).withLock(() => this._optimisticSave(entity, cb)); } /** * @param entity * @param cb pre-safe callback * @return * @private */ private _optimisticSave<E extends Entity>(entity: E, cb: (entity: E, abort: () => void) => any): Promise<E> { let abort = false; const abortFn = () => { abort = true; }; const promise = Promise.resolve(cb(entity, abortFn)); if (abort) { return Promise.resolve(entity); } return promise.then(() => ( this.save(entity, {}, true) .catch((e) => { if (e.status === 412) { return this.refresh(entity, {}) .then(() => this._optimisticSave(entity, cb)); } throw e; }) )); } /** * Save the object state without locking * @param entity * @param options * @param msgFactory * @return * @private */ private _locklessSave<T extends Entity>(entity: T, options: { depth?: number | boolean, refresh?: boolean }, msgFactory: MessageFactory): Promise<T> { this.attach(entity); const state = Metadata.get(entity); let refPromises; let json: JsonMap; if (state.isAvailable) { // getting json will check all collections changes, therefore we must do it before proofing the dirty state json = state.type.toJsonValue(state, entity, { persisting: true, }) as JsonMap; } if (state.isDirty) { if (!options.refresh) { state.setPersistent(); } const sendPromise = this.send(msgFactory(state, json!!)).then((response) => { if (state.id && state.id !== response.entity.id) { this.removeReference(entity); state.id = response.entity.id; this._attach(entity); } state.type.fromJsonValue(state, response.entity, entity, { persisting: options.refresh, onlyMetadata: !options.refresh, }); return entity; }, (e) => { if (e.status === StatusCode.OBJECT_NOT_FOUND) { this.removeReference(entity); state.setRemoved(); return null; } state.setDirty(); throw e; }); refPromises = [sendPromise]; } else { refPromises = [Promise.resolve(entity)]; } const subOptions = { ...options }; subOptions.depth = 0; this.getSubEntities(entity, options.depth).forEach((sub) => { refPromises.push(this._save(sub, subOptions, msgFactory)); }); return Promise.all(refPromises).then(() => entity); } /** * Save and lock the object state * @param entity * @param options * @param msgFactory * @return * @private */ private _save<T extends Entity>(entity: T, options: { depth?: number | boolean, refresh?: boolean }, msgFactory: MessageFactory): Promise<T> { this.ensureBloomFilterFreshness(); const state = Metadata.get(entity); if (state.version) { this.addToBlackList(entity.id); } return state.withLock(() => this._locklessSave(entity, options, msgFactory)); } /** * Returns all referenced sub entities for the given depth and root entity * @param entity * @param depth * @param [resolved] * @param initialEntity * @return */ getSubEntities(entity: Entity, depth?: boolean | number, resolved: Entity[] = [], initialEntity? : Entity): Entity[] { if (!depth) { return resolved; } const obj = initialEntity || entity; const state = Metadata.get(entity); const iter = state.type.references(); for (let item = iter.next(); !item.done; item = iter.next()) { const { value } = item; const subEntities = this.getSubEntitiesByPath(entity, value.path); for (let i = 0, len = subEntities.length; i < len; i += 1) { const subEntity = subEntities[i]; if (resolved.indexOf(subEntity) === -1 && subEntity !== obj) { resolved.push(subEntity); this.getSubEntities(subEntity, depth === true ? depth : depth - 1, resolved, obj); } } } return resolved; } /** * Returns all referenced one level sub entities for the given path * @param entity * @param path * @return */ getSubEntitiesByPath(entity: Entity, path: string[]): Entity[] { let subEntities = [entity]; path.forEach((attributeName) => { const tmpSubEntities: Entity[] = []; subEntities.forEach((subEntity) => { const curEntity = subEntity[attributeName]; if (!curEntity) { return; } const attribute = this.metamodel.managedType(subEntity.constructor)!.getAttribute(attributeName); if (attribute instanceof PluralAttribute) { const iter = curEntity.entries(); for (let item = iter.next(); !item.done; item = iter.next()) { const entry = item.value; tmpSubEntities.push(entry[1]); if (attribute instanceof MapAttribute && attribute.keyType.isEntity) { tmpSubEntities.push(entry[0]); } } } else { tmpSubEntities.push(curEntity); } }); subEntities = tmpSubEntities; }); return subEntities; } /** * Delete the entity instance. * @param entity * @param options The delete options * @return */ delete<T extends Entity>(entity: T, options?: { force?: boolean, depth?: number | boolean }): Promise<T> { const opt = options || {}; this.attach(entity); const state = Metadata.get(entity); return state.withLock(() => { if (!state.version && !opt.force) { throw new IllegalEntityError(entity); } const msg = new messages.DeleteObject(state.bucket, state.key!); this.addToBlackList(entity.id); if (!opt.force) { msg.ifMatch(`${state.version}`); } const refPromises: Promise<Entity>[] = [this.send(msg).then(() => { this.removeReference(entity); state.setRemoved(); return entity; })]; const subOptions = { ...opt }; subOptions.depth = 0; this.getSubEntities(entity, opt.depth).forEach((sub) => { refPromises.push(this.delete(sub, subOptions)); }); return Promise.all(refPromises).then(() => entity); }); } /** * Synchronize the persistence context to the underlying database. * * @return */ flush(): Promise<any> { throw new Error('Not implemented.'); } /** * Make an instance managed and persistent. * @param entity - entity instance * @return */ persist(entity: Entity): void { this.attach(entity); } /** * Refresh the state of the instance from the database, overwriting changes made to the entity, if any. * @param entity - entity instance * @param options The refresh options * @return */ refresh<T extends Entity>(entity: T, options: { depth?: number | boolean }): Promise<T | null> { if (!entity.id) { // entity is not persisted so far return Promise.resolve(entity); } return this.load(entity.id, undefined, { ...options, refresh: true }); } /** * Attach the instance to this database context, if it is not already attached * @param entity The entity to attach * @return */ attach(entity: Entity): void { if (!this.contains(entity)) { const type = this.metamodel.entity(entity.constructor); if (!type) { throw new IllegalEntityError(entity); } if (this.containsById(entity)) { throw new EntityExistsError(entity); } this._attach(entity); } } private _attach(entity: Entity) { const metadata = Metadata.get(entity); if (metadata.isAttached) { if (metadata.db !== this) { throw new EntityExistsError(entity); } } else { metadata.db = this; } if (!metadata.id) { if (metadata.type.name !== 'User' && metadata.type.name !== 'Role' && metadata.type.name !== 'logs.AppLog') { metadata.id = `${DB_PREFIX + metadata.type.name}/${uuid()}`; } } if (metadata.id) { this.entities[metadata.id] = entity; } } removeReference(entity: Entity) { const state = Metadata.get(entity); if (!state || !state.id) { throw new IllegalEntityError(entity); } delete this.entities[state.id]; } register(user: model.User, password: string, loginOption: LoginOption | boolean) { const login = loginOption > LoginOption.NO_LOGIN; if (this.me && login) { throw new PersistentError('User is already logged in.'); } return this.withLock(() => { const msg = new messages.Register({ user, password, login }); return this._userRequest(msg, loginOption); }); } login(username: string, password: string, loginOption: LoginOption | boolean) { if (this.me) { throw new PersistentError('User is already logged in.'); } return this.withLock(() => { const msg = new messages.Login({ username, password }); return this._userRequest(msg, loginOption); }); } logout() { return this.withLock(() => this.send(new messages.Logout()).then(this._logout.bind(this))); } /** * Starts the MFA initiate process - note you must be logged in, to start the mfa setup process * * @returns A promise that resolves to an object with the following properties: * - qrCode: A Base64 representation of the QR code for MFA setup. * - keyUri: The URI for the MFA secret key. * @example * const { qrCode, keyUri } = await db.initMFA(); * const code = await setupMFADevice(qrCode, keyUri); * const user = await db.finishMFA(code); */ async initMFA(): Promise<MFAResponse> { return this.send(new messages.MFAInitChallenge()).then((resp) => { return { qrCode: resp.entity.qrCode as Base64<'png'>, keyUri: resp.entity.keyUri as string }; }); } /** * Finishes the MFA (Multi-Factor Authentication) initiation process. * * @param code - The verification code for MFA. * @returns A promise that resolves with the user object of the logged-in user. */ public finishMFA(code: number): Promise<model.User> { return this.send(new messages.MFAInitFinish({ code })).then((resp) => { return this.User.me!; // to be here user is already logged in; }); } /** * Submit a verification code after a login * * @param code - A 6 digit verification code * @param token - An MFA token obtained during the login process * @return The logged-in user object */ async submitMFACode(code: number, token: string): Promise<model.User > { const loginType = this.tokenStorage.temporary ? LoginOption.SESSION_LOGIN : LoginOption.PERSIST_LOGIN; const msg = new messages.MFAToken({ authToken: token, code, global: loginType === LoginOption.PERSIST_LOGIN, }); return this.withLock(() => this._userRequest(msg, loginType)) as Promise< model.User>; } /** * Disables Multi-Factor Authentication for the currently logged in user. * * @throws {PersistentError} - Thrown when the user is not logged in. * @return A promise that resolves when Multi-Factor Authentication is successfully disabled. */ disableMFA(): Promise<any> { if (!this.User.me) throw new PersistentError('User not Logged in'); return this.send(new messages.MFADelete()); } /** * Returns the current MFA status of the user * * @returns A promise that resolves to the MFA status of the user. * Possible values are 'ENABLED' if MFA is enabled, 'DISABLED' if MFA is * disabled, or 'PENDING' if MFA status is pending. */ getMFAStatus(): Promise<'ENABLED' | 'DISABLED' | 'PENDING'> { return this.send(new messages.MFAStatus()).then((resp) => resp.entity); } loginWithOAuth(provider: string, options: OAuthOptions): any | string | Promise<model.User | null> { if (!this.connection) { throw new Error('This EntityManager is not connected.'); } if (this.me) { throw new PersistentError('User is already logged in.'); } const opt = { title: `Login with ${provider}`, timeout: 5 * 60 * 1000, state: {}, loginOption: true, oAuthVersion: 2, open: openWindow, ...options, }; if (opt.deviceCode) { return this._loginOAuthDevice(provider.toLowerCase(), opt); } if (opt.oAuthVersion !== 1 && !opt.path && !opt.deviceCode) { throw new Error('No OAuth path is provided to start the OAuth flow.'); } if (opt.redirect) { Object.assign(opt.state, { redirect: opt.redirect, loginOption: opt.loginOption }); } const oAuthEndpoint = `${this.connection.origin}${this.connection.basePath}/db/User/OAuth/${provider.toLowerCase()}`; const url = opt.oAuthVersion === 1 ? oAuthEndpoint : appendQueryParams(opt.path!, { client_id: opt.clientId, scope: opt.scope, state: JSON.stringify(opt.state), redirect_uri: oAuthEndpoint, }); const windowOptions = { title: opt.title, width: opt.width, height: opt.height, }; if (opt.redirect) { // use oauth via redirect by opening the login in the same window return opt.open(url, { target: '_self', ...windowOptions }) || url; } const req = this._userRequest(new OAuthMessage(), opt.loginOption); if (!opt.open(url, windowOptions)) { throw new Error('The OAuth flow with a Pop-Up can only be issued in browsers. Add a redirect URL to the options to return to your app via that redirect after the OAuth flow succeed.'); } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new PersistentError('OAuth login timeout.')); }, opt.timeout); req.then(resolve, reject).then(() => { clearTimeout(timeout); }); }); } private _loginOAuthDevice(provider: string, opt: OAuthOptions): Promise<model.User | null> { return this._userRequest(new messages.OAuth2(provider, opt.deviceCode), opt.loginOption) .catch(() => new Promise((resolve) => setTimeout(resolve, 5000)) .then(() => this._loginOAuthDevice(provider, opt))) as Promise<model.User | null>; } renew(loginOption?: LoginOption | boolean) { return this.withLock(() => { const msg = new messages.Me(); return this._userRequest(msg, loginOption); }); } newPassword(username: string, password: string, newPassword: string) { return this.withLock(() => { const msg = new messages.NewPassword({ username, password, newPassword }); return this.send(msg, true).then((response) => this._updateUser(response.entity)); }); } newPasswordWithToken(token: string, newPassword: string, loginOption?: LoginOption | boolean) { return this.withLock(() => ( this._userRequest(new messages.NewPassword({ token, newPassword }), loginOption) )); } resetPassword(username: string) { return this.send(new messages.ResetPassword({ username })); } changeUsername(username: string, newUsername: string, password: string) { return this.send(new messages.ChangeUsername({ username, newUsername, password })); } private _updateUser(obj: JsonMap, updateMe = false) { const user = this.getReference(obj.id as string) as model.User; const metadata = Metadata.get(user); metadata.type.fromJsonValue(metadata, obj, user, { persisting: true }); if (updateMe) { this.me = user; if (this.connectData) { this.connectData.user = obj; } } return user; } private _logout() { this.me = null; this.token = null; if (this.connectData) { delete this.connectData.user; } } private _userRequest(msg: Message, loginOption?: LoginOption | boolean) { const opt = loginOption === undefined ? true : loginOption; const login = opt > LoginOption.NO_LOGIN; if (login) { this.tokenStorage.temporary = opt < LoginOption.PERSIST_LOGIN; } return this.send(msg, !login) .then( (response) => { return response.entity ? this._updateUser(response.entity, login) : null; }, (e) => { if (e.status === StatusCode.OBJECT_NOT_FOUND) { if (login) { this._logout(); } return null; } if (e.status === StatusCode.FORBIDDEN) { const { data } = e; throw new MFAError(data['baqend-mfa-auth-token']); // If MFA is required: throw an error containing the auth token } throw e; }, ); } /** * @param deviceType The OS of the device (IOS/Android) * @param subscription WebPush subscription * @param device * @return */ registerDevice(deviceType: string, subscription: PushSubscription | { token: string }, device: model.Device | null): Promise<model.Device> { const msg = new messages.DeviceRegister({ devicetype: deviceType, subscription: subscription as JsonMap, device }); msg.withCredentials = true; return this.send(msg) .then((response) => this._updateDevice(response.entity)); } private _updateDevice(obj: JsonMap) { const device = this.getReference(obj.id as string); const metadata = Metadata.get(device); metadata.type.fromJsonValue(metadata, obj, device, { persisting: true }); this.deviceMe = device; if (this.connectData) { this.connectData.device = obj; } return device; } checkDeviceRegistration(): Promise<boolean> { return this.send(new messages.DeviceRegistered()) .then(() => true, (e) => { if (e.status === StatusCode.OBJECT_NOT_FOUND) { return false; } throw e; }); } pushDevice(pushMessage: PushMessage) { return this.send(new messages.DevicePush(pushMessage.toJSON())); } /** * The given entity will be checked by the validation code of the entity type. * * @param entity * @return result */ validate(entity: Entity): ValidationResult { const { type } = Metadata.get(entity); const result = new ValidationResult(); const iter = type.attributes(); for (let item = iter.next(); !item.done; item = iter.next()) { const validate = new Validator(item.value.name, entity); result.fields[validate.key] = validate; } const { validationCode } = type; if (validationCode) { validationCode(result.fields); } return result; } /** * Adds the given object id to the cacheWhiteList if needed. * @param objectId The id to add. * @return */ addToWhiteList(objectId: string): void { if (this.isCachingEnabled()) { if (this.bloomFilter.contains(objectId) || this.cacheBlackList.has(objectId)) { this.cacheWhiteList.add(objectId); } } } /** * Adds the given object id to the cacheBlackList if needed. * @param objectId The id to add. * @return */ addToBlackList(objectId: string | null): void { if (this.isCachingEnabled() && objectId) { if (!this.bloomFilter.contains(objectId)) { this.cacheBlackList.add(objectId); } this.cacheWhiteList.delete(objectId); } } refreshBloomFilter(): Promise<BloomFilter | null> { if (!this.isCachingEnabled()) { return Promise.resolve(null); } const msg = new messages.GetBloomFilter(); msg.noCache(); return this.send(msg).then((response) => { this._updateBloomFilter(response.entity); return this.bloomFilter; }); } private _updateBloomFilter(bloomFilter: JsonMap) { this.bloomFilter = new BloomFilter(bloomFilter as { m: number, h: number, b: string }); this.cacheWhiteList = new Set(); this.cacheBlackList = new Set(); } /** * Checks the freshness of the bloom filter and does a reload if necessary */ ensureBloomFilterFreshness(): void { if (!this.isCachingEnabled()) { return; } const now = new Date().getTime(); const refreshRate = this.bloomFilterRefresh * 1000; if (this.bloomFilterLock.isReady && now - this.bloomFilter.creation > refreshRate) { this.bloomFilterLock.withLock(() => this.refreshBloomFilter()); } } /** * Checks for a given id, if revalidation is required, the resource is stale or caching was disabled * @param id The object id to check * @return Indicates if the resource must be revalidated */ mustRevalidate(id: string): boolean { if (isNode) { return false; } this.ensureBloomFilterFreshness(); if (!this.isCachingEnabled() || !this.bloomFilterLock.isReady) { return true; } if (CACHE_REPLACEMENT_SUPPORTED && this.cacheWhiteList.has(id)) { return false; } return this.cacheBlackList.has(id) || this.bloomFilter.contains(id); } /** * @param id To check the bloom filter * @param message To attach the headers * @param refresh To force the reload headers * @return */ ensureCacheHeader(id: string | null, message: Message, refresh?: boolean): void { const noCache = refresh || !id || this.mustRevalidate(id); if (noCache) { message.noCache(); } } /** * Creates a absolute url for the given relative one * @param relativePath the relative url * @param authorize indicates if authorization credentials should be generated and be attached to the url * @return a absolute url which is optionally signed with a resource token which authenticates the currently * logged in user */ createURL(relativePath: string, authorize?: boolean): Promise<string> { const { connection } = this; if (!connection) { throw new Error('This EntityManager is not connected.'); } return this.tokenStorage.signPath(connection.basePath + relativePath, authorize) .then((path) => { let url = connection.origin + path; if (this.mustRevalidate(relativePath)) { url += `${authorize ? '&' : '?'}BCB`; } return url; }); } /** * Requests a perpetual token for the given user * * Only users with the admin role are allowed to request an API token. * * @param entityClass * @param user The user object or id of the user object * @return */ requestAPIToken(entityClass: Class<model.User>, user: model.User | string): Promise<JsonMap> { const userObj = this._getUserReference(entityClass, user); const msg = new messages.UserToken(userObj.key!); return this.send(msg).then((resp) => resp.entity); } /** * Revoke all created tokens for the given user * * This method will revoke all previously issued tokens and the user must login again. * * @param entityClass * @param user The user object or id of the user object */ revokeAllTokens(entityClass: Class<model.User>, user: model.User | string): Promise<any> { const userObj = this._getUserReference(entityClass, user); const msg = new messages.RevokeUserToken(userObj.key!); return this.send(msg); } executeQuery<T extends Entity>(resultClass: Class<T>, query: string, triggeredBy: string): Promise<any[]> { const type = resultClass ? this.metamodel.entity(resultClass) : null; const backendType = type?.getMetadata("backendType") if (!type || !backendType || backendType !== "analytics") { throw new Error("Only analytics entities can execute queries") } const uriSize = (this.connection?.host.length || 0) + query.length; let msg; if (uriSize > Query.MAX_URI_SIZE) { msg = new messages.AdhocQueryPOST(type.name, undefined, undefined, undefined, triggeredBy, query) .entity(query, 'text'); } else { msg = new message.AdhocQuery(type.name, query, undefined, undefined, undefined, undefined, undefined, triggeredBy) } return this.send(msg).then((response) => response.entity); } private _getUserReference(entityClass: Class<model.User> | string, user: string | model.User): model.User { if (typeof user === 'string') { return this.getReference(entityClass, user); } return user; } } export interface EntityManager extends Lockable { [Class: string]: EntityFactory<any> | ManagedFactory<any> | any; /** * An User factory for user objects. * The User factory can be called to create new instances of users or can be used to register/login/logout users. * The created instances implements the {@link model.User} interface */ readonly User: UserFactory; /** * An Role factory for role objects. * The Role factory can be called to create new instances of roles, later on users can be attached to roles to manage * the access permissions through this role * The created instances implements the {@link model.Role} interface */ readonly Role: EntityFactory<model.Role>; /** * An Device factory for user objects. * The Device factory can be called to create new instances of devices or can be used to register, push to and * check registration status of devices. */ readonly Device: DeviceFactory; /** * An Object factory for entity or embedded objects, * that can be accessed by the type name of the entity type. * An object factory can be called to create new instances of the type. * The created instances implements the {@link Entity} or the {@link Managed} interface * whenever the class is an entity or embedded object * @name [YourEntityClass: string] * @memberOf EntityManager.prototype * @type {*} */ }