diff --git a/dev/tool/src/index.ts b/dev/tool/src/index.ts index 7be5ea2ddb..fd421f4a4d 100644 --- a/dev/tool/src/index.ts +++ b/dev/tool/src/index.ts @@ -30,10 +30,8 @@ import { replacePassword, setAccountAdmin, setRole, - updateWorkspace, upgradeWorkspace, - type Workspace, - type WorkspaceInfo + UpgradeWorker } from '@hcengineering/account' import { setMetadata } from '@hcengineering/platform' import { @@ -45,7 +43,7 @@ import { restore } from '@hcengineering/server-backup' import serverToken, { decodeToken, generateToken } from '@hcengineering/server-token' -import toolPlugin, { FileModelLogger } from '@hcengineering/server-tool' +import toolPlugin from '@hcengineering/server-tool' import { program, type Command } from 'commander' import { type Db, type MongoClient } from 'mongodb' @@ -56,7 +54,6 @@ import core, { getWorkspaceId, MeasureMetricsContext, metricsToString, - RateLimiter, versionToString, type AccountRole, type Data, @@ -69,7 +66,6 @@ import { getMongoClient, getWorkspaceDB } from '@hcengineering/mongo' import { openAIConfigDefaults } from '@hcengineering/openai' import { type StorageAdapter } from '@hcengineering/server-core' import { deepEqual } from 'fast-equals' -import path from 'path' import { benchmark } from './benchmark' import { cleanArchivedSpaces, @@ -345,93 +341,15 @@ export function devTool ( .option('-f|--force [force]', 'Force update', false) .action(async (cmd: { parallel: string, logs: string, retry: string, force: boolean, console: boolean }) => { const { mongodbUri, version, txes, migrateOperations } = prepareTools() - await withDatabase(mongodbUri, async (db) => { - const workspaces = await listWorkspacesRaw(db, productId) - workspaces.sort((a, b) => b.lastVisit - a.lastVisit) - - // We need to update workspaces with missing workspaceUrl - for (const ws of workspaces) { - if (ws.workspaceUrl == null) { - const upd: Partial = { - workspaceUrl: ws.workspace - } - if (ws.workspaceName == null) { - upd.workspaceName = ws.workspace - } - await updateWorkspace(db, productId, ws, upd) - } - } - - const withError: string[] = [] - let toProcess = workspaces.length - const st = Date.now() - - async function _upgradeWorkspace (ws: WorkspaceInfo): Promise { - if (ws.disabled === true) { - return - } - const t = Date.now() - const logger = cmd.console - ? consoleModelLogger - : new FileModelLogger(path.join(cmd.logs, `${ws.workspace}.log`)) - - const avgTime = (Date.now() - st) / (workspaces.length - toProcess + 1) - console.log( - '----------------------------------------------------------\n---UPGRADING----', - 'pending: ', - toProcess, - 'ETA: ', - Math.floor(avgTime * toProcess), - ws.workspace - ) - toProcess-- - try { - await upgradeWorkspace( - toolCtx, - version, - txes, - migrateOperations, - productId, - db, - ws.workspaceUrl ?? ws.workspace, - logger, - cmd.force - ) - console.log('---done---------', 'pending: ', toProcess, 'TIME:', Date.now() - t, ws.workspace) - } catch (err: any) { - withError.push(ws.workspace) - logger.log('error', JSON.stringify(err)) - console.log(' FAILED-------', 'pending: ', toProcess, 'TIME:', Date.now() - t, ws.workspace) - } finally { - if (!cmd.console) { - ;(logger as FileModelLogger).close() - } - } - } - if (cmd.parallel !== '0') { - const parallel = parseInt(cmd.parallel) ?? 1 - const rateLimit = new RateLimiter(parallel) - console.log('parallel upgrade', parallel, cmd.parallel) - await Promise.all( - workspaces.map((it) => - rateLimit.add(() => { - return _upgradeWorkspace(it) - }) - ) - ) - console.log('Upgrade done') - // console.log((process as any)._getActiveHandles()) - // console.log((process as any)._getActiveRequests()) - // process.exit() - } else { - console.log('UPGRADE write logs at:', cmd.logs) - for (const ws of workspaces) { - await _upgradeWorkspace(ws) - } - if (withError.length > 0) { - console.log('Failed workspaces', withError) - } - } + await withDatabase(mongodbUri, async (db, client) => { + const worker = new UpgradeWorker(db, client, version, txes, migrateOperations, productId) + await worker.upgradeAll(toolCtx, { + errorHandler: async (ws, err) => {}, + force: cmd.force, + console: cmd.console, + logs: cmd.logs, + parallel: parseInt(cmd.parallel ?? '1') + }) }) }) diff --git a/models/all/src/index.ts b/models/all/src/index.ts index b5cdf01b9d..89014f3ac4 100644 --- a/models/all/src/index.ts +++ b/models/all/src/index.ts @@ -26,8 +26,8 @@ import contact, { contactId, createModel as contactModel } from '@hcengineering/ import { createModel as coreModel } from '@hcengineering/model-core' import document, { documentId, createModel as documentModel } from '@hcengineering/model-document' import gmail, { gmailId, createModel as gmailModel } from '@hcengineering/model-gmail' +import { guestId, createModel as guestModel } from '@hcengineering/model-guest' import hr, { hrId, createModel as hrModel } from '@hcengineering/model-hr' -import { timeId, createModel as timeModel } from '@hcengineering/model-time' import inventory, { inventoryId, createModel as inventoryModel } from '@hcengineering/model-inventory' import lead, { leadId, createModel as leadModel } from '@hcengineering/model-lead' import notification, { notificationId, createModel as notificationModel } from '@hcengineering/model-notification' @@ -37,16 +37,17 @@ import recruit, { recruitId, createModel as recruitModel } from '@hcengineering/ import { requestId, createModel as requestModel } from '@hcengineering/model-request' import { serverActivityId, createModel as serverActivityModel } from '@hcengineering/model-server-activity' import { serverAttachmentId, createModel as serverAttachmentModel } from '@hcengineering/model-server-attachment' +import { serverCalendarId, createModel as serverCalendarModel } from '@hcengineering/model-server-calendar' +import { serverChunterId, createModel as serverChunterModel } from '@hcengineering/model-server-chunter' import { serverCollaborationId, createModel as serverCollaborationModel } from '@hcengineering/model-server-collaboration' -import { serverCalendarId, createModel as serverCalendarModel } from '@hcengineering/model-server-calendar' -import { serverChunterId, createModel as serverChunterModel } from '@hcengineering/model-server-chunter' import { serverContactId, createModel as serverContactModel } from '@hcengineering/model-server-contact' import { serverCoreId, createModel as serverCoreModel } from '@hcengineering/model-server-core' import { serverDocumentId, createModel as serverDocumentModel } from '@hcengineering/model-server-document' import { serverGmailId, createModel as serverGmailModel } from '@hcengineering/model-server-gmail' +import { serverGuestId, createModel as serverGuestModel } from '@hcengineering/model-server-guest' import { serverHrId, createModel as serverHrModel } from '@hcengineering/model-server-hr' import { serverInventoryId, createModel as serverInventoryModel } from '@hcengineering/model-server-inventory' import { serverLeadId, createModel as serverLeadModel } from '@hcengineering/model-server-lead' @@ -58,6 +59,7 @@ import { serverTagsId, createModel as serverTagsModel } from '@hcengineering/mod import { serverTaskId, createModel as serverTaskModel } from '@hcengineering/model-server-task' import { serverTelegramId, createModel as serverTelegramModel } from '@hcengineering/model-server-telegram' import { serverTemplatesId, createModel as serverTemplatesModel } from '@hcengineering/model-server-templates' +import { serverTimeId, createModel as serverTimeModel } from '@hcengineering/model-server-time' import { serverTrackerId, createModel as serverTrackerModel } from '@hcengineering/model-server-tracker' import { serverViewId, createModel as serverViewModel } from '@hcengineering/model-server-view' import setting, { settingId, createModel as settingModel } from '@hcengineering/model-setting' @@ -67,12 +69,10 @@ import { taskId, createModel as taskModel } from '@hcengineering/model-task' import telegram, { telegramId, createModel as telegramModel } from '@hcengineering/model-telegram' import { templatesId, createModel as templatesModel } from '@hcengineering/model-templates' import { textEditorId, createModel as textEditorModel } from '@hcengineering/model-text-editor' +import { timeId, createModel as timeModel } from '@hcengineering/model-time' import tracker, { trackerId, createModel as trackerModel } from '@hcengineering/model-tracker' import view, { viewId, createModel as viewModel } from '@hcengineering/model-view' import workbench, { workbenchId, createModel as workbenchModel } from '@hcengineering/model-workbench' -import { guestId, createModel as guestModel } from '@hcengineering/model-guest' -import { serverGuestId, createModel as serverGuestModel } from '@hcengineering/model-server-guest' -import { serverTimeId, createModel as serverTimeModel } from '@hcengineering/model-server-time' import { openAIId, createModel as serverOpenAI } from '@hcengineering/model-server-openai' import { createModel as serverTranslate, translateId } from '@hcengineering/model-server-translate' @@ -95,6 +95,8 @@ export function getModelVersion (): Data { return { major: 0, minor: 6, patch: 0 } } +export type { MigrateOperation } from '@hcengineering/model' + /** * @public * @param enabled - a set of enabled plugins diff --git a/models/core/src/migration.ts b/models/core/src/migration.ts index c13d8a2d0e..3c4100827d 100644 --- a/models/core/src/migration.ts +++ b/models/core/src/migration.ts @@ -26,11 +26,9 @@ export const coreOperation: MigrateOperation = { // We need to delete all documents in doc index state for missing classes const allClasses = client.hierarchy.getDescendants(core.class.Doc) const allIndexed = allClasses.filter((it) => isClassIndexable(client.hierarchy, it)) - const indexed = new Set(allIndexed) - const skipped = allClasses.filter((it) => !indexed.has(it)) // Next remove all non indexed classes and missing classes as well. - const updated = await client.update( + await client.update( DOMAIN_DOC_INDEX_STATE, { objectClass: { $nin: allIndexed } }, { @@ -39,7 +37,6 @@ export const coreOperation: MigrateOperation = { } } ) - console.log('clearing non indexed documents', skipped, updated.updated, updated.matched) }, async upgrade (client: MigrationUpgradeClient): Promise { await tryUpgrade(client, coreId, [ diff --git a/pods/account/src/__start.ts b/pods/account/src/__start.ts index 7a71dd5c22..a5eba0ef7d 100644 --- a/pods/account/src/__start.ts +++ b/pods/account/src/__start.ts @@ -13,7 +13,6 @@ // limitations under the License. // -import { getMethods } from '@hcengineering/account' import { MeasureMetricsContext, newMetrics, type Tx } from '@hcengineering/core' import builder, { getModelVersion, migrateOperations } from '@hcengineering/model-all' import { serveAccount } from '.' @@ -25,4 +24,4 @@ const txes = JSON.parse(JSON.stringify(builder(enabled, disabled).getTxes())) as const metricsContext = new MeasureMetricsContext('account', {}, {}, newMetrics()) -serveAccount(metricsContext, getMethods(getModelVersion(), txes, migrateOperations)) +serveAccount(metricsContext, getModelVersion(), txes, migrateOperations) diff --git a/pods/account/src/index.ts b/pods/account/src/index.ts index 1e938c9683..9ac127a4a9 100644 --- a/pods/account/src/index.ts +++ b/pods/account/src/index.ts @@ -14,11 +14,18 @@ // limitations under the License. // -import account, { ACCOUNT_DB, type AccountMethod, accountId, cleanInProgressWorkspaces } from '@hcengineering/account' +import account, { + ACCOUNT_DB, + UpgradeWorker, + accountId, + cleanInProgressWorkspaces, + getMethods +} from '@hcengineering/account' import accountEn from '@hcengineering/account/lang/en.json' import accountRu from '@hcengineering/account/lang/ru.json' import { registerProviders } from '@hcengineering/auth-providers' -import { type MeasureContext } from '@hcengineering/core' +import { type Data, type MeasureContext, type Tx, type Version } from '@hcengineering/core' +import { getModelVersion, type MigrateOperation } from '@hcengineering/model-all' import platform, { Severity, Status, addStringsLoader, setMetadata } from '@hcengineering/platform' import serverToken from '@hcengineering/server-token' import toolPlugin from '@hcengineering/server-tool' @@ -32,7 +39,14 @@ import { MongoClient } from 'mongodb' /** * @public */ -export function serveAccount (measureCtx: MeasureContext, methods: Record, productId = ''): void { +export function serveAccount ( + measureCtx: MeasureContext, + version: Data, + txes: Tx[], + migrateOperations: [string, MigrateOperation][], + productId: string = '' +): void { + const methods = getMethods(getModelVersion(), txes, migrateOperations) const ACCOUNT_PORT = parseInt(process.env.ACCOUNT_PORT ?? '3000') const dbUri = process.env.MONGO_URL if (dbUri === undefined) { @@ -90,12 +104,21 @@ export function serveAccount (measureCtx: MeasureContext, methods: Record { + void client.then(async (p: MongoClient) => { const db = p.db(ACCOUNT_DB) registerProviders(measureCtx, app, router, db, productId, serverSecret, frontURL) // We need to clean workspace with creating === true, since server is restarted. void cleanInProgressWorkspaces(db, productId) + + const worker = new UpgradeWorker(db, p, version, txes, migrateOperations, productId) + await worker.upgradeAll(measureCtx, { + errorHandler: async (ws, err) => {}, + force: false, + console: false, + logs: 'upgrade-logs', + parallel: parseInt(process.env.PARALLEL ?? '1') + }) }) const extractToken = (header: IncomingHttpHeaders): string | undefined => { diff --git a/server-plugins/notification-resources/src/index.ts b/server-plugins/notification-resources/src/index.ts index 2bd4fdec64..c0b4437414 100644 --- a/server-plugins/notification-resources/src/index.ts +++ b/server-plugins/notification-resources/src/index.ts @@ -66,7 +66,6 @@ import notification, { } from '@hcengineering/notification' import { getMetadata, getResource, translate } from '@hcengineering/platform' import type { TriggerControl } from '@hcengineering/server-core' -import { stripTags } from '@hcengineering/text' import serverCore from '@hcengineering/server-core' import serverNotification, { getEmployee, @@ -74,6 +73,7 @@ import serverNotification, { getPersonAccountById, NOTIFICATION_BODY_SIZE } from '@hcengineering/server-notification' +import { stripTags } from '@hcengineering/text' import { workbenchId } from '@hcengineering/workbench' import webpush, { WebPushError } from 'web-push' import { Content, NotifyResult } from './types' diff --git a/server/account/src/__tests__/account.test_skip.ts b/server/account/src/__tests__/account.test_skip.ts index f8f949fddf..39df87a03a 100644 --- a/server/account/src/__tests__/account.test_skip.ts +++ b/server/account/src/__tests__/account.test_skip.ts @@ -17,7 +17,7 @@ import builder, { migrateOperations, getModelVersion } from '@hcengineering/model-all' import { randomBytes } from 'crypto' import { Db, MongoClient } from 'mongodb' -import accountPlugin, { getAccount, getMethods, getWorkspaceByUrl } from '..' +import accountPlugin, { getAccount, getMethods, getWorkspaceByUrl } from '../operations' import { setMetadata } from '@hcengineering/platform' import { MeasureMetricsContext } from '@hcengineering/core' diff --git a/server/account/src/index.ts b/server/account/src/index.ts index 735eb7e9be..4a1770bd09 100644 --- a/server/account/src/index.ts +++ b/server/account/src/index.ts @@ -1,5 +1,5 @@ // -// Copyright © 2022-2023 Hardcore Engineering Inc. +// Copyright © 2024 Hardcore Engineering Inc. // // Licensed under the Eclipse Public License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. You may @@ -10,1966 +10,13 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // // See the License for the specific language governing permissions and -// limitations under the f. +// limitations under the License. // -import { Analytics } from '@hcengineering/analytics' -import contact, { - AvatarType, - buildGravatarId, - checkHasGravatar, - combineName, - Employee, - getAvatarColorForId, - Person, - PersonAccount -} from '@hcengineering/contact' -import core, { - AccountRole, - BaseWorkspaceInfo, - Client, - concatLink, - Data, - generateId, - getWorkspaceId, - MeasureContext, - MeasureMetricsContext, - RateLimiter, - Ref, - systemAccountEmail, - Tx, - TxOperations, - Version, - versionToString, - WorkspaceId -} from '@hcengineering/core' -import { consoleModelLogger, MigrateOperation, ModelLogger } from '@hcengineering/model' -import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform' -import { cloneWorkspace } from '@hcengineering/server-backup' -import { decodeToken, generateToken } from '@hcengineering/server-token' -import toolPlugin, { connect, initModel, upgradeModel } from '@hcengineering/server-tool' -import { pbkdf2Sync, randomBytes } from 'crypto' -import { Binary, Db, Filter, ObjectId } from 'mongodb' -import fetch from 'node-fetch' -import accountPlugin from './plugin' - -const WORKSPACE_COLLECTION = 'workspace' -const ACCOUNT_COLLECTION = 'account' -const INVITE_COLLECTION = 'invite' - -/** - * @public - */ -export const ACCOUNT_DB = 'account' - -const getEndpoint = (): string => { - const endpoint = getMetadata(toolPlugin.metadata.Endpoint) - if (endpoint === undefined) { - throw new Error('Please provide transactor endpoint url') - } - return endpoint -} - -const getTransactor = (): string => { - const transactor = getMetadata(toolPlugin.metadata.Transactor) - if (transactor === undefined) { - throw new Error('Please provide transactor url') - } - return transactor -} - -/** - * @public - */ -export interface Account { - _id: ObjectId - email: string - // null if auth provider was used - hash: Binary | null - salt: Binary - workspaces: ObjectId[] - first: string - last: string - // Defined for server admins only - admin?: boolean - confirmed?: boolean - lastWorkspace?: number - createdOn: number - lastVisit: number -} - -/** - * @public - */ -export interface Workspace extends BaseWorkspaceInfo { - _id: ObjectId - accounts: ObjectId[] -} - -/** - * @public - */ -export interface LoginInfo { - email: string - token: string - endpoint: string -} - -/** - * @public - */ -export interface WorkspaceLoginInfo extends LoginInfo { - workspace: string - productId: string - - creating?: boolean - createProgress?: number -} - -/** - * @public - */ -export interface Invite { - _id: ObjectId - workspace: WorkspaceId - exp: number - emailMask: string - limit: number -} - -/** - * @public - */ -export type AccountInfo = Omit - -function hashWithSalt (password: string, salt: Buffer): Buffer { - return pbkdf2Sync(password, salt, 1000, 32, 'sha256') -} - -function verifyPassword (password: string, hash: Buffer, salt: Buffer): boolean { - return Buffer.compare(hash, hashWithSalt(password, salt)) === 0 -} - -function cleanEmail (email: string): string { - return email.toLowerCase().trim() -} - -function isEmail (email: string): boolean { - const EMAIL_REGEX = - /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/ - return EMAIL_REGEX.test(email) -} - -/** - * @public - */ -export async function getAccount (db: Db, email: string): Promise { - return await db.collection(ACCOUNT_COLLECTION).findOne({ email: cleanEmail(email) }) -} - -async function getAccountByQuery (db: Db, query: Record): Promise { - return await db.collection(ACCOUNT_COLLECTION).findOne(query) -} - -/** - * @public - */ -export async function setAccountAdmin (db: Db, email: string, admin: boolean): Promise { - const account = await getAccount(db, email) - if (account === null) { - return - } - // Add workspace to account - await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { admin } }) -} - -function withProductId (productId: string, query: Filter): Filter { - return productId === '' - ? { - $or: [ - { productId: '', ...query }, - { productId: { $exists: false }, ...query } - ] - } - : { productId, ...query } -} -/** - * @public - * @param db - - * @param workspaceUrl - - * @returns - */ -export async function getWorkspaceByUrl (db: Db, productId: string, workspaceUrl: string): Promise { - const res = await db.collection(WORKSPACE_COLLECTION).findOne(withProductId(productId, { workspaceUrl })) - if (res != null) { - return res - } - // Fallback to old workspaces. - return await db - .collection(WORKSPACE_COLLECTION) - .findOne(withProductId(productId, { workspace: workspaceUrl, workspaceUrl: { $exists: false } })) -} - -/** - * @public - * @param db - - * @param workspace - - * @returns - */ -export async function getWorkspaceById (db: Db, productId: string, workspace: string): Promise { - return await db.collection(WORKSPACE_COLLECTION).findOne(withProductId(productId, { workspace })) -} - -function toAccountInfo (account: Account): AccountInfo { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { hash, salt, ...result } = account - return result -} - -async function getAccountInfo (ctx: MeasureContext, db: Db, email: string, password: string): Promise { - const account = await getAccount(db, email) - if (account === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) - } - if (account.hash === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidPassword, { account: email })) - } - if (!verifyPassword(password, Buffer.from(account.hash.buffer), Buffer.from(account.salt.buffer))) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidPassword, { account: email })) - } - return toAccountInfo(account) -} - -async function getAccountInfoByToken ( - ctx: MeasureContext, - db: Db, - productId: string, - token: string -): Promise { - let email: string = '' - try { - email = decodeToken(token)?.email - } catch (err: any) { - Analytics.handleError(err) - await ctx.error('Invalid token', { token }) - throw new PlatformError(new Status(Severity.ERROR, platform.status.Unauthorized, {})) - } - const account = await getAccount(db, email) - if (account === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) - } - const info = toAccountInfo(account) - const result = { - endpoint: getEndpoint(), - email, - confirmed: info.confirmed ?? true, - token: generateToken(email, getWorkspaceId('', productId), getExtra(info)) - } - return result -} - -/** - * @public - * @param db - - * @param email - - * @param password - - * @param workspace - - * @returns - */ -export async function login ( - ctx: MeasureContext, - db: Db, - productId: string, - _email: string, - password: string -): Promise { - const email = cleanEmail(_email) - try { - const info = await getAccountInfo(ctx, db, email, password) - const result = { - endpoint: getEndpoint(), - email, - confirmed: info.confirmed ?? true, - token: generateToken(email, getWorkspaceId('', productId), getExtra(info)) - } - await ctx.info('login success', { email, productId }) - return result - } catch (err: any) { - Analytics.handleError(err) - await ctx.error('login failed', { email, productId, _email, err }) - throw err - } -} - -/** - * Will add extra props - */ -function getExtra (info: Account | AccountInfo | null, rec?: Record): Record | undefined { - const res = rec ?? {} - if (info?.admin === true) { - res.admin = 'true' - } - res.confirmed = info?.confirmed ?? true - return res -} - -/** - * @public - */ -export async function selectWorkspace ( - ctx: MeasureContext, - db: Db, - productId: string, - token: string, - workspaceUrl: string, - allowAdmin: boolean = true -): Promise { - let { email } = decodeToken(token) - email = cleanEmail(email) - const accountInfo = await getAccount(db, email) - if (accountInfo === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) - } - - const workspaceInfo = await getWorkspaceByUrl(db, productId, workspaceUrl) - if (workspaceInfo == null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspaceUrl })) - } - if (accountInfo.admin === true && allowAdmin) { - return { - endpoint: getEndpoint(), - email, - token: generateToken(email, getWorkspaceId(workspaceInfo.workspace, productId), getExtra(accountInfo)), - workspace: workspaceUrl, - productId, - creating: workspaceInfo.creating, - createProgress: workspaceInfo.createProgress - } - } - - if (workspaceInfo !== null) { - if (workspaceInfo.disabled === true && workspaceInfo.creating !== true) { - await ctx.error('workspace disabled', { workspaceUrl, email }) - throw new PlatformError( - new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspaceUrl }) - ) - } - const workspaces = accountInfo.workspaces - - for (const w of workspaces) { - if (w.equals(workspaceInfo._id)) { - const result = { - endpoint: getEndpoint(), - email, - token: generateToken(email, getWorkspaceId(workspaceInfo.workspace, productId), getExtra(accountInfo)), - workspace: workspaceUrl, - productId, - creating: workspaceInfo.creating, - createProgress: workspaceInfo.createProgress - } - return result - } - } - } - await ctx.error('workspace error', { workspaceUrl, email }) - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) -} - -/** - * @public - */ -export async function getInvite (db: Db, inviteId: ObjectId): Promise { - return await db.collection(INVITE_COLLECTION).findOne({ _id: new ObjectId(inviteId) }) -} - -/** - * @public - */ -export async function checkInvite (ctx: MeasureContext, invite: Invite | null, email: string): Promise { - if (invite === null || invite.limit === 0) { - void ctx.error('invite', { email, state: 'no invite or limit exceed' }) - Analytics.handleError(new Error(`no invite or invite limit exceed ${email}`)) - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - } - if (invite.exp < Date.now()) { - void ctx.error('invite', { email, state: 'link expired' }) - Analytics.handleError(new Error(`invite link expired ${invite._id.toString()} ${email}`)) - throw new PlatformError(new Status(Severity.ERROR, platform.status.ExpiredLink, {})) - } - if (invite.emailMask != null && invite.emailMask.trim().length > 0 && !new RegExp(invite.emailMask).test(email)) { - void ctx.error('invite', { email, state: 'mask to match', mask: invite.emailMask }) - Analytics.handleError(new Error(`invite link mask failed ${invite._id.toString()} ${email} ${invite.emailMask}`)) - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - } - - return invite.workspace -} - -/** - * @public - */ -export async function useInvite (db: Db, inviteId: ObjectId): Promise { - await db.collection(INVITE_COLLECTION).updateOne({ _id: inviteId }, { $inc: { limit: -1 } }) -} - -/** - * @public - */ -export async function join ( - ctx: MeasureContext, - db: Db, - productId: string, - _email: string, - password: string, - inviteId: ObjectId -): Promise { - const email = cleanEmail(_email) - const invite = await getInvite(db, inviteId) - const workspace = await checkInvite(ctx, invite, email) - await ctx.info(`join attempt:${email}, ${workspace.name}`) - const ws = await assignWorkspace(ctx, db, productId, email, workspace.name) - - const token = (await login(ctx, db, productId, email, password)).token - const result = await selectWorkspace(ctx, db, productId, token, ws.workspaceUrl ?? ws.workspace) - await useInvite(db, inviteId) - return result -} - -/** - * @public - */ -export async function confirmEmail (db: Db, _email: string): Promise { - const email = cleanEmail(_email) - const account = await getAccount(db, email) - console.log(`confirm email:${email}`) - - if (account === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: _email })) - } - if (account.confirmed === true) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountAlreadyConfirmed, { account: _email })) - } - - await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { confirmed: true } }) - account.confirmed = true - return account -} - -/** - * @public - */ -export async function confirm (ctx: MeasureContext, db: Db, productId: string, token: string): Promise { - const decode = decodeToken(token) - const _email = decode.extra?.confirm - if (_email === undefined) { - await ctx.error('confirm email invalid', { token: decode }) - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: _email })) - } - const email = cleanEmail(_email) - const account = await confirmEmail(db, email) - - const result = { - endpoint: getEndpoint(), - email, - token: generateToken(email, getWorkspaceId('', productId), getExtra(account)) - } - await ctx.info('confirm success', { email, productId }) - return result -} - -async function sendConfirmation (productId: string, account: Account): Promise { - const sesURL = getMetadata(accountPlugin.metadata.SES_URL) - if (sesURL === undefined || sesURL === '') { - console.info('Please provide email service url to enable email confirmations.') - return - } - const front = getMetadata(accountPlugin.metadata.FrontURL) - if (front === undefined || front === '') { - throw new Error('Please provide front url') - } - - const token = generateToken( - '@confirm', - getWorkspaceId('', productId), - getExtra(account, { - confirm: account.email - }) - ) - - const link = concatLink(front, `/login/confirm?id=${token}`) - - const name = getMetadata(accountPlugin.metadata.ProductName) - const text = await translate(accountPlugin.string.ConfirmationText, { name, link }) - const html = await translate(accountPlugin.string.ConfirmationHTML, { name, link }) - const subject = await translate(accountPlugin.string.ConfirmationSubject, { name }) - - if (sesURL !== undefined && sesURL !== '') { - const to = account.email - await fetch(concatLink(sesURL, '/send'), { - method: 'post', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - text, - html, - subject, - to - }) - }) - } -} - -/** - * @public - */ -export async function signUpJoin ( - ctx: MeasureContext, - db: Db, - productId: string, - _email: string, - password: string, - first: string, - last: string, - inviteId: ObjectId -): Promise { - const email = cleanEmail(_email) - console.log(`signup join:${email} ${first} ${last}`) - const invite = await getInvite(db, inviteId) - const workspace = await checkInvite(ctx, invite, email) - const sesURL = getMetadata(accountPlugin.metadata.SES_URL) - await createAcc( - ctx, - db, - productId, - email, - password, - first, - last, - invite?.emailMask === email || sesURL === undefined || sesURL === '' - ) - const ws = await assignWorkspace(ctx, db, productId, email, workspace.name) - - const token = (await login(ctx, db, productId, email, password)).token - const result = await selectWorkspace(ctx, db, productId, token, ws.workspaceUrl ?? ws.workspace) - await useInvite(db, inviteId) - return result -} - -/** - * @public - */ -export async function createAcc ( - ctx: MeasureContext, - db: Db, - productId: string, - _email: string, - password: string | null, - first: string, - last: string, - confirmed: boolean = false, - extra?: Record -): Promise { - const email = cleanEmail(_email) - const salt = randomBytes(32) - const hash = password !== null ? hashWithSalt(password, salt) : null - - const systemEmails = [systemAccountEmail] - if (systemEmails.includes(email)) { - await ctx.error('system email used for account', { email }) - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountAlreadyExists, { account: email })) - } - - const account = await getAccount(db, email) - if (account !== null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountAlreadyExists, { account: email })) - } - - await db.collection(ACCOUNT_COLLECTION).insertOne({ - email, - hash, - salt, - first, - last, - confirmed, - workspaces: [], - createdOn: Date.now(), - lastVisit: Date.now(), - ...(extra ?? {}) - }) - - const newAccount = await getAccount(db, email) - if (newAccount === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountAlreadyExists, { account: email })) - } - const sesURL = getMetadata(accountPlugin.metadata.SES_URL) - if (!confirmed) { - if (sesURL !== undefined && sesURL !== '') { - await sendConfirmation(productId, newAccount) - } else { - await ctx.info('Please provide email service url to enable email confirmations.') - await confirmEmail(db, email) - } - } - await ctx.info('account created', { account: email }) - return newAccount -} - -/** - * @public - */ -export async function createAccount ( - ctx: MeasureContext, - db: Db, - productId: string, - _email: string, - password: string, - first: string, - last: string -): Promise { - const email = cleanEmail(_email) - const sesURL = getMetadata(accountPlugin.metadata.SES_URL) - const account = await createAcc( - ctx, - db, - productId, - email, - password, - first, - last, - sesURL === undefined || sesURL === '' - ) - - const result = { - endpoint: getEndpoint(), - email, - token: generateToken(email, getWorkspaceId('', productId), getExtra(account)) - } - return result -} - -/** - * @public - */ -export async function listWorkspaces ( - ctx: MeasureContext, - db: Db, - productId: string, - token: string -): Promise { - decodeToken(token) // Just verify token is valid - return (await db.collection(WORKSPACE_COLLECTION).find(withProductId(productId, {})).toArray()) - .map((it) => ({ ...it, productId })) - .filter((it) => it.disabled !== true) - .map(trimWorkspaceInfo) -} - -/** - * @public - */ -export async function listWorkspacesRaw (db: Db, productId: string): Promise { - return (await db.collection(WORKSPACE_COLLECTION).find(withProductId(productId, {})).toArray()) - .map((it) => ({ ...it, productId })) - .filter((it) => it.disabled !== true) -} - -/** - * @public - */ -export async function listWorkspacesPure (db: Db, productId: string): Promise { - return (await db.collection(WORKSPACE_COLLECTION).find(withProductId(productId, {})).toArray()).map( - (it) => ({ ...it, productId }) - ) -} -/** - * @public - */ -export async function setWorkspaceDisabled (db: Db, workspaceId: Workspace['_id'], disabled: boolean): Promise { - await db.collection(WORKSPACE_COLLECTION).updateOne({ _id: workspaceId }, { $set: { disabled } }) -} - -export async function cleanInProgressWorkspaces (db: Db, productId: string): Promise { - const toDelete = ( - await db - .collection(WORKSPACE_COLLECTION) - .find(withProductId(productId, { creating: true })) - .toArray() - ).map((it) => ({ ...it, productId })) - const ctx = new MeasureMetricsContext('clean', {}) - for (const d of toDelete) { - await dropWorkspace(ctx, db, productId, d.workspace) - } -} - -/** - * @public - */ -export async function updateWorkspace ( - db: Db, - productId: string, - info: Workspace, - ops: Partial -): Promise { - await db.collection(WORKSPACE_COLLECTION).updateOne({ _id: info._id }, { $set: { ...info, ...ops } }) -} - -/** - * @public - */ -export async function listAccounts (db: Db): Promise { - return await db.collection(ACCOUNT_COLLECTION).find({}).toArray() -} - -const workspaceReg = /[a-z0-9]/ -const workspaceRegDigit = /[0-9]/ - -function stripId (name: string): string { - let workspaceId = '' - for (const c of name.toLowerCase()) { - if (workspaceReg.test(c) || c === '-') { - if (workspaceId.length > 0 || !workspaceRegDigit.test(c)) { - workspaceId += c - } - } - } - return workspaceId -} - -function getEmailName (email: string): string { - return email.split('@')[0] -} - -async function generateWorkspaceRecord ( - db: Db, - email: string, - productId: string, - version: Data, - workspaceName: string, - fixedWorkspace?: string -): Promise { - const coll = db.collection>(WORKSPACE_COLLECTION) - if (fixedWorkspace !== undefined) { - const ws = await coll.find({ workspaceUrl: fixedWorkspace }).toArray() - if ((await getWorkspaceById(db, productId, fixedWorkspace)) !== null || ws.length > 0) { - throw new PlatformError( - new Status(Severity.ERROR, platform.status.WorkspaceAlreadyExists, { workspace: fixedWorkspace }) - ) - } - const data = { - workspace: fixedWorkspace, - workspaceUrl: fixedWorkspace, - productId, - version, - workspaceName, - accounts: [], - disabled: true, - creating: true, - createProgress: 0, - createdOn: Date.now(), - lastVisit: Date.now(), - createdBy: email - } - // Add fixed workspace - const id = await coll.insertOne(data) - return { _id: id.insertedId, ...data } - } - const workspaceUrlPrefix = stripId(workspaceName) - const workspaceIdPrefix = stripId(getEmailName(email)).slice(0, 12) + '-' + workspaceUrlPrefix.slice(0, 12) - let iteration = 0 - let idPostfix = generateId('-') - let urlPostfix = '' - while (true) { - const workspace = 'w-' + workspaceIdPrefix + '-' + idPostfix - let workspaceUrl = - workspaceUrlPrefix + (workspaceUrlPrefix.length > 0 && urlPostfix.length > 0 ? '-' : '') + urlPostfix - if (workspaceUrl.trim().length === 0) { - workspaceUrl = generateId('-') - } - const ws = await coll.find({ $or: [{ workspaceUrl }, { workspace }] }).toArray() - if (ws.length === 0) { - const data = { - workspace, - workspaceUrl, - productId, - version, - workspaceName, - accounts: [], - disabled: true, - creating: true, - createProgress: 0, - createdOn: Date.now(), - lastVisit: Date.now(), - createdBy: email - } - // Nice we do not have a workspace or workspaceUrl duplicated. - const id = await coll.insertOne(data) - return { _id: id.insertedId, ...data } - } - for (const w of ws) { - if (w.workspace === workspaceUrl) { - idPostfix = generateId('-') - } - if (w.workspaceUrl === workspaceUrl) { - urlPostfix = generateId('-') - } - } - iteration++ - - // A stupid check, but for sure we not hang. - if (iteration > 10000) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceRateLimit, { workspace })) - } - } -} - -let searchPromise: Promise | undefined - -const rateLimiter = new RateLimiter(3) - -/** - * @public - */ -export async function createWorkspace ( - ctx: MeasureContext, - version: Data, - txes: Tx[], - migrationOperation: [string, MigrateOperation][], - db: Db, - productId: string, - email: string, - workspaceName: string, - workspace?: string, - notifyHandler?: (workspace: Workspace) => void -): Promise<{ workspaceInfo: Workspace, err?: any, client?: Client }> { - return await rateLimiter.exec(async () => { - // We need to search for duplicate workspaceUrl - await searchPromise - - // Safe generate workspace record. - searchPromise = generateWorkspaceRecord(db, email, productId, version, workspaceName, workspace) - - const workspaceInfo = await searchPromise - - notifyHandler?.(workspaceInfo) - - const wsColl = db.collection>(WORKSPACE_COLLECTION) - - async function updateInfo (ops: Partial): Promise { - await wsColl.updateOne({ _id: workspaceInfo._id }, { $set: ops }) - console.log('update', ops) - } - - await updateInfo({ createProgress: 10 }) - - let client: Client | undefined - const childLogger = ctx.newChild( - 'createWorkspace', - { workspace: workspaceInfo.workspace }, - {}, - ctx.logger.childLogger?.(workspaceInfo.workspace, {}) ?? ctx.logger - ) - const ctxModellogger: ModelLogger = { - log: (msg, data) => { - void childLogger.info(msg, data) - }, - error: (msg, data) => { - void childLogger.error(msg, data) - } - } - try { - const initWS = getMetadata(toolPlugin.metadata.InitWorkspace) - const wsId = getWorkspaceId(workspaceInfo.workspace, productId) - - // We should not try to clone INIT_WS into INIT_WS during it's creation. - if ( - initWS !== undefined && - (await getWorkspaceById(db, productId, initWS)) !== null && - initWS !== workspaceInfo.workspace - ) { - // Just any valid model for transactor to be able to function - await ( - await initModel(ctx, getTransactor(), wsId, txes, [], ctxModellogger, async (value) => { - await updateInfo({ createProgress: Math.round((Math.min(value, 100) / 100) * 20) }) - }) - ).close() - await updateInfo({ createProgress: 20 }) - // Clone init workspace. - await cloneWorkspace( - getTransactor(), - getWorkspaceId(initWS, productId), - getWorkspaceId(workspaceInfo.workspace, productId), - true, - async (value) => { - await updateInfo({ createProgress: 20 + Math.round((Math.min(value, 100) / 100) * 30) }) - } - ) - await updateInfo({ createProgress: 50 }) - client = await upgradeModel( - ctx, - getTransactor(), - wsId, - txes, - migrationOperation, - ctxModellogger, - true, - async (value) => { - await updateInfo({ createProgress: Math.round(50 + (Math.min(value, 100) / 100) * 40) }) - } - ) - await updateInfo({ createProgress: 90 }) - } else { - client = await initModel( - ctx, - getTransactor(), - wsId, - txes, - migrationOperation, - ctxModellogger, - async (value) => { - await updateInfo({ createProgress: Math.round(Math.min(value, 100)) }) - } - ) - } - } catch (err: any) { - Analytics.handleError(err) - return { workspaceInfo, err, client: null as any } - } - // Workspace is created, we need to clear disabled flag. - await updateInfo({ createProgress: 100, disabled: false, creating: false }) - return { workspaceInfo, client } - }) -} - -/** - * @public - */ -export async function upgradeWorkspace ( - ctx: MeasureContext, - version: Data, - txes: Tx[], - migrationOperation: [string, MigrateOperation][], - productId: string, - db: Db, - workspaceUrl: string, - logger: ModelLogger = consoleModelLogger, - forceUpdate: boolean = true -): Promise { - const ws = await getWorkspaceByUrl(db, productId, workspaceUrl) - if (ws === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspaceUrl })) - } - if (ws.productId !== productId) { - if (productId !== '' || ws.productId !== undefined) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.ProductIdMismatch, { productId })) - } - } - const versionStr = versionToString(version) - - console.log( - `${ws.workspace} - ${forceUpdate ? 'force-' : ''}upgrade from "${ - ws?.version !== undefined ? versionToString(ws.version) : '' - }" to "${versionStr}"` - ) - - if (ws?.version !== undefined && !forceUpdate && versionStr === versionToString(ws.version)) { - return versionStr - } - await db.collection(WORKSPACE_COLLECTION).updateOne( - { _id: ws._id }, - { - $set: { version } - } - ) - await ( - await upgradeModel( - ctx, - getTransactor(), - getWorkspaceId(ws.workspace, productId), - txes, - migrationOperation, - logger, - false, - async (value) => {} - ) - ).close() - return versionStr -} - -/** - * @public - */ -export const createUserWorkspace = - (version: Data, txes: Tx[], migrationOperation: [string, MigrateOperation][]) => - async (ctx: MeasureContext, db: Db, productId: string, token: string, workspaceName: string): Promise => { - const { email } = decodeToken(token) - - await ctx.info('Creating workspace', { workspaceName, email }) - - const info = await getAccount(db, email) - - if (info === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) - } - if (info.confirmed === false) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotConfirmed, { account: email })) - } - - if (info.lastWorkspace !== undefined && info.admin === false) { - if (Date.now() - info.lastWorkspace < 60 * 1000) { - throw new PlatformError( - new Status(Severity.ERROR, platform.status.WorkspaceRateLimit, { workspace: workspaceName }) - ) - } - } - - async function doCreate (info: Account, notifyHandler: (workspace: Workspace) => void): Promise { - const { workspaceInfo, err, client } = await createWorkspace( - ctx, - version, - txes, - migrationOperation, - db, - productId, - email, - workspaceName, - undefined, - notifyHandler - ) - - if (err != null) { - await ctx.error('failed to create workspace', { err, workspaceName, email }) - // We need to drop workspace, to prevent wrong data usage. - - await db.collection(WORKSPACE_COLLECTION).updateOne( - { - _id: workspaceInfo._id - }, - { $set: { disabled: true, message: JSON.stringify(err?.message ?? ''), err: JSON.stringify(err) } } - ) - throw err - } - try { - info.lastWorkspace = Date.now() - - // Update last workspace time. - await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: info._id }, { $set: { lastWorkspace: Date.now() } }) - - const initWS = getMetadata(toolPlugin.metadata.InitWorkspace) - const shouldUpdateAccount = initWS !== undefined && (await getWorkspaceById(db, productId, initWS)) !== null - await assignWorkspace(ctx, db, productId, email, workspaceInfo.workspace, shouldUpdateAccount, client) - await setRole(email, workspaceInfo.workspace, productId, AccountRole.Owner, client) - await ctx.info('Creating server side done', { workspaceName, email }) - } finally { - await client?.close() - } - } - - const workspaceInfo = await new Promise((resolve) => { - void doCreate(info, (info: Workspace) => { - resolve(info) - }) - }) - - await assignWorkspaceRaw(db, { account: info, workspace: workspaceInfo }) - - const result = { - endpoint: getEndpoint(), - email, - token: generateToken(email, getWorkspaceId(workspaceInfo.workspace, productId), getExtra(info)), - productId, - workspace: workspaceInfo.workspaceUrl - } - await ctx.info('Creating user side done', { workspaceName, email }) - return result - } - -/** - * @public - */ -export async function getInviteLink ( - ctx: MeasureContext, - db: Db, - productId: string, - token: string, - exp: number, - emailMask: string, - limit: number -): Promise { - const { workspace, email } = decodeToken(token) - const wsPromise = await getWorkspaceById(db, productId, workspace.name) - if (wsPromise === null) { - await ctx.error('workspace not found', { workspace, email }) - throw new PlatformError( - new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspace.name }) - ) - } - await ctx.info('Getting invite link', { workspace: workspace.name, emailMask, limit }) - const result = await db.collection(INVITE_COLLECTION).insertOne({ - workspace, - exp: Date.now() + exp, - emailMask, - limit - }) - return result.insertedId -} - -/** - * @public - */ -export type ClientWorkspaceInfo = Omit & { workspaceId: string } - -/** - * @public - */ -export type WorkspaceInfo = Omit - -function mapToClientWorkspace (ws: Workspace): ClientWorkspaceInfo { - const { _id, accounts, ...data } = ws - return { ...data, workspace: ws.workspaceUrl ?? ws.workspace, workspaceId: ws.workspace } -} - -function trimWorkspaceInfo (ws: Workspace): WorkspaceInfo { - const { _id, accounts, ...data } = ws - return { ...data } -} - -/** - * @public - */ -export async function getUserWorkspaces ( - ctx: MeasureContext, - db: Db, - productId: string, - token: string -): Promise { - const { email } = decodeToken(token) - const account = await getAccount(db, email) - if (account === null) { - await ctx.error('account not found', { email }) - return [] - } - return ( - await db - .collection(WORKSPACE_COLLECTION) - .find(withProductId(productId, account.admin === true ? {} : { _id: { $in: account.workspaces } })) - .toArray() - ) - .filter((it) => it.disabled !== true || it.creating === true) - .map(mapToClientWorkspace) -} - -/** - * @public - */ -export async function getWorkspaceInfo ( - ctx: MeasureContext, - db: Db, - productId: string, - token: string, - _updateLastVisit: boolean = false -): Promise { - const { email, workspace, extra } = decodeToken(token) - const guest = extra?.guest === 'true' - let account: Pick | Account | null = null - const query: Filter = { - workspace: workspace.name - } - if (email !== systemAccountEmail && !guest) { - account = await getAccount(db, email) - if (account === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - } - } else if (guest) { - account = { - admin: false, - workspaces: [] - } - } else { - account = { - admin: true, - workspaces: [] - } - } - - if (account.admin !== true && !guest) { - query._id = { $in: account.workspaces } - } - - const [ws] = ( - await db.collection(WORKSPACE_COLLECTION).find(withProductId(productId, query)).toArray() - ).filter((it) => it.disabled !== true || account?.admin === true || it.creating === true) - if (ws == null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - } - if (_updateLastVisit && isAccount(account)) { - await updateLastVisit(db, ws, account) - } - return mapToClientWorkspace(ws) -} - -function isAccount (data: Pick | Account | null): data is Account { - return (data as Account)._id !== undefined -} - -async function updateLastVisit (db: Db, ws: Workspace, account: Account): Promise { - const now = Date.now() - await db.collection(WORKSPACE_COLLECTION).updateOne({ _id: ws._id }, { $set: { lastVisit: now } }) - - // Add workspace to account - await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { lastVisit: now } }) -} - -async function getWorkspaceAndAccount ( - ctx: MeasureContext, - db: Db, - productId: string, - _email: string, - workspaceUrl: string -): Promise<{ account: Account, workspace: Workspace }> { - const email = cleanEmail(_email) - const wsPromise = await getWorkspaceById(db, productId, workspaceUrl) - if (wsPromise === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspaceUrl })) - } - const account = await getAccount(db, email) - if (account === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) - } - return { account, workspace: wsPromise } -} - -/** - * @public - */ -export async function setRole ( - _email: string, - workspace: string, - productId: string, - role: AccountRole, - client?: Client -): Promise { - if (!Object.values(AccountRole).includes(role)) return - const email = cleanEmail(_email) - const connection = client ?? (await connect(getTransactor(), getWorkspaceId(workspace, productId))) - try { - const ops = new TxOperations(connection, core.account.System) - - const existingAccount = await ops.findOne(contact.class.PersonAccount, { email }) - - if (existingAccount !== undefined) { - await ops.update(existingAccount, { - role - }) - } - } finally { - if (client === undefined) { - await connection.close() - } - } -} - -/** - * @public - */ -export async function assignWorkspace ( - ctx: MeasureContext, - db: Db, - productId: string, - _email: string, - workspaceId: string, - shouldReplaceAccount: boolean = false, - client?: Client, - personAccountId?: Ref -): Promise { - const email = cleanEmail(_email) - const initWS = getMetadata(toolPlugin.metadata.InitWorkspace) - if (initWS !== undefined && initWS === workspaceId) { - Analytics.handleError(new Error(`assign-workspace failed ${email} ${workspaceId}`)) - await ctx.error('assign-workspace failed', { email, workspaceId, reason: 'initWs === workspaceId' }) - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - } - const workspaceInfo = await getWorkspaceAndAccount(ctx, db, productId, email, workspaceId) - - if (workspaceInfo.account !== null) { - await createPersonAccount( - workspaceInfo.account, - productId, - workspaceId, - shouldReplaceAccount, - client, - personAccountId - ) - } - - // Add account into workspace. - await assignWorkspaceRaw(db, workspaceInfo) - - await ctx.info('assign-workspace success', { email, workspaceId }) - return workspaceInfo.workspace -} - -async function assignWorkspaceRaw (db: Db, workspaceInfo: { account: Account, workspace: Workspace }): Promise { - await db - .collection(WORKSPACE_COLLECTION) - .updateOne({ _id: workspaceInfo.workspace._id }, { $addToSet: { accounts: workspaceInfo.account._id } }) - - // Add workspace to account - await db - .collection(ACCOUNT_COLLECTION) - .updateOne({ _id: workspaceInfo.account._id }, { $addToSet: { workspaces: workspaceInfo.workspace._id } }) -} - -async function createEmployee (ops: TxOperations, name: string, _email: string): Promise> { - const id = generateId() - let avatar = `${AvatarType.COLOR}://${getAvatarColorForId(id)}` - const email = cleanEmail(_email) - if (isEmail(email)) { - const gravatarId = buildGravatarId(email) - const hasGravatar = await checkHasGravatar(gravatarId) - if (hasGravatar) { - avatar = `${AvatarType.GRAVATAR}://${gravatarId}` - } - } - - await ops.createDoc( - contact.class.Person, - contact.space.Employee, - { - name, - city: '', - avatar - }, - id - ) - await ops.createMixin(id, contact.class.Person, contact.space.Contacts, contact.mixin.Employee, { - active: true - }) - if (isEmail(email)) { - await ops.addCollection(contact.class.Channel, contact.space.Contacts, id, contact.mixin.Employee, 'channels', { - provider: contact.channelProvider.Email, - value: email - }) - } - - return id -} - -async function replaceCurrentAccount ( - ops: TxOperations, - account: Account, - currentAccount: PersonAccount, - name: string -): Promise { - await ops.update(currentAccount, { email: account.email }) - const employee = await ops.findOne(contact.mixin.Employee, { _id: currentAccount.person as Ref }) - if (employee === undefined) { - // Employee was deleted, let's restore it. - const employeeId = await createEmployee(ops, name, account.email) - - await ops.updateDoc(contact.class.PersonAccount, currentAccount.space, currentAccount._id, { - person: employeeId - }) - } else { - const email = cleanEmail(account.email) - const gravatarId = buildGravatarId(email) - const hasGravatar = await checkHasGravatar(gravatarId) - - await ops.update(employee, { - name, - avatar: hasGravatar - ? `${AvatarType.GRAVATAR}://${gravatarId}` - : `${AvatarType.COLOR}://${getAvatarColorForId(employee._id)}`, - ...(employee.active ? {} : { active: true }) - }) - const currentChannel = await ops.findOne(contact.class.Channel, { - attachedTo: employee._id, - provider: contact.channelProvider.Email - }) - if (currentChannel === undefined) { - await ops.addCollection( - contact.class.Channel, - contact.space.Contacts, - employee._id, - contact.mixin.Employee, - 'channels', - { - provider: contact.channelProvider.Email, - value: email - } - ) - } else if (currentChannel.value !== email) { - await ops.update(currentChannel, { value: email }) - } - } -} - -async function createPersonAccount ( - account: Account, - productId: string, - workspace: string, - shouldReplaceCurrent: boolean = false, - client?: Client, - personAccountId?: Ref -): Promise { - const connection = client ?? (await connect(getTransactor(), getWorkspaceId(workspace, productId))) - try { - const ops = new TxOperations(connection, core.account.System) - - const name = combineName(account.first, account.last) - // Check if EmployeeAccount is not exists - if (shouldReplaceCurrent) { - const currentAccount = await ops.findOne(contact.class.PersonAccount, {}) - if (currentAccount !== undefined) { - await replaceCurrentAccount(ops, account, currentAccount, name) - return - } - } - const existingAccount = await ops.findOne(contact.class.PersonAccount, { email: account.email }) - if (existingAccount === undefined) { - const employee = await createEmployee(ops, name, account.email) - - await ops.createDoc( - contact.class.PersonAccount, - core.space.Model, - { - email: account.email, - person: employee, - role: AccountRole.User - }, - personAccountId - ) - } else { - const employee = await ops.findOne(contact.mixin.Employee, { _id: existingAccount.person as Ref }) - if (employee === undefined) { - // Employee was deleted, let's restore it. - const employeeId = await createEmployee(ops, name, account.email) - - await ops.updateDoc(contact.class.PersonAccount, existingAccount.space, existingAccount._id, { - person: employeeId - }) - } else if (!employee.active) { - await ops.update(employee, { - active: true - }) - } - } - } finally { - if (client === undefined) { - await connection.close() - } - } -} - -/** - * @public - */ -export async function changePassword ( - ctx: MeasureContext, - db: Db, - productId: string, - token: string, - oldPassword: string, - password: string -): Promise { - const { email } = decodeToken(token) - const account = await getAccountInfo(ctx, db, email, oldPassword) - - const salt = randomBytes(32) - const hash = hashWithSalt(password, salt) - - await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { salt, hash } }) - await ctx.info('change-password success', { email }) -} - -/** - * @public - */ -export async function changeEmail (ctx: MeasureContext, db: Db, account: Account, newEmail: string): Promise { - await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { email: newEmail } }) - await ctx.info('change-email success', { email: newEmail }) -} - -/** - * @public - */ -export async function replacePassword (db: Db, productId: string, email: string, password: string): Promise { - const account = await getAccount(db, email) - - if (account === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) - } - const salt = randomBytes(32) - const hash = hashWithSalt(password, salt) - - await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { salt, hash } }) -} - -/** - * @public - */ -export async function requestPassword (ctx: MeasureContext, db: Db, productId: string, _email: string): Promise { - const email = cleanEmail(_email) - const account = await getAccount(db, email) - - if (account === null) { - await ctx.info('account not found', { email }) - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) - } - - const sesURL = getMetadata(accountPlugin.metadata.SES_URL) - if (sesURL === undefined || sesURL === '') { - throw new Error('Please provide email service url') - } - const front = getMetadata(accountPlugin.metadata.FrontURL) - if (front === undefined || front === '') { - throw new Error('Please provide front url') - } - - const token = generateToken( - '@restore', - getWorkspaceId('', productId), - getExtra(account, { - restore: email - }) - ) - - const link = concatLink(front, `/login/recovery?id=${token}`) - - const text = await translate(accountPlugin.string.RecoveryText, { link }) - const html = await translate(accountPlugin.string.RecoveryHTML, { link }) - const subject = await translate(accountPlugin.string.RecoverySubject, {}) - - const to = account.email - await fetch(concatLink(sesURL, '/send'), { - method: 'post', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - text, - html, - subject, - to - }) - }) - await ctx.info('recovery email sent', { email, accountEmail: account.email }) -} - -/** - * @public - */ -export async function restorePassword ( - ctx: MeasureContext, - db: Db, - productId: string, - token: string, - password: string -): Promise { - const decode = decodeToken(token) - const email = decode.extra?.restore - if (email === undefined) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) - } - const account = await getAccount(db, email) - - if (account === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) - } - - await updatePassword(db, account, password) - - return await login(ctx, db, productId, email, password) -} - -async function updatePassword (db: Db, account: Account, password: string | null): Promise { - const salt = randomBytes(32) - const hash = password !== null ? hashWithSalt(password, salt) : null - - await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { salt, hash } }) -} - -/** - * @public - */ -export async function removeWorkspace ( - ctx: MeasureContext, - db: Db, - productId: string, - email: string, - workspaceId: string -): Promise { - const { workspace, account } = await getWorkspaceAndAccount(ctx, db, productId, email, workspaceId) - - // Add account into workspace. - await db.collection(WORKSPACE_COLLECTION).updateOne({ _id: workspace._id }, { $pull: { accounts: account._id } }) - - // Add account a workspace - await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $pull: { workspaces: workspace._id } }) - await ctx.info('Workspace removed', { email, workspace }) -} - -/** - * @public - */ -export async function checkJoin ( - ctx: MeasureContext, - db: Db, - productId: string, - token: string, - inviteId: ObjectId -): Promise { - const { email } = decodeToken(token) - const invite = await getInvite(db, inviteId) - const workspace = await checkInvite(ctx, invite, email) - const ws = await getWorkspaceById(db, productId, workspace.name) - if (ws === null) { - await ctx.error('workspace not found', { name: workspace.name, email, inviteId }) - throw new PlatformError( - new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspace.name }) - ) - } - return await selectWorkspace(ctx, db, productId, token, ws?.workspaceUrl ?? ws.workspace, false) -} - -/** - * @public - */ -export async function dropWorkspace ( - ctx: MeasureContext, - db: Db, - productId: string, - workspaceId: string -): Promise { - const ws = await getWorkspaceById(db, productId, workspaceId) - if (ws === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspaceId })) - } - await db.collection(WORKSPACE_COLLECTION).deleteOne({ _id: ws._id }) - await db - .collection(ACCOUNT_COLLECTION) - .updateMany({ _id: { $in: ws.accounts ?? [] } }, { $pull: { workspaces: ws._id } }) - - await ctx.info('Workspace dropped', { workspace: ws.workspace }) -} - -/** - * @public - */ -export async function dropAccount (ctx: MeasureContext, db: Db, productId: string, email: string): Promise { - const account = await getAccount(db, email) - if (account === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) - } - - const workspaces = await db - .collection(WORKSPACE_COLLECTION) - .find(withProductId(productId, { _id: { $in: account.workspaces } })) - .toArray() - - await Promise.all( - workspaces.map(async (ws) => { - await deactivatePersonAccount(ctx, account.email, ws.workspace, productId) - }) - ) - - await db.collection(ACCOUNT_COLLECTION).deleteOne({ _id: account._id }) - await db - .collection(WORKSPACE_COLLECTION) - .updateMany({ _id: { $in: account.workspaces } }, { $pull: { accounts: account._id } }) - await ctx.info('Account Dropped', { email, account }) -} - -/** - * @public - */ -export async function leaveWorkspace ( - ctx: MeasureContext, - db: Db, - productId: string, - token: string, - email: string -): Promise { - const tokenData = decodeToken(token) - - const currentAccount = await getAccount(db, tokenData.email) - if (currentAccount === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: tokenData.email })) - } - - const workspace = await getWorkspaceById(db, productId, tokenData.workspace.name) - if (workspace === null) { - throw new PlatformError( - new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: tokenData.workspace.name }) - ) - } - - await deactivatePersonAccount(ctx, email, workspace.workspace, workspace.productId) - - const account = tokenData.email !== email ? await getAccount(db, email) : currentAccount - if (account !== null) { - await db - .collection(WORKSPACE_COLLECTION) - .updateOne({ _id: workspace._id }, { $pull: { accounts: account._id } }) - await db - .collection(ACCOUNT_COLLECTION) - .updateOne({ _id: account._id }, { $pull: { workspaces: workspace._id } }) - } - await ctx.info('Account removed from workspace', { email, workspace }) -} - -/** - * @public - */ -export async function sendInvite ( - ctx: MeasureContext, - db: Db, - productId: string, - token: string, - email: string -): Promise { - const tokenData = decodeToken(token) - const currentAccount = await getAccount(db, tokenData.email) - if (currentAccount === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: tokenData.email })) - } - - const workspace = await getWorkspaceById(db, productId, tokenData.workspace.name) - if (workspace === null) { - throw new PlatformError( - new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: tokenData.workspace.name }) - ) - } - - // TODO: Why we not send invite if user has account??? - // const account = await getAccount(db, email) - // if (account !== null) return - - const sesURL = getMetadata(accountPlugin.metadata.SES_URL) - if (sesURL === undefined || sesURL === '') { - throw new Error('Please provide email service url') - } - const front = getMetadata(accountPlugin.metadata.FrontURL) - if (front === undefined || front === '') { - throw new Error('Please provide front url') - } - - const expHours = 48 - const exp = expHours * 60 * 60 * 1000 - - const inviteId = await getInviteLink(ctx, db, productId, token, exp, email, 1) - const link = concatLink(front, `/login/join?inviteId=${inviteId.toString()}`) - - const ws = workspace.workspace - const text = await translate(accountPlugin.string.InviteText, { link, ws, expHours }) - const html = await translate(accountPlugin.string.InviteHTML, { link, ws, expHours }) - const subject = await translate(accountPlugin.string.InviteSubject, { ws }) - - const to = email - await fetch(concatLink(sesURL, '/send'), { - method: 'post', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - text, - html, - subject, - to - }) - }) - await ctx.info('Invite sent', { email, workspace, link }) -} - -async function deactivatePersonAccount ( - ctx: MeasureContext, - email: string, - workspace: string, - productId: string -): Promise { - const connection = await connect(getTransactor(), getWorkspaceId(workspace, productId)) - try { - const ops = new TxOperations(connection, core.account.System) - - const existingAccount = await ops.findOne(contact.class.PersonAccount, { email }) - - if (existingAccount !== undefined) { - const employee = await ops.findOne(contact.mixin.Employee, { _id: existingAccount.person as Ref }) - if (employee !== undefined) { - await ops.update(employee, { - active: false - }) - } - await ctx.info('account deactivated', { email, workspace }) - } - } finally { - await connection.close() - } -} - -/** - * @public - */ -export type AccountMethod = ( - ctx: MeasureContext, - db: Db, - productId: string, - request: any, - token?: string -) => Promise - -function wrap ( - accountMethod: (ctx: MeasureContext, db: Db, productId: string, ...args: any[]) => Promise -): AccountMethod { - return async function (ctx: MeasureContext, db: Db, productId: string, request: any, token?: string): Promise { - if (token !== undefined) request.params.unshift(token) - return await accountMethod(ctx, db, productId, ...request.params) - .then((result) => ({ id: request.id, result })) - .catch((err) => { - const status = - err instanceof PlatformError - ? err.status - : new Status(Severity.ERROR, platform.status.InternalServerError, {}) - if (status.code === platform.status.InternalServerError) { - Analytics.handleError(err) - void ctx.error('error', { status, err }) - } else { - void ctx.error('error', { status }) - } - return { - error: status - } - }) - } -} - -export async function joinWithProvider ( - ctx: MeasureContext, - db: Db, - productId: string, - _email: string, - first: string, - last: string, - inviteId: ObjectId, - extra?: Record -): Promise { - const email = cleanEmail(_email) - const invite = await getInvite(db, inviteId) - const workspace = await checkInvite(ctx, invite, email) - if (last == null) { - last = '' - } - let account = await getAccount(db, email) - if (account == null && extra !== undefined) { - account = await getAccountByQuery(db, extra) - } - if (account !== null) { - // we should clean password if account is not confirmed - if (account.confirmed === false) { - await updatePassword(db, account, null) - } - - const token = generateToken(email, getWorkspaceId('', productId), getExtra(account)) - const ws = await getWorkspaceById(db, productId, workspace.name) - - if (ws?.accounts.includes(account._id) ?? false) { - const result = { - endpoint: getEndpoint(), - email, - token - } - return result - } - - const wsRes = await assignWorkspace(ctx, db, productId, email, workspace.name, false) - const result = await selectWorkspace(ctx, db, productId, token, wsRes.workspaceUrl ?? wsRes.workspace, false) - - await useInvite(db, inviteId) - return result - } - - const newAccount = await createAcc(ctx, db, productId, email, null, first, last, true, extra) - const token = generateToken(email, getWorkspaceId('', productId), getExtra(newAccount)) - const ws = await assignWorkspace(ctx, db, productId, email, workspace.name, false) - const result = await selectWorkspace(ctx, db, productId, token, ws.workspaceUrl ?? ws.workspace, false) - - await useInvite(db, inviteId) - - return result -} - -export async function loginWithProvider ( - ctx: MeasureContext, - db: Db, - productId: string, - _email: string, - first: string, - last: string, - extra?: Record -): Promise { - const email = cleanEmail(_email) - if (last == null) { - last = '' - } - let account = await getAccount(db, email) - if (account == null && extra !== undefined) { - account = await getAccountByQuery(db, extra) - } - if (account !== null) { - // we should clean password if account is not confirmed - if (account.confirmed === false) { - await updatePassword(db, account, null) - } - const result = { - endpoint: getEndpoint(), - email, - token: generateToken(email, getWorkspaceId('', productId), getExtra(account)) - } - return result - } - const newAccount = await createAcc(ctx, db, productId, email, null, first, last, true, extra) - - const result = { - endpoint: getEndpoint(), - email, - token: generateToken(email, getWorkspaceId('', productId), getExtra(newAccount)) - } - return result -} - -/** - * @public - */ -export function getMethods ( - version: Data, - txes: Tx[], - migrateOperations: [string, MigrateOperation][] -): Record { - return { - getEndpoint: wrap(async () => getEndpoint()), - login: wrap(login), - join: wrap(join), - checkJoin: wrap(checkJoin), - signUpJoin: wrap(signUpJoin), - selectWorkspace: wrap(selectWorkspace), - getUserWorkspaces: wrap(getUserWorkspaces), - getInviteLink: wrap(getInviteLink), - getAccountInfo: wrap(getAccountInfo), - getWorkspaceInfo: wrap(getWorkspaceInfo), - createAccount: wrap(createAccount), - createWorkspace: wrap(createUserWorkspace(version, txes, migrateOperations)), - assignWorkspace: wrap(assignWorkspace), - removeWorkspace: wrap(removeWorkspace), - leaveWorkspace: wrap(leaveWorkspace), - listWorkspaces: wrap(listWorkspaces), - changePassword: wrap(changePassword), - requestPassword: wrap(requestPassword), - restorePassword: wrap(restorePassword), - sendInvite: wrap(sendInvite), - confirm: wrap(confirm), - getAccountInfoByToken: wrap(getAccountInfoByToken) - // updateAccount: wrap(updateAccount) - } -} +import { accountPlugin } from './plugin' +export * from './operations' export * from './plugin' +export * from './service' + export default accountPlugin diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts new file mode 100644 index 0000000000..bcb55d37eb --- /dev/null +++ b/server/account/src/operations.ts @@ -0,0 +1,1975 @@ +// +// Copyright © 2022-2023 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the f. +// + +import { Analytics } from '@hcengineering/analytics' +import contact, { + AvatarType, + buildGravatarId, + checkHasGravatar, + combineName, + Employee, + getAvatarColorForId, + Person, + PersonAccount +} from '@hcengineering/contact' +import core, { + AccountRole, + BaseWorkspaceInfo, + Client, + concatLink, + Data, + generateId, + getWorkspaceId, + MeasureContext, + MeasureMetricsContext, + RateLimiter, + Ref, + systemAccountEmail, + Tx, + TxOperations, + Version, + versionToString, + WorkspaceId +} from '@hcengineering/core' +import { consoleModelLogger, MigrateOperation, ModelLogger } from '@hcengineering/model' +import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform' +import { cloneWorkspace } from '@hcengineering/server-backup' +import { decodeToken, generateToken } from '@hcengineering/server-token' +import toolPlugin, { connect, initModel, upgradeModel } from '@hcengineering/server-tool' +import { pbkdf2Sync, randomBytes } from 'crypto' +import { Binary, Db, Filter, ObjectId } from 'mongodb' +import fetch from 'node-fetch' +import { accountPlugin } from './plugin' + +const WORKSPACE_COLLECTION = 'workspace' +const ACCOUNT_COLLECTION = 'account' +const INVITE_COLLECTION = 'invite' + +/** + * @public + */ +export const ACCOUNT_DB = 'account' + +const getEndpoint = (): string => { + const endpoint = getMetadata(toolPlugin.metadata.Endpoint) + if (endpoint === undefined) { + throw new Error('Please provide transactor endpoint url') + } + return endpoint +} + +const getTransactor = (): string => { + const transactor = getMetadata(toolPlugin.metadata.Transactor) + if (transactor === undefined) { + throw new Error('Please provide transactor url') + } + return transactor +} + +/** + * @public + */ +export interface Account { + _id: ObjectId + email: string + // null if auth provider was used + hash: Binary | null + salt: Binary + workspaces: ObjectId[] + first: string + last: string + // Defined for server admins only + admin?: boolean + confirmed?: boolean + lastWorkspace?: number + createdOn: number + lastVisit: number +} + +/** + * @public + */ +export interface Workspace extends BaseWorkspaceInfo { + _id: ObjectId + accounts: ObjectId[] +} + +/** + * @public + */ +export interface LoginInfo { + email: string + token: string + endpoint: string +} + +/** + * @public + */ +export interface WorkspaceLoginInfo extends LoginInfo { + workspace: string + productId: string + + creating?: boolean + createProgress?: number +} + +/** + * @public + */ +export interface Invite { + _id: ObjectId + workspace: WorkspaceId + exp: number + emailMask: string + limit: number +} + +/** + * @public + */ +export type AccountInfo = Omit + +function hashWithSalt (password: string, salt: Buffer): Buffer { + return pbkdf2Sync(password, salt, 1000, 32, 'sha256') +} + +function verifyPassword (password: string, hash: Buffer, salt: Buffer): boolean { + return Buffer.compare(hash, hashWithSalt(password, salt)) === 0 +} + +function cleanEmail (email: string): string { + return email.toLowerCase().trim() +} + +function isEmail (email: string): boolean { + const EMAIL_REGEX = + /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/ + return EMAIL_REGEX.test(email) +} + +/** + * @public + */ +export async function getAccount (db: Db, email: string): Promise { + return await db.collection(ACCOUNT_COLLECTION).findOne({ email: cleanEmail(email) }) +} + +async function getAccountByQuery (db: Db, query: Record): Promise { + return await db.collection(ACCOUNT_COLLECTION).findOne(query) +} + +/** + * @public + */ +export async function setAccountAdmin (db: Db, email: string, admin: boolean): Promise { + const account = await getAccount(db, email) + if (account === null) { + return + } + // Add workspace to account + await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { admin } }) +} + +function withProductId (productId: string, query: Filter): Filter { + return productId === '' + ? { + $or: [ + { productId: '', ...query }, + { productId: { $exists: false }, ...query } + ] + } + : { productId, ...query } +} +/** + * @public + * @param db - + * @param workspaceUrl - + * @returns + */ +export async function getWorkspaceByUrl (db: Db, productId: string, workspaceUrl: string): Promise { + const res = await db.collection(WORKSPACE_COLLECTION).findOne(withProductId(productId, { workspaceUrl })) + if (res != null) { + return res + } + // Fallback to old workspaces. + return await db + .collection(WORKSPACE_COLLECTION) + .findOne(withProductId(productId, { workspace: workspaceUrl, workspaceUrl: { $exists: false } })) +} + +/** + * @public + * @param db - + * @param workspace - + * @returns + */ +export async function getWorkspaceById (db: Db, productId: string, workspace: string): Promise { + return await db.collection(WORKSPACE_COLLECTION).findOne(withProductId(productId, { workspace })) +} + +function toAccountInfo (account: Account): AccountInfo { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { hash, salt, ...result } = account + return result +} + +async function getAccountInfo (ctx: MeasureContext, db: Db, email: string, password: string): Promise { + const account = await getAccount(db, email) + if (account === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) + } + if (account.hash === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidPassword, { account: email })) + } + if (!verifyPassword(password, Buffer.from(account.hash.buffer), Buffer.from(account.salt.buffer))) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidPassword, { account: email })) + } + return toAccountInfo(account) +} + +async function getAccountInfoByToken ( + ctx: MeasureContext, + db: Db, + productId: string, + token: string +): Promise { + let email: string = '' + try { + email = decodeToken(token)?.email + } catch (err: any) { + Analytics.handleError(err) + await ctx.error('Invalid token', { token }) + throw new PlatformError(new Status(Severity.ERROR, platform.status.Unauthorized, {})) + } + const account = await getAccount(db, email) + if (account === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) + } + const info = toAccountInfo(account) + const result = { + endpoint: getEndpoint(), + email, + confirmed: info.confirmed ?? true, + token: generateToken(email, getWorkspaceId('', productId), getExtra(info)) + } + return result +} + +/** + * @public + * @param db - + * @param email - + * @param password - + * @param workspace - + * @returns + */ +export async function login ( + ctx: MeasureContext, + db: Db, + productId: string, + _email: string, + password: string +): Promise { + const email = cleanEmail(_email) + try { + const info = await getAccountInfo(ctx, db, email, password) + const result = { + endpoint: getEndpoint(), + email, + confirmed: info.confirmed ?? true, + token: generateToken(email, getWorkspaceId('', productId), getExtra(info)) + } + await ctx.info('login success', { email, productId }) + return result + } catch (err: any) { + Analytics.handleError(err) + await ctx.error('login failed', { email, productId, _email, err }) + throw err + } +} + +/** + * Will add extra props + */ +function getExtra (info: Account | AccountInfo | null, rec?: Record): Record | undefined { + const res = rec ?? {} + if (info?.admin === true) { + res.admin = 'true' + } + res.confirmed = info?.confirmed ?? true + return res +} + +/** + * @public + */ +export async function selectWorkspace ( + ctx: MeasureContext, + db: Db, + productId: string, + token: string, + workspaceUrl: string, + allowAdmin: boolean = true +): Promise { + let { email } = decodeToken(token) + email = cleanEmail(email) + const accountInfo = await getAccount(db, email) + if (accountInfo === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) + } + + const workspaceInfo = await getWorkspaceByUrl(db, productId, workspaceUrl) + if (workspaceInfo == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspaceUrl })) + } + if (accountInfo.admin === true && allowAdmin) { + return { + endpoint: getEndpoint(), + email, + token: generateToken(email, getWorkspaceId(workspaceInfo.workspace, productId), getExtra(accountInfo)), + workspace: workspaceUrl, + productId, + creating: workspaceInfo.creating, + createProgress: workspaceInfo.createProgress + } + } + + if (workspaceInfo !== null) { + if (workspaceInfo.disabled === true && workspaceInfo.creating !== true) { + await ctx.error('workspace disabled', { workspaceUrl, email }) + throw new PlatformError( + new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspaceUrl }) + ) + } + const workspaces = accountInfo.workspaces + + for (const w of workspaces) { + if (w.equals(workspaceInfo._id)) { + const result = { + endpoint: getEndpoint(), + email, + token: generateToken(email, getWorkspaceId(workspaceInfo.workspace, productId), getExtra(accountInfo)), + workspace: workspaceUrl, + productId, + creating: workspaceInfo.creating, + createProgress: workspaceInfo.createProgress + } + return result + } + } + } + await ctx.error('workspace error', { workspaceUrl, email }) + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) +} + +/** + * @public + */ +export async function getInvite (db: Db, inviteId: ObjectId): Promise { + return await db.collection(INVITE_COLLECTION).findOne({ _id: new ObjectId(inviteId) }) +} + +/** + * @public + */ +export async function checkInvite (ctx: MeasureContext, invite: Invite | null, email: string): Promise { + if (invite === null || invite.limit === 0) { + void ctx.error('invite', { email, state: 'no invite or limit exceed' }) + Analytics.handleError(new Error(`no invite or invite limit exceed ${email}`)) + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + if (invite.exp < Date.now()) { + void ctx.error('invite', { email, state: 'link expired' }) + Analytics.handleError(new Error(`invite link expired ${invite._id.toString()} ${email}`)) + throw new PlatformError(new Status(Severity.ERROR, platform.status.ExpiredLink, {})) + } + if (invite.emailMask != null && invite.emailMask.trim().length > 0 && !new RegExp(invite.emailMask).test(email)) { + void ctx.error('invite', { email, state: 'mask to match', mask: invite.emailMask }) + Analytics.handleError(new Error(`invite link mask failed ${invite._id.toString()} ${email} ${invite.emailMask}`)) + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + return invite.workspace +} + +/** + * @public + */ +export async function useInvite (db: Db, inviteId: ObjectId): Promise { + await db.collection(INVITE_COLLECTION).updateOne({ _id: inviteId }, { $inc: { limit: -1 } }) +} + +/** + * @public + */ +export async function join ( + ctx: MeasureContext, + db: Db, + productId: string, + _email: string, + password: string, + inviteId: ObjectId +): Promise { + const email = cleanEmail(_email) + const invite = await getInvite(db, inviteId) + const workspace = await checkInvite(ctx, invite, email) + await ctx.info(`join attempt:${email}, ${workspace.name}`) + const ws = await assignWorkspace(ctx, db, productId, email, workspace.name) + + const token = (await login(ctx, db, productId, email, password)).token + const result = await selectWorkspace(ctx, db, productId, token, ws.workspaceUrl ?? ws.workspace) + await useInvite(db, inviteId) + return result +} + +/** + * @public + */ +export async function confirmEmail (db: Db, _email: string): Promise { + const email = cleanEmail(_email) + const account = await getAccount(db, email) + console.log(`confirm email:${email}`) + + if (account === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: _email })) + } + if (account.confirmed === true) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountAlreadyConfirmed, { account: _email })) + } + + await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { confirmed: true } }) + account.confirmed = true + return account +} + +/** + * @public + */ +export async function confirm (ctx: MeasureContext, db: Db, productId: string, token: string): Promise { + const decode = decodeToken(token) + const _email = decode.extra?.confirm + if (_email === undefined) { + await ctx.error('confirm email invalid', { token: decode }) + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: _email })) + } + const email = cleanEmail(_email) + const account = await confirmEmail(db, email) + + const result = { + endpoint: getEndpoint(), + email, + token: generateToken(email, getWorkspaceId('', productId), getExtra(account)) + } + await ctx.info('confirm success', { email, productId }) + return result +} + +async function sendConfirmation (productId: string, account: Account): Promise { + const sesURL = getMetadata(accountPlugin.metadata.SES_URL) + if (sesURL === undefined || sesURL === '') { + console.info('Please provide email service url to enable email confirmations.') + return + } + const front = getMetadata(accountPlugin.metadata.FrontURL) + if (front === undefined || front === '') { + throw new Error('Please provide front url') + } + + const token = generateToken( + '@confirm', + getWorkspaceId('', productId), + getExtra(account, { + confirm: account.email + }) + ) + + const link = concatLink(front, `/login/confirm?id=${token}`) + + const name = getMetadata(accountPlugin.metadata.ProductName) + const text = await translate(accountPlugin.string.ConfirmationText, { name, link }) + const html = await translate(accountPlugin.string.ConfirmationHTML, { name, link }) + const subject = await translate(accountPlugin.string.ConfirmationSubject, { name }) + + if (sesURL !== undefined && sesURL !== '') { + const to = account.email + await fetch(concatLink(sesURL, '/send'), { + method: 'post', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + text, + html, + subject, + to + }) + }) + } +} + +/** + * @public + */ +export async function signUpJoin ( + ctx: MeasureContext, + db: Db, + productId: string, + _email: string, + password: string, + first: string, + last: string, + inviteId: ObjectId +): Promise { + const email = cleanEmail(_email) + console.log(`signup join:${email} ${first} ${last}`) + const invite = await getInvite(db, inviteId) + const workspace = await checkInvite(ctx, invite, email) + const sesURL = getMetadata(accountPlugin.metadata.SES_URL) + await createAcc( + ctx, + db, + productId, + email, + password, + first, + last, + invite?.emailMask === email || sesURL === undefined || sesURL === '' + ) + const ws = await assignWorkspace(ctx, db, productId, email, workspace.name) + + const token = (await login(ctx, db, productId, email, password)).token + const result = await selectWorkspace(ctx, db, productId, token, ws.workspaceUrl ?? ws.workspace) + await useInvite(db, inviteId) + return result +} + +/** + * @public + */ +export async function createAcc ( + ctx: MeasureContext, + db: Db, + productId: string, + _email: string, + password: string | null, + first: string, + last: string, + confirmed: boolean = false, + extra?: Record +): Promise { + const email = cleanEmail(_email) + const salt = randomBytes(32) + const hash = password !== null ? hashWithSalt(password, salt) : null + + const systemEmails = [systemAccountEmail] + if (systemEmails.includes(email)) { + await ctx.error('system email used for account', { email }) + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountAlreadyExists, { account: email })) + } + + const account = await getAccount(db, email) + if (account !== null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountAlreadyExists, { account: email })) + } + + await db.collection(ACCOUNT_COLLECTION).insertOne({ + email, + hash, + salt, + first, + last, + confirmed, + workspaces: [], + createdOn: Date.now(), + lastVisit: Date.now(), + ...(extra ?? {}) + }) + + const newAccount = await getAccount(db, email) + if (newAccount === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountAlreadyExists, { account: email })) + } + const sesURL = getMetadata(accountPlugin.metadata.SES_URL) + if (!confirmed) { + if (sesURL !== undefined && sesURL !== '') { + await sendConfirmation(productId, newAccount) + } else { + await ctx.info('Please provide email service url to enable email confirmations.') + await confirmEmail(db, email) + } + } + await ctx.info('account created', { account: email }) + return newAccount +} + +/** + * @public + */ +export async function createAccount ( + ctx: MeasureContext, + db: Db, + productId: string, + _email: string, + password: string, + first: string, + last: string +): Promise { + const email = cleanEmail(_email) + const sesURL = getMetadata(accountPlugin.metadata.SES_URL) + const account = await createAcc( + ctx, + db, + productId, + email, + password, + first, + last, + sesURL === undefined || sesURL === '' + ) + + const result = { + endpoint: getEndpoint(), + email, + token: generateToken(email, getWorkspaceId('', productId), getExtra(account)) + } + return result +} + +/** + * @public + */ +export async function listWorkspaces ( + ctx: MeasureContext, + db: Db, + productId: string, + token: string +): Promise { + decodeToken(token) // Just verify token is valid + return (await db.collection(WORKSPACE_COLLECTION).find(withProductId(productId, {})).toArray()) + .map((it) => ({ ...it, productId })) + .filter((it) => it.disabled !== true) + .map(trimWorkspaceInfo) +} + +/** + * @public + */ +export async function listWorkspacesRaw (db: Db, productId: string): Promise { + return (await db.collection(WORKSPACE_COLLECTION).find(withProductId(productId, {})).toArray()) + .map((it) => ({ ...it, productId })) + .filter((it) => it.disabled !== true) +} + +/** + * @public + */ +export async function listWorkspacesPure (db: Db, productId: string): Promise { + return (await db.collection(WORKSPACE_COLLECTION).find(withProductId(productId, {})).toArray()).map( + (it) => ({ ...it, productId }) + ) +} +/** + * @public + */ +export async function setWorkspaceDisabled (db: Db, workspaceId: Workspace['_id'], disabled: boolean): Promise { + await db.collection(WORKSPACE_COLLECTION).updateOne({ _id: workspaceId }, { $set: { disabled } }) +} + +export async function cleanInProgressWorkspaces (db: Db, productId: string): Promise { + const toDelete = ( + await db + .collection(WORKSPACE_COLLECTION) + .find(withProductId(productId, { creating: true })) + .toArray() + ).map((it) => ({ ...it, productId })) + const ctx = new MeasureMetricsContext('clean', {}) + for (const d of toDelete) { + await dropWorkspace(ctx, db, productId, d.workspace) + } +} + +/** + * @public + */ +export async function updateWorkspace ( + db: Db, + productId: string, + info: Workspace, + ops: Partial +): Promise { + await db.collection(WORKSPACE_COLLECTION).updateOne({ _id: info._id }, { $set: { ...info, ...ops } }) +} + +/** + * @public + */ +export async function listAccounts (db: Db): Promise { + return await db.collection(ACCOUNT_COLLECTION).find({}).toArray() +} + +const workspaceReg = /[a-z0-9]/ +const workspaceRegDigit = /[0-9]/ + +function stripId (name: string): string { + let workspaceId = '' + for (const c of name.toLowerCase()) { + if (workspaceReg.test(c) || c === '-') { + if (workspaceId.length > 0 || !workspaceRegDigit.test(c)) { + workspaceId += c + } + } + } + return workspaceId +} + +function getEmailName (email: string): string { + return email.split('@')[0] +} + +async function generateWorkspaceRecord ( + db: Db, + email: string, + productId: string, + version: Data, + workspaceName: string, + fixedWorkspace?: string +): Promise { + const coll = db.collection>(WORKSPACE_COLLECTION) + if (fixedWorkspace !== undefined) { + const ws = await coll.find({ workspaceUrl: fixedWorkspace }).toArray() + if ((await getWorkspaceById(db, productId, fixedWorkspace)) !== null || ws.length > 0) { + throw new PlatformError( + new Status(Severity.ERROR, platform.status.WorkspaceAlreadyExists, { workspace: fixedWorkspace }) + ) + } + const data = { + workspace: fixedWorkspace, + workspaceUrl: fixedWorkspace, + productId, + version, + workspaceName, + accounts: [], + disabled: true, + creating: true, + createProgress: 0, + createdOn: Date.now(), + lastVisit: Date.now(), + createdBy: email + } + // Add fixed workspace + const id = await coll.insertOne(data) + return { _id: id.insertedId, ...data } + } + const workspaceUrlPrefix = stripId(workspaceName) + const workspaceIdPrefix = stripId(getEmailName(email)).slice(0, 12) + '-' + workspaceUrlPrefix.slice(0, 12) + let iteration = 0 + let idPostfix = generateId('-') + let urlPostfix = '' + while (true) { + const workspace = 'w-' + workspaceIdPrefix + '-' + idPostfix + let workspaceUrl = + workspaceUrlPrefix + (workspaceUrlPrefix.length > 0 && urlPostfix.length > 0 ? '-' : '') + urlPostfix + if (workspaceUrl.trim().length === 0) { + workspaceUrl = generateId('-') + } + const ws = await coll.find({ $or: [{ workspaceUrl }, { workspace }] }).toArray() + if (ws.length === 0) { + const data = { + workspace, + workspaceUrl, + productId, + version, + workspaceName, + accounts: [], + disabled: true, + creating: true, + createProgress: 0, + createdOn: Date.now(), + lastVisit: Date.now(), + createdBy: email + } + // Nice we do not have a workspace or workspaceUrl duplicated. + const id = await coll.insertOne(data) + return { _id: id.insertedId, ...data } + } + for (const w of ws) { + if (w.workspace === workspaceUrl) { + idPostfix = generateId('-') + } + if (w.workspaceUrl === workspaceUrl) { + urlPostfix = generateId('-') + } + } + iteration++ + + // A stupid check, but for sure we not hang. + if (iteration > 10000) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceRateLimit, { workspace })) + } + } +} + +let searchPromise: Promise | undefined + +const rateLimiter = new RateLimiter(3) + +/** + * @public + */ +export async function createWorkspace ( + ctx: MeasureContext, + version: Data, + txes: Tx[], + migrationOperation: [string, MigrateOperation][], + db: Db, + productId: string, + email: string, + workspaceName: string, + workspace?: string, + notifyHandler?: (workspace: Workspace) => void +): Promise<{ workspaceInfo: Workspace, err?: any, client?: Client }> { + return await rateLimiter.exec(async () => { + // We need to search for duplicate workspaceUrl + await searchPromise + + // Safe generate workspace record. + searchPromise = generateWorkspaceRecord(db, email, productId, version, workspaceName, workspace) + + const workspaceInfo = await searchPromise + + notifyHandler?.(workspaceInfo) + + const wsColl = db.collection>(WORKSPACE_COLLECTION) + + async function updateInfo (ops: Partial): Promise { + await wsColl.updateOne({ _id: workspaceInfo._id }, { $set: ops }) + console.log('update', ops) + } + + await updateInfo({ createProgress: 10 }) + + let client: Client | undefined + const childLogger = ctx.newChild( + 'createWorkspace', + { workspace: workspaceInfo.workspace }, + {}, + ctx.logger.childLogger?.(workspaceInfo.workspace, {}) ?? ctx.logger + ) + const ctxModellogger: ModelLogger = { + log: (msg, data) => { + void childLogger.info(msg, data) + }, + error: (msg, data) => { + void childLogger.error(msg, data) + } + } + try { + const initWS = getMetadata(toolPlugin.metadata.InitWorkspace) + const wsId = getWorkspaceId(workspaceInfo.workspace, productId) + + // We should not try to clone INIT_WS into INIT_WS during it's creation. + if ( + initWS !== undefined && + (await getWorkspaceById(db, productId, initWS)) !== null && + initWS !== workspaceInfo.workspace + ) { + // Just any valid model for transactor to be able to function + await ( + await initModel(ctx, getTransactor(), wsId, txes, [], ctxModellogger, async (value) => { + await updateInfo({ createProgress: Math.round((Math.min(value, 100) / 100) * 20) }) + }) + ).close() + await updateInfo({ createProgress: 20 }) + // Clone init workspace. + await cloneWorkspace( + getTransactor(), + getWorkspaceId(initWS, productId), + getWorkspaceId(workspaceInfo.workspace, productId), + true, + async (value) => { + await updateInfo({ createProgress: 20 + Math.round((Math.min(value, 100) / 100) * 30) }) + } + ) + await updateInfo({ createProgress: 50 }) + client = await upgradeModel( + ctx, + getTransactor(), + wsId, + txes, + migrationOperation, + ctxModellogger, + true, + async (value) => { + await updateInfo({ createProgress: Math.round(50 + (Math.min(value, 100) / 100) * 40) }) + } + ) + await updateInfo({ createProgress: 90 }) + } else { + client = await initModel( + ctx, + getTransactor(), + wsId, + txes, + migrationOperation, + ctxModellogger, + async (value) => { + await updateInfo({ createProgress: Math.round(Math.min(value, 100)) }) + } + ) + } + } catch (err: any) { + Analytics.handleError(err) + return { workspaceInfo, err, client: null as any } + } + // Workspace is created, we need to clear disabled flag. + await updateInfo({ createProgress: 100, disabled: false, creating: false }) + return { workspaceInfo, client } + }) +} + +/** + * @public + */ +export async function upgradeWorkspace ( + ctx: MeasureContext, + version: Data, + txes: Tx[], + migrationOperation: [string, MigrateOperation][], + productId: string, + db: Db, + workspaceUrl: string, + logger: ModelLogger = consoleModelLogger, + forceUpdate: boolean = true +): Promise { + const ws = await getWorkspaceByUrl(db, productId, workspaceUrl) + if (ws === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspaceUrl })) + } + if (ws.productId !== productId) { + if (productId !== '' || ws.productId !== undefined) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.ProductIdMismatch, { productId })) + } + } + const versionStr = versionToString(version) + + await ctx.info('upgrading', { + force: forceUpdate, + currentVersion: ws?.version !== undefined ? versionToString(ws.version) : '', + toVersion: versionStr + }) + + if (ws?.version !== undefined && !forceUpdate && versionStr === versionToString(ws.version)) { + return versionStr + } + await db.collection(WORKSPACE_COLLECTION).updateOne( + { _id: ws._id }, + { + $set: { version } + } + ) + await ( + await upgradeModel( + ctx, + getTransactor(), + getWorkspaceId(ws.workspace, productId), + txes, + migrationOperation, + logger, + false, + async (value) => {} + ) + ).close() + return versionStr +} + +/** + * @public + */ +export const createUserWorkspace = + (version: Data, txes: Tx[], migrationOperation: [string, MigrateOperation][]) => + async (ctx: MeasureContext, db: Db, productId: string, token: string, workspaceName: string): Promise => { + const { email } = decodeToken(token) + + await ctx.info('Creating workspace', { workspaceName, email }) + + const info = await getAccount(db, email) + + if (info === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) + } + if (info.confirmed === false) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotConfirmed, { account: email })) + } + + if (info.lastWorkspace !== undefined && info.admin === false) { + if (Date.now() - info.lastWorkspace < 60 * 1000) { + throw new PlatformError( + new Status(Severity.ERROR, platform.status.WorkspaceRateLimit, { workspace: workspaceName }) + ) + } + } + + async function doCreate (info: Account, notifyHandler: (workspace: Workspace) => void): Promise { + const { workspaceInfo, err, client } = await createWorkspace( + ctx, + version, + txes, + migrationOperation, + db, + productId, + email, + workspaceName, + undefined, + notifyHandler + ) + + if (err != null) { + await ctx.error('failed to create workspace', { err, workspaceName, email }) + // We need to drop workspace, to prevent wrong data usage. + + await db.collection(WORKSPACE_COLLECTION).updateOne( + { + _id: workspaceInfo._id + }, + { $set: { disabled: true, message: JSON.stringify(err?.message ?? ''), err: JSON.stringify(err) } } + ) + throw err + } + try { + info.lastWorkspace = Date.now() + + // Update last workspace time. + await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: info._id }, { $set: { lastWorkspace: Date.now() } }) + + const initWS = getMetadata(toolPlugin.metadata.InitWorkspace) + const shouldUpdateAccount = initWS !== undefined && (await getWorkspaceById(db, productId, initWS)) !== null + await assignWorkspace(ctx, db, productId, email, workspaceInfo.workspace, shouldUpdateAccount, client) + await setRole(email, workspaceInfo.workspace, productId, AccountRole.Owner, client) + await ctx.info('Creating server side done', { workspaceName, email }) + } finally { + await client?.close() + } + } + + const workspaceInfo = await new Promise((resolve) => { + void doCreate(info, (info: Workspace) => { + resolve(info) + }) + }) + + await assignWorkspaceRaw(db, { account: info, workspace: workspaceInfo }) + + const result = { + endpoint: getEndpoint(), + email, + token: generateToken(email, getWorkspaceId(workspaceInfo.workspace, productId), getExtra(info)), + productId, + workspace: workspaceInfo.workspaceUrl + } + await ctx.info('Creating user side done', { workspaceName, email }) + return result + } + +/** + * @public + */ +export async function getInviteLink ( + ctx: MeasureContext, + db: Db, + productId: string, + token: string, + exp: number, + emailMask: string, + limit: number +): Promise { + const { workspace, email } = decodeToken(token) + const wsPromise = await getWorkspaceById(db, productId, workspace.name) + if (wsPromise === null) { + await ctx.error('workspace not found', { workspace, email }) + throw new PlatformError( + new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspace.name }) + ) + } + await ctx.info('Getting invite link', { workspace: workspace.name, emailMask, limit }) + const result = await db.collection(INVITE_COLLECTION).insertOne({ + workspace, + exp: Date.now() + exp, + emailMask, + limit + }) + return result.insertedId +} + +/** + * @public + */ +export type ClientWorkspaceInfo = Omit & { workspaceId: string } + +/** + * @public + */ +export type WorkspaceInfo = Omit + +function mapToClientWorkspace (ws: Workspace): ClientWorkspaceInfo { + const { _id, accounts, ...data } = ws + return { ...data, workspace: ws.workspaceUrl ?? ws.workspace, workspaceId: ws.workspace } +} + +function trimWorkspaceInfo (ws: Workspace): WorkspaceInfo { + const { _id, accounts, ...data } = ws + return { ...data } +} + +/** + * @public + */ +export async function getUserWorkspaces ( + ctx: MeasureContext, + db: Db, + productId: string, + token: string +): Promise { + const { email } = decodeToken(token) + const account = await getAccount(db, email) + if (account === null) { + await ctx.error('account not found', { email }) + return [] + } + return ( + await db + .collection(WORKSPACE_COLLECTION) + .find(withProductId(productId, account.admin === true ? {} : { _id: { $in: account.workspaces } })) + .toArray() + ) + .filter((it) => it.disabled !== true || it.creating === true) + .map(mapToClientWorkspace) +} + +/** + * @public + */ +export async function getWorkspaceInfo ( + ctx: MeasureContext, + db: Db, + productId: string, + token: string, + _updateLastVisit: boolean = false +): Promise { + const { email, workspace, extra } = decodeToken(token) + const guest = extra?.guest === 'true' + let account: Pick | Account | null = null + const query: Filter = { + workspace: workspace.name + } + if (email !== systemAccountEmail && !guest) { + account = await getAccount(db, email) + if (account === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + } else if (guest) { + account = { + admin: false, + workspaces: [] + } + } else { + account = { + admin: true, + workspaces: [] + } + } + + if (account.admin !== true && !guest) { + query._id = { $in: account.workspaces } + } + + const [ws] = ( + await db.collection(WORKSPACE_COLLECTION).find(withProductId(productId, query)).toArray() + ).filter((it) => it.disabled !== true || account?.admin === true || it.creating === true) + if (ws == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + if (_updateLastVisit && isAccount(account)) { + await updateLastVisit(db, ws, account) + } + return mapToClientWorkspace(ws) +} + +function isAccount (data: Pick | Account | null): data is Account { + return (data as Account)._id !== undefined +} + +async function updateLastVisit (db: Db, ws: Workspace, account: Account): Promise { + const now = Date.now() + await db.collection(WORKSPACE_COLLECTION).updateOne({ _id: ws._id }, { $set: { lastVisit: now } }) + + // Add workspace to account + await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { lastVisit: now } }) +} + +async function getWorkspaceAndAccount ( + ctx: MeasureContext, + db: Db, + productId: string, + _email: string, + workspaceUrl: string +): Promise<{ account: Account, workspace: Workspace }> { + const email = cleanEmail(_email) + const wsPromise = await getWorkspaceById(db, productId, workspaceUrl) + if (wsPromise === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspaceUrl })) + } + const account = await getAccount(db, email) + if (account === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) + } + return { account, workspace: wsPromise } +} + +/** + * @public + */ +export async function setRole ( + _email: string, + workspace: string, + productId: string, + role: AccountRole, + client?: Client +): Promise { + if (!Object.values(AccountRole).includes(role)) return + const email = cleanEmail(_email) + const connection = client ?? (await connect(getTransactor(), getWorkspaceId(workspace, productId))) + try { + const ops = new TxOperations(connection, core.account.System) + + const existingAccount = await ops.findOne(contact.class.PersonAccount, { email }) + + if (existingAccount !== undefined) { + await ops.update(existingAccount, { + role + }) + } + } finally { + if (client === undefined) { + await connection.close() + } + } +} + +/** + * @public + */ +export async function assignWorkspace ( + ctx: MeasureContext, + db: Db, + productId: string, + _email: string, + workspaceId: string, + shouldReplaceAccount: boolean = false, + client?: Client, + personAccountId?: Ref +): Promise { + const email = cleanEmail(_email) + const initWS = getMetadata(toolPlugin.metadata.InitWorkspace) + if (initWS !== undefined && initWS === workspaceId) { + Analytics.handleError(new Error(`assign-workspace failed ${email} ${workspaceId}`)) + await ctx.error('assign-workspace failed', { email, workspaceId, reason: 'initWs === workspaceId' }) + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + const workspaceInfo = await getWorkspaceAndAccount(ctx, db, productId, email, workspaceId) + + if (workspaceInfo.account !== null) { + await createPersonAccount( + workspaceInfo.account, + productId, + workspaceId, + shouldReplaceAccount, + client, + personAccountId + ) + } + + // Add account into workspace. + await assignWorkspaceRaw(db, workspaceInfo) + + await ctx.info('assign-workspace success', { email, workspaceId }) + return workspaceInfo.workspace +} + +async function assignWorkspaceRaw (db: Db, workspaceInfo: { account: Account, workspace: Workspace }): Promise { + await db + .collection(WORKSPACE_COLLECTION) + .updateOne({ _id: workspaceInfo.workspace._id }, { $addToSet: { accounts: workspaceInfo.account._id } }) + + // Add workspace to account + await db + .collection(ACCOUNT_COLLECTION) + .updateOne({ _id: workspaceInfo.account._id }, { $addToSet: { workspaces: workspaceInfo.workspace._id } }) +} + +async function createEmployee (ops: TxOperations, name: string, _email: string): Promise> { + const id = generateId() + let avatar = `${AvatarType.COLOR}://${getAvatarColorForId(id)}` + const email = cleanEmail(_email) + if (isEmail(email)) { + const gravatarId = buildGravatarId(email) + const hasGravatar = await checkHasGravatar(gravatarId) + if (hasGravatar) { + avatar = `${AvatarType.GRAVATAR}://${gravatarId}` + } + } + + await ops.createDoc( + contact.class.Person, + contact.space.Employee, + { + name, + city: '', + avatar + }, + id + ) + await ops.createMixin(id, contact.class.Person, contact.space.Contacts, contact.mixin.Employee, { + active: true + }) + if (isEmail(email)) { + await ops.addCollection(contact.class.Channel, contact.space.Contacts, id, contact.mixin.Employee, 'channels', { + provider: contact.channelProvider.Email, + value: email + }) + } + + return id +} + +async function replaceCurrentAccount ( + ops: TxOperations, + account: Account, + currentAccount: PersonAccount, + name: string +): Promise { + await ops.update(currentAccount, { email: account.email }) + const employee = await ops.findOne(contact.mixin.Employee, { _id: currentAccount.person as Ref }) + if (employee === undefined) { + // Employee was deleted, let's restore it. + const employeeId = await createEmployee(ops, name, account.email) + + await ops.updateDoc(contact.class.PersonAccount, currentAccount.space, currentAccount._id, { + person: employeeId + }) + } else { + const email = cleanEmail(account.email) + const gravatarId = buildGravatarId(email) + const hasGravatar = await checkHasGravatar(gravatarId) + + await ops.update(employee, { + name, + avatar: hasGravatar + ? `${AvatarType.GRAVATAR}://${gravatarId}` + : `${AvatarType.COLOR}://${getAvatarColorForId(employee._id)}`, + ...(employee.active ? {} : { active: true }) + }) + const currentChannel = await ops.findOne(contact.class.Channel, { + attachedTo: employee._id, + provider: contact.channelProvider.Email + }) + if (currentChannel === undefined) { + await ops.addCollection( + contact.class.Channel, + contact.space.Contacts, + employee._id, + contact.mixin.Employee, + 'channels', + { + provider: contact.channelProvider.Email, + value: email + } + ) + } else if (currentChannel.value !== email) { + await ops.update(currentChannel, { value: email }) + } + } +} + +async function createPersonAccount ( + account: Account, + productId: string, + workspace: string, + shouldReplaceCurrent: boolean = false, + client?: Client, + personAccountId?: Ref +): Promise { + const connection = client ?? (await connect(getTransactor(), getWorkspaceId(workspace, productId))) + try { + const ops = new TxOperations(connection, core.account.System) + + const name = combineName(account.first, account.last) + // Check if EmployeeAccount is not exists + if (shouldReplaceCurrent) { + const currentAccount = await ops.findOne(contact.class.PersonAccount, {}) + if (currentAccount !== undefined) { + await replaceCurrentAccount(ops, account, currentAccount, name) + return + } + } + const existingAccount = await ops.findOne(contact.class.PersonAccount, { email: account.email }) + if (existingAccount === undefined) { + const employee = await createEmployee(ops, name, account.email) + + await ops.createDoc( + contact.class.PersonAccount, + core.space.Model, + { + email: account.email, + person: employee, + role: AccountRole.User + }, + personAccountId + ) + } else { + const employee = await ops.findOne(contact.mixin.Employee, { _id: existingAccount.person as Ref }) + if (employee === undefined) { + // Employee was deleted, let's restore it. + const employeeId = await createEmployee(ops, name, account.email) + + await ops.updateDoc(contact.class.PersonAccount, existingAccount.space, existingAccount._id, { + person: employeeId + }) + } else if (!employee.active) { + await ops.update(employee, { + active: true + }) + } + } + } finally { + if (client === undefined) { + await connection.close() + } + } +} + +/** + * @public + */ +export async function changePassword ( + ctx: MeasureContext, + db: Db, + productId: string, + token: string, + oldPassword: string, + password: string +): Promise { + const { email } = decodeToken(token) + const account = await getAccountInfo(ctx, db, email, oldPassword) + + const salt = randomBytes(32) + const hash = hashWithSalt(password, salt) + + await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { salt, hash } }) + await ctx.info('change-password success', { email }) +} + +/** + * @public + */ +export async function changeEmail (ctx: MeasureContext, db: Db, account: Account, newEmail: string): Promise { + await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { email: newEmail } }) + await ctx.info('change-email success', { email: newEmail }) +} + +/** + * @public + */ +export async function replacePassword (db: Db, productId: string, email: string, password: string): Promise { + const account = await getAccount(db, email) + + if (account === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) + } + const salt = randomBytes(32) + const hash = hashWithSalt(password, salt) + + await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { salt, hash } }) +} + +/** + * @public + */ +export async function requestPassword (ctx: MeasureContext, db: Db, productId: string, _email: string): Promise { + const email = cleanEmail(_email) + const account = await getAccount(db, email) + + if (account === null) { + await ctx.info('account not found', { email }) + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) + } + + const sesURL = getMetadata(accountPlugin.metadata.SES_URL) + if (sesURL === undefined || sesURL === '') { + throw new Error('Please provide email service url') + } + const front = getMetadata(accountPlugin.metadata.FrontURL) + if (front === undefined || front === '') { + throw new Error('Please provide front url') + } + + const token = generateToken( + '@restore', + getWorkspaceId('', productId), + getExtra(account, { + restore: email + }) + ) + + const link = concatLink(front, `/login/recovery?id=${token}`) + + const text = await translate(accountPlugin.string.RecoveryText, { link }) + const html = await translate(accountPlugin.string.RecoveryHTML, { link }) + const subject = await translate(accountPlugin.string.RecoverySubject, {}) + + const to = account.email + await fetch(concatLink(sesURL, '/send'), { + method: 'post', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + text, + html, + subject, + to + }) + }) + await ctx.info('recovery email sent', { email, accountEmail: account.email }) +} + +/** + * @public + */ +export async function restorePassword ( + ctx: MeasureContext, + db: Db, + productId: string, + token: string, + password: string +): Promise { + const decode = decodeToken(token) + const email = decode.extra?.restore + if (email === undefined) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) + } + const account = await getAccount(db, email) + + if (account === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) + } + + await updatePassword(db, account, password) + + return await login(ctx, db, productId, email, password) +} + +async function updatePassword (db: Db, account: Account, password: string | null): Promise { + const salt = randomBytes(32) + const hash = password !== null ? hashWithSalt(password, salt) : null + + await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { salt, hash } }) +} + +/** + * @public + */ +export async function removeWorkspace ( + ctx: MeasureContext, + db: Db, + productId: string, + email: string, + workspaceId: string +): Promise { + const { workspace, account } = await getWorkspaceAndAccount(ctx, db, productId, email, workspaceId) + + // Add account into workspace. + await db.collection(WORKSPACE_COLLECTION).updateOne({ _id: workspace._id }, { $pull: { accounts: account._id } }) + + // Add account a workspace + await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $pull: { workspaces: workspace._id } }) + await ctx.info('Workspace removed', { email, workspace }) +} + +/** + * @public + */ +export async function checkJoin ( + ctx: MeasureContext, + db: Db, + productId: string, + token: string, + inviteId: ObjectId +): Promise { + const { email } = decodeToken(token) + const invite = await getInvite(db, inviteId) + const workspace = await checkInvite(ctx, invite, email) + const ws = await getWorkspaceById(db, productId, workspace.name) + if (ws === null) { + await ctx.error('workspace not found', { name: workspace.name, email, inviteId }) + throw new PlatformError( + new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspace.name }) + ) + } + return await selectWorkspace(ctx, db, productId, token, ws?.workspaceUrl ?? ws.workspace, false) +} + +/** + * @public + */ +export async function dropWorkspace ( + ctx: MeasureContext, + db: Db, + productId: string, + workspaceId: string +): Promise { + const ws = await getWorkspaceById(db, productId, workspaceId) + if (ws === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspaceId })) + } + await db.collection(WORKSPACE_COLLECTION).deleteOne({ _id: ws._id }) + await db + .collection(ACCOUNT_COLLECTION) + .updateMany({ _id: { $in: ws.accounts ?? [] } }, { $pull: { workspaces: ws._id } }) + + await ctx.info('Workspace dropped', { workspace: ws.workspace }) +} + +/** + * @public + */ +export async function dropAccount (ctx: MeasureContext, db: Db, productId: string, email: string): Promise { + const account = await getAccount(db, email) + if (account === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) + } + + const workspaces = await db + .collection(WORKSPACE_COLLECTION) + .find(withProductId(productId, { _id: { $in: account.workspaces } })) + .toArray() + + await Promise.all( + workspaces.map(async (ws) => { + await deactivatePersonAccount(ctx, account.email, ws.workspace, productId) + }) + ) + + await db.collection(ACCOUNT_COLLECTION).deleteOne({ _id: account._id }) + await db + .collection(WORKSPACE_COLLECTION) + .updateMany({ _id: { $in: account.workspaces } }, { $pull: { accounts: account._id } }) + await ctx.info('Account Dropped', { email, account }) +} + +/** + * @public + */ +export async function leaveWorkspace ( + ctx: MeasureContext, + db: Db, + productId: string, + token: string, + email: string +): Promise { + const tokenData = decodeToken(token) + + const currentAccount = await getAccount(db, tokenData.email) + if (currentAccount === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: tokenData.email })) + } + + const workspace = await getWorkspaceById(db, productId, tokenData.workspace.name) + if (workspace === null) { + throw new PlatformError( + new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: tokenData.workspace.name }) + ) + } + + await deactivatePersonAccount(ctx, email, workspace.workspace, workspace.productId) + + const account = tokenData.email !== email ? await getAccount(db, email) : currentAccount + if (account !== null) { + await db + .collection(WORKSPACE_COLLECTION) + .updateOne({ _id: workspace._id }, { $pull: { accounts: account._id } }) + await db + .collection(ACCOUNT_COLLECTION) + .updateOne({ _id: account._id }, { $pull: { workspaces: workspace._id } }) + } + await ctx.info('Account removed from workspace', { email, workspace }) +} + +/** + * @public + */ +export async function sendInvite ( + ctx: MeasureContext, + db: Db, + productId: string, + token: string, + email: string +): Promise { + const tokenData = decodeToken(token) + const currentAccount = await getAccount(db, tokenData.email) + if (currentAccount === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: tokenData.email })) + } + + const workspace = await getWorkspaceById(db, productId, tokenData.workspace.name) + if (workspace === null) { + throw new PlatformError( + new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: tokenData.workspace.name }) + ) + } + + // TODO: Why we not send invite if user has account??? + // const account = await getAccount(db, email) + // if (account !== null) return + + const sesURL = getMetadata(accountPlugin.metadata.SES_URL) + if (sesURL === undefined || sesURL === '') { + throw new Error('Please provide email service url') + } + const front = getMetadata(accountPlugin.metadata.FrontURL) + if (front === undefined || front === '') { + throw new Error('Please provide front url') + } + + const expHours = 48 + const exp = expHours * 60 * 60 * 1000 + + const inviteId = await getInviteLink(ctx, db, productId, token, exp, email, 1) + const link = concatLink(front, `/login/join?inviteId=${inviteId.toString()}`) + + const ws = workspace.workspace + const text = await translate(accountPlugin.string.InviteText, { link, ws, expHours }) + const html = await translate(accountPlugin.string.InviteHTML, { link, ws, expHours }) + const subject = await translate(accountPlugin.string.InviteSubject, { ws }) + + const to = email + await fetch(concatLink(sesURL, '/send'), { + method: 'post', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + text, + html, + subject, + to + }) + }) + await ctx.info('Invite sent', { email, workspace, link }) +} + +async function deactivatePersonAccount ( + ctx: MeasureContext, + email: string, + workspace: string, + productId: string +): Promise { + const connection = await connect(getTransactor(), getWorkspaceId(workspace, productId)) + try { + const ops = new TxOperations(connection, core.account.System) + + const existingAccount = await ops.findOne(contact.class.PersonAccount, { email }) + + if (existingAccount !== undefined) { + const employee = await ops.findOne(contact.mixin.Employee, { _id: existingAccount.person as Ref }) + if (employee !== undefined) { + await ops.update(employee, { + active: false + }) + } + await ctx.info('account deactivated', { email, workspace }) + } + } finally { + await connection.close() + } +} + +/** + * @public + */ +export type AccountMethod = ( + ctx: MeasureContext, + db: Db, + productId: string, + request: any, + token?: string +) => Promise + +function wrap ( + accountMethod: (ctx: MeasureContext, db: Db, productId: string, ...args: any[]) => Promise +): AccountMethod { + return async function (ctx: MeasureContext, db: Db, productId: string, request: any, token?: string): Promise { + if (token !== undefined) request.params.unshift(token) + return await accountMethod(ctx, db, productId, ...request.params) + .then((result) => ({ id: request.id, result })) + .catch((err) => { + const status = + err instanceof PlatformError + ? err.status + : new Status(Severity.ERROR, platform.status.InternalServerError, {}) + if (status.code === platform.status.InternalServerError) { + Analytics.handleError(err) + void ctx.error('error', { status, err }) + } else { + void ctx.error('error', { status }) + } + return { + error: status + } + }) + } +} + +export async function joinWithProvider ( + ctx: MeasureContext, + db: Db, + productId: string, + _email: string, + first: string, + last: string, + inviteId: ObjectId, + extra?: Record +): Promise { + const email = cleanEmail(_email) + const invite = await getInvite(db, inviteId) + const workspace = await checkInvite(ctx, invite, email) + if (last == null) { + last = '' + } + let account = await getAccount(db, email) + if (account == null && extra !== undefined) { + account = await getAccountByQuery(db, extra) + } + if (account !== null) { + // we should clean password if account is not confirmed + if (account.confirmed === false) { + await updatePassword(db, account, null) + } + + const token = generateToken(email, getWorkspaceId('', productId), getExtra(account)) + const ws = await getWorkspaceById(db, productId, workspace.name) + + if (ws?.accounts.includes(account._id) ?? false) { + const result = { + endpoint: getEndpoint(), + email, + token + } + return result + } + + const wsRes = await assignWorkspace(ctx, db, productId, email, workspace.name, false) + const result = await selectWorkspace(ctx, db, productId, token, wsRes.workspaceUrl ?? wsRes.workspace, false) + + await useInvite(db, inviteId) + return result + } + + const newAccount = await createAcc(ctx, db, productId, email, null, first, last, true, extra) + const token = generateToken(email, getWorkspaceId('', productId), getExtra(newAccount)) + const ws = await assignWorkspace(ctx, db, productId, email, workspace.name, false) + const result = await selectWorkspace(ctx, db, productId, token, ws.workspaceUrl ?? ws.workspace, false) + + await useInvite(db, inviteId) + + return result +} + +export async function loginWithProvider ( + ctx: MeasureContext, + db: Db, + productId: string, + _email: string, + first: string, + last: string, + extra?: Record +): Promise { + const email = cleanEmail(_email) + if (last == null) { + last = '' + } + let account = await getAccount(db, email) + if (account == null && extra !== undefined) { + account = await getAccountByQuery(db, extra) + } + if (account !== null) { + // we should clean password if account is not confirmed + if (account.confirmed === false) { + await updatePassword(db, account, null) + } + const result = { + endpoint: getEndpoint(), + email, + token: generateToken(email, getWorkspaceId('', productId), getExtra(account)) + } + return result + } + const newAccount = await createAcc(ctx, db, productId, email, null, first, last, true, extra) + + const result = { + endpoint: getEndpoint(), + email, + token: generateToken(email, getWorkspaceId('', productId), getExtra(newAccount)) + } + return result +} + +/** + * @public + */ +export function getMethods ( + version: Data, + txes: Tx[], + migrateOperations: [string, MigrateOperation][] +): Record { + return { + getEndpoint: wrap(async () => getEndpoint()), + login: wrap(login), + join: wrap(join), + checkJoin: wrap(checkJoin), + signUpJoin: wrap(signUpJoin), + selectWorkspace: wrap(selectWorkspace), + getUserWorkspaces: wrap(getUserWorkspaces), + getInviteLink: wrap(getInviteLink), + getAccountInfo: wrap(getAccountInfo), + getWorkspaceInfo: wrap(getWorkspaceInfo), + createAccount: wrap(createAccount), + createWorkspace: wrap(createUserWorkspace(version, txes, migrateOperations)), + assignWorkspace: wrap(assignWorkspace), + removeWorkspace: wrap(removeWorkspace), + leaveWorkspace: wrap(leaveWorkspace), + listWorkspaces: wrap(listWorkspaces), + changePassword: wrap(changePassword), + requestPassword: wrap(requestPassword), + restorePassword: wrap(restorePassword), + sendInvite: wrap(sendInvite), + confirm: wrap(confirm), + getAccountInfoByToken: wrap(getAccountInfoByToken) + // updateAccount: wrap(updateAccount) + } +} + +export * from './plugin' +export default accountPlugin diff --git a/server/account/src/plugin.ts b/server/account/src/plugin.ts index 03c92c913a..0f5df6979e 100644 --- a/server/account/src/plugin.ts +++ b/server/account/src/plugin.ts @@ -8,7 +8,7 @@ export const accountId = 'account' as Plugin /** * @public */ -const accountPlugin = plugin(accountId, { +export const accountPlugin = plugin(accountId, { metadata: { FrontURL: '' as Metadata, SES_URL: '' as Metadata, @@ -26,5 +26,3 @@ const accountPlugin = plugin(accountId, { InviteSubject: '' as IntlString } }) - -export default accountPlugin diff --git a/server/account/src/service.ts b/server/account/src/service.ts new file mode 100644 index 0000000000..b1da23efeb --- /dev/null +++ b/server/account/src/service.ts @@ -0,0 +1,160 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { BaseWorkspaceInfo, Data, RateLimiter, Tx, Version, type MeasureContext } from '@hcengineering/core' +import { MigrateOperation, ModelLogger } from '@hcengineering/model' +import { FileModelLogger } from '@hcengineering/server-tool' +import { Db, MongoClient } from 'mongodb' +import path from 'path' +import { listWorkspacesRaw, updateWorkspace, upgradeWorkspace, Workspace, WorkspaceInfo } from './operations' + +export type UpgradeErrorHandler = (workspace: BaseWorkspaceInfo, error: any) => Promise + +export interface UpgradeOptions { + errorHandler: (workspace: BaseWorkspaceInfo, error: any) => Promise + force: boolean + console: boolean + logs: string + parallel: number +} + +export class UpgradeWorker { + constructor ( + readonly db: Db, + readonly client: MongoClient, + readonly version: Data, + readonly txes: Tx[], + readonly migrationOperation: [string, MigrateOperation][], + readonly productId: string + ) {} + + canceled = false + + st: number = Date.now() + workspaces: BaseWorkspaceInfo[] = [] + toProcess: number = 0 + + async close (): Promise { + this.canceled = true + } + + private async _upgradeWorkspace (ctx: MeasureContext, ws: WorkspaceInfo, opt: UpgradeOptions): Promise { + if (ws.disabled === true) { + return + } + const t = Date.now() + + const ctxModelLogger: ModelLogger = { + log (msg: string, data: any): void { + void ctx.info(msg, data) + }, + error (msg: string, data: any): void { + void ctx.error(msg, data) + } + } + + const logger = opt.console ? ctxModelLogger : new FileModelLogger(path.join(opt.logs, `${ws.workspace}.log`)) + + const avgTime = (Date.now() - this.st) / (this.workspaces.length - this.toProcess + 1) + await ctx.info('----------------------------------------------------------\n---UPGRADING----', { + pending: this.toProcess, + eta: Math.floor(avgTime * this.toProcess), + workspace: ws.workspace + }) + this.toProcess-- + try { + await upgradeWorkspace( + ctx, + this.version, + this.txes, + this.migrationOperation, + this.productId, + this.db, + ws.workspaceUrl ?? ws.workspace, + logger, + opt.force + ) + await ctx.info('---done---------', { + pending: this.toProcess, + time: Date.now() - t, + workspace: ws.workspace + }) + } catch (err: any) { + await opt.errorHandler(ws, err) + + logger.log('error', err) + + if (!opt.console) { + await ctx.error('error', err) + } + + await ctx.info('---failed---------', { + pending: this.toProcess, + time: Date.now() - t, + workspace: ws.workspace + }) + } finally { + if (!opt.console) { + ;(logger as FileModelLogger).close() + } + } + } + + async upgradeAll (ctx: MeasureContext, opt: UpgradeOptions): Promise { + const workspaces = await listWorkspacesRaw(this.db, this.productId) + workspaces.sort((a, b) => b.lastVisit - a.lastVisit) + + // We need to update workspaces with missing workspaceUrl + for (const ws of workspaces) { + if (ws.workspaceUrl == null) { + const upd: Partial = { + workspaceUrl: ws.workspace + } + if (ws.workspaceName == null) { + upd.workspaceName = ws.workspace + } + await updateWorkspace(this.db, this.productId, ws, upd) + } + } + + const withError: string[] = [] + this.toProcess = workspaces.length + this.st = Date.now() + + if (opt.parallel !== 0) { + const parallel = opt.parallel + const rateLimit = new RateLimiter(parallel) + await ctx.info('parallel upgrade', { parallel }) + await Promise.all( + workspaces.map((it) => + rateLimit.add(async () => { + await ctx.with('do-upgrade', {}, async () => { + await this._upgradeWorkspace(ctx, it, opt) + }) + }) + ) + ) + await ctx.info('Upgrade done') + } else { + await ctx.info('UPGRADE write logs at:', { logs: opt.logs }) + for (const ws of workspaces) { + await this._upgradeWorkspace(ctx, ws, opt) + } + if (withError.length > 0) { + await ctx.info('Failed workspaces', withError) + } + } + } +}