mirror of
https://github.com/hcengineering/platform.git
synced 2025-05-15 03:51:04 +00:00
uberf-9726: manage integrations in accounts (#8475)
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / uitest-workspaces (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / uitest-workspaces (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions
This commit is contained in:
parent
099fb90c59
commit
9bf5234730
@ -38,7 +38,11 @@ import type {
|
|||||||
WorkspaceLoginInfo,
|
WorkspaceLoginInfo,
|
||||||
RegionInfo,
|
RegionInfo,
|
||||||
WorkspaceOperation,
|
WorkspaceOperation,
|
||||||
MailboxInfo
|
MailboxInfo,
|
||||||
|
Integration,
|
||||||
|
IntegrationKey,
|
||||||
|
IntegrationSecret,
|
||||||
|
IntegrationSecretKey
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
@ -136,6 +140,25 @@ export interface AccountClient {
|
|||||||
lastName: string
|
lastName: string
|
||||||
) => Promise<{ uuid: PersonUuid, socialId: PersonId }>
|
) => Promise<{ uuid: PersonUuid, socialId: PersonId }>
|
||||||
addSocialIdToPerson: (person: PersonUuid, type: SocialIdType, value: string, confirmed: boolean) => Promise<PersonId>
|
addSocialIdToPerson: (person: PersonUuid, type: SocialIdType, value: string, confirmed: boolean) => Promise<PersonId>
|
||||||
|
createIntegration: (integration: Integration) => Promise<void>
|
||||||
|
updateIntegration: (integration: Integration) => Promise<void>
|
||||||
|
deleteIntegration: (integrationKey: IntegrationKey) => Promise<void>
|
||||||
|
getIntegration: (integrationKey: IntegrationKey) => Promise<Integration | null>
|
||||||
|
listIntegrations: (filter: {
|
||||||
|
socialId?: PersonId
|
||||||
|
kind?: string
|
||||||
|
workspaceUuid?: WorkspaceUuid | null
|
||||||
|
}) => Promise<Integration[]>
|
||||||
|
addIntegrationSecret: (integrationSecret: IntegrationSecret) => Promise<void>
|
||||||
|
updateIntegrationSecret: (integrationSecret: IntegrationSecret) => Promise<void>
|
||||||
|
deleteIntegrationSecret: (integrationSecretKey: IntegrationSecretKey) => Promise<void>
|
||||||
|
getIntegrationSecret: (integrationSecretKey: IntegrationSecretKey) => Promise<IntegrationSecret | null>
|
||||||
|
listIntegrationsSecrets: (filter: {
|
||||||
|
socialId?: PersonId
|
||||||
|
kind?: string
|
||||||
|
workspaceUuid?: WorkspaceUuid | null
|
||||||
|
key?: string
|
||||||
|
}) => Promise<IntegrationSecret[]>
|
||||||
|
|
||||||
setCookie: () => Promise<void>
|
setCookie: () => Promise<void>
|
||||||
deleteCookie: () => Promise<void>
|
deleteCookie: () => Promise<void>
|
||||||
@ -721,6 +744,105 @@ class AccountClientImpl implements AccountClient {
|
|||||||
await this.rpc(request)
|
await this.rpc(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createIntegration (integration: Integration): Promise<void> {
|
||||||
|
const request = {
|
||||||
|
method: 'createIntegration' as const,
|
||||||
|
params: integration
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.rpc(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateIntegration (integration: Integration): Promise<void> {
|
||||||
|
const request = {
|
||||||
|
method: 'updateIntegration' as const,
|
||||||
|
params: integration
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.rpc(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIntegration (integrationKey: IntegrationKey): Promise<void> {
|
||||||
|
const request = {
|
||||||
|
method: 'deleteIntegration' as const,
|
||||||
|
params: integrationKey
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.rpc(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIntegration (integrationKey: IntegrationKey): Promise<Integration | null> {
|
||||||
|
const request = {
|
||||||
|
method: 'getIntegration' as const,
|
||||||
|
params: integrationKey
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.rpc(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listIntegrations (filter: {
|
||||||
|
socialId?: PersonId
|
||||||
|
kind?: string
|
||||||
|
workspaceUuid?: WorkspaceUuid | null
|
||||||
|
}): Promise<Integration[]> {
|
||||||
|
const request = {
|
||||||
|
method: 'listIntegrations' as const,
|
||||||
|
params: filter
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.rpc(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
async addIntegrationSecret (integrationSecret: IntegrationSecret): Promise<void> {
|
||||||
|
const request = {
|
||||||
|
method: 'addIntegrationSecret' as const,
|
||||||
|
params: integrationSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.rpc(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateIntegrationSecret (integrationSecret: IntegrationSecret): Promise<void> {
|
||||||
|
const request = {
|
||||||
|
method: 'updateIntegrationSecret' as const,
|
||||||
|
params: integrationSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.rpc(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIntegrationSecret (integrationSecretKey: IntegrationSecretKey): Promise<void> {
|
||||||
|
const request = {
|
||||||
|
method: 'deleteIntegrationSecret' as const,
|
||||||
|
params: integrationSecretKey
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.rpc(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIntegrationSecret (integrationSecretKey: IntegrationSecretKey): Promise<IntegrationSecret | null> {
|
||||||
|
const request = {
|
||||||
|
method: 'getIntegrationSecret' as const,
|
||||||
|
params: integrationSecretKey
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.rpc(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listIntegrationsSecrets (filter: {
|
||||||
|
socialId?: PersonId
|
||||||
|
kind?: string
|
||||||
|
workspaceUuid?: WorkspaceUuid | null
|
||||||
|
key?: string
|
||||||
|
}): Promise<IntegrationSecret[]> {
|
||||||
|
const request = {
|
||||||
|
method: 'listIntegrationsSecrets' as const,
|
||||||
|
params: filter
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.rpc(request)
|
||||||
|
}
|
||||||
|
|
||||||
async setCookie (): Promise<void> {
|
async setCookie (): Promise<void> {
|
||||||
const url = concatLink(this.url, '/cookie')
|
const url = concatLink(this.url, '/cookie')
|
||||||
const response = await fetch(url, { ...this.request, method: 'PUT' })
|
const response = await fetch(url, { ...this.request, method: 'PUT' })
|
||||||
|
@ -55,3 +55,22 @@ export interface MailboxInfo {
|
|||||||
aliases: string[]
|
aliases: string[]
|
||||||
appPasswords: string[]
|
appPasswords: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Integration {
|
||||||
|
socialId: PersonId
|
||||||
|
kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
|
||||||
|
workspaceUuid?: WorkspaceUuid
|
||||||
|
data?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntegrationKey = Omit<Integration, 'data'>
|
||||||
|
|
||||||
|
export interface IntegrationSecret {
|
||||||
|
socialId: PersonId
|
||||||
|
kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
|
||||||
|
workspaceUuid?: WorkspaceUuid
|
||||||
|
key: string // Key for the secret in the integration. Different secrets for the same integration must have different keys. Can be any string. E.g. '', 'user_app_1' etc.
|
||||||
|
secret: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntegrationSecretKey = Omit<IntegrationSecret, 'secret'>
|
||||||
|
@ -13,7 +13,6 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Metadata, PluginLoader, PluginModule, Resources } from '.'
|
import { Metadata, PluginLoader, PluginModule, Resources } from '.'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -152,9 +151,13 @@ export default plugin(platformId, {
|
|||||||
WorkspaceNotFound: '' as StatusCode<{ workspaceUuid?: string, workspaceName?: string, workspaceUrl?: string }>,
|
WorkspaceNotFound: '' as StatusCode<{ workspaceUuid?: string, workspaceName?: string, workspaceUrl?: string }>,
|
||||||
WorkspaceArchived: '' as StatusCode<{ workspaceUuid: string }>,
|
WorkspaceArchived: '' as StatusCode<{ workspaceUuid: string }>,
|
||||||
WorkspaceMigration: '' as StatusCode<{ workspaceUuid: string }>,
|
WorkspaceMigration: '' as StatusCode<{ workspaceUuid: string }>,
|
||||||
SocialIdNotFound: '' as StatusCode<{ socialId: string, type: string }>,
|
SocialIdNotFound: '' as StatusCode<{ value?: string, type?: string, _id?: string }>,
|
||||||
SocialIdNotConfirmed: '' as StatusCode<{ socialId: string, type: string }>,
|
SocialIdNotConfirmed: '' as StatusCode<{ socialId: string, type: string }>,
|
||||||
SocialIdAlreadyConfirmed: '' as StatusCode<{ socialId: string, type: string }>,
|
SocialIdAlreadyConfirmed: '' as StatusCode<{ socialId: string, type: string }>,
|
||||||
|
IntegrationAlreadyExists: '' as StatusCode,
|
||||||
|
IntegrationNotFound: '' as StatusCode,
|
||||||
|
IntegrationSecretAlreadyExists: '' as StatusCode,
|
||||||
|
IntegrationSecretNotFound: '' as StatusCode,
|
||||||
PersonNotFound: '' as StatusCode<{ person: string }>,
|
PersonNotFound: '' as StatusCode<{ person: string }>,
|
||||||
InvalidPassword: '' as StatusCode<{ account: string }>,
|
InvalidPassword: '' as StatusCode<{ account: string }>,
|
||||||
AccountAlreadyExists: '' as StatusCode,
|
AccountAlreadyExists: '' as StatusCode,
|
||||||
|
@ -13,13 +13,12 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//
|
//
|
||||||
|
|
||||||
import { AccountRole, MeasureContext, PersonId, PersonUuid, SocialIdType, WorkspaceUuid } from '@hcengineering/core'
|
import { AccountRole, MeasureContext, PersonUuid, WorkspaceUuid } from '@hcengineering/core'
|
||||||
import platform, { PlatformError, Status, Severity, getMetadata } from '@hcengineering/platform'
|
import platform, { PlatformError, Status, Severity, getMetadata } from '@hcengineering/platform'
|
||||||
import { decodeTokenVerbose } from '@hcengineering/server-token'
|
import { decodeTokenVerbose } from '@hcengineering/server-token'
|
||||||
import * as utils from '../utils'
|
|
||||||
|
|
||||||
import { AccountDB } from '../types'
|
import { AccountDB } from '../types'
|
||||||
import { createInvite, createInviteLink, sendInvite, resendInvite, addSocialIdToPerson } from '../operations'
|
import { createInvite, createInviteLink, sendInvite, resendInvite } from '../operations'
|
||||||
import { accountPlugin } from '../plugin'
|
import { accountPlugin } from '../plugin'
|
||||||
|
|
||||||
// Mock platform
|
// Mock platform
|
||||||
@ -453,106 +452,4 @@ describe('invite operations', () => {
|
|||||||
expect(global.fetch).toHaveBeenCalled()
|
expect(global.fetch).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('addSocialIdToPerson', () => {
|
|
||||||
const mockCtx = {
|
|
||||||
error: jest.fn()
|
|
||||||
} as unknown as MeasureContext
|
|
||||||
|
|
||||||
const mockDb = {} as unknown as AccountDB
|
|
||||||
const mockBranding = null
|
|
||||||
const mockToken = 'test-token'
|
|
||||||
|
|
||||||
// Create spy only for this test suite
|
|
||||||
const addSocialIdSpy = jest.spyOn(utils, 'addSocialId')
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
// Restore the original implementation
|
|
||||||
addSocialIdSpy.mockRestore()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should allow github service to add social id', async () => {
|
|
||||||
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
|
|
||||||
extra: { service: 'github' }
|
|
||||||
})
|
|
||||||
const newSocialId = 'new-social-id' as PersonId
|
|
||||||
addSocialIdSpy.mockResolvedValue(newSocialId)
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
person: 'test-person' as PersonUuid,
|
|
||||||
type: SocialIdType.GITHUB,
|
|
||||||
value: 'test-value',
|
|
||||||
confirmed: true
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params)
|
|
||||||
|
|
||||||
expect(result).toBe(newSocialId)
|
|
||||||
expect(addSocialIdSpy).toHaveBeenCalledWith(mockDb, params.person, params.type, params.value, params.confirmed)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should allow admin to add social id', async () => {
|
|
||||||
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
|
|
||||||
extra: { admin: 'true' }
|
|
||||||
})
|
|
||||||
const newSocialId = 'new-social-id' as PersonId
|
|
||||||
addSocialIdSpy.mockResolvedValue(newSocialId)
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
person: 'test-person' as PersonUuid,
|
|
||||||
type: SocialIdType.GITHUB,
|
|
||||||
value: 'test-value',
|
|
||||||
confirmed: false
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params)
|
|
||||||
|
|
||||||
expect(result).toBe(newSocialId)
|
|
||||||
expect(addSocialIdSpy).toHaveBeenCalledWith(mockDb, params.person, params.type, params.value, params.confirmed)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should throw error for unauthorized service', async () => {
|
|
||||||
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
|
|
||||||
extra: { service: 'other-service' }
|
|
||||||
})
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
person: 'test-person' as PersonUuid,
|
|
||||||
type: SocialIdType.GITHUB,
|
|
||||||
value: 'test-value',
|
|
||||||
confirmed: false
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params)).rejects.toThrow(
|
|
||||||
new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(addSocialIdSpy).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should throw error for regular user', async () => {
|
|
||||||
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
|
|
||||||
account: 'test-account',
|
|
||||||
workspace: 'test-workspace',
|
|
||||||
extra: {}
|
|
||||||
})
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
person: 'test-person' as PersonUuid,
|
|
||||||
type: SocialIdType.GITHUB,
|
|
||||||
value: 'test-value',
|
|
||||||
confirmed: false
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params)).rejects.toThrow(
|
|
||||||
new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(addSocialIdSpy).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
@ -31,6 +31,8 @@ interface TestWorkspace {
|
|||||||
lastProcessingTime?: number
|
lastProcessingTime?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ns = 'global_account'
|
||||||
|
|
||||||
describe('PostgresDbCollection', () => {
|
describe('PostgresDbCollection', () => {
|
||||||
let mockClient: any
|
let mockClient: any
|
||||||
let collection: PostgresDbCollection<TestWorkspace, 'uuid'>
|
let collection: PostgresDbCollection<TestWorkspace, 'uuid'>
|
||||||
@ -40,7 +42,7 @@ describe('PostgresDbCollection', () => {
|
|||||||
unsafe: jest.fn().mockResolvedValue([]) // Default to empty array result
|
unsafe: jest.fn().mockResolvedValue([]) // Default to empty array result
|
||||||
}
|
}
|
||||||
|
|
||||||
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, 'uuid')
|
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, 'uuid', ns)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getTableName', () => {
|
describe('getTableName', () => {
|
||||||
@ -208,7 +210,7 @@ describe('AccountPostgresDbCollection', () => {
|
|||||||
unsafe: jest.fn().mockResolvedValue([])
|
unsafe: jest.fn().mockResolvedValue([])
|
||||||
}
|
}
|
||||||
|
|
||||||
collection = new AccountPostgresDbCollection(mockClient as Sql)
|
collection = new AccountPostgresDbCollection(mockClient as Sql, ns)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getTableName', () => {
|
describe('getTableName', () => {
|
||||||
|
1009
server/account/src/__tests__/serviceOperations.test.ts
Normal file
1009
server/account/src/__tests__/serviceOperations.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -932,7 +932,7 @@ describe('account utils', () => {
|
|||||||
await expect(confirmEmail(mockCtx, mockDb, account, email)).rejects.toThrow(
|
await expect(confirmEmail(mockCtx, mockDb, account, email)).rejects.toThrow(
|
||||||
new PlatformError(
|
new PlatformError(
|
||||||
new Status(Severity.ERROR, platform.status.SocialIdNotFound, {
|
new Status(Severity.ERROR, platform.status.SocialIdNotFound, {
|
||||||
socialId: email,
|
value: email,
|
||||||
type: SocialIdType.EMAIL
|
type: SocialIdType.EMAIL
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
@ -51,7 +51,9 @@ import type {
|
|||||||
WorkspaceStatusData,
|
WorkspaceStatusData,
|
||||||
Sort,
|
Sort,
|
||||||
Mailbox,
|
Mailbox,
|
||||||
MailboxSecret
|
MailboxSecret,
|
||||||
|
Integration,
|
||||||
|
IntegrationSecret
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import { isShallowEqual } from '../utils'
|
import { isShallowEqual } from '../utils'
|
||||||
|
|
||||||
@ -373,6 +375,8 @@ export class MongoAccountDB implements AccountDB {
|
|||||||
invite: MongoDbCollection<WorkspaceInvite, 'id'>
|
invite: MongoDbCollection<WorkspaceInvite, 'id'>
|
||||||
mailbox: MongoDbCollection<Mailbox, 'mailbox'>
|
mailbox: MongoDbCollection<Mailbox, 'mailbox'>
|
||||||
mailboxSecret: MongoDbCollection<MailboxSecret>
|
mailboxSecret: MongoDbCollection<MailboxSecret>
|
||||||
|
integration: MongoDbCollection<Integration>
|
||||||
|
integrationSecret: MongoDbCollection<IntegrationSecret>
|
||||||
|
|
||||||
workspaceMembers: MongoDbCollection<WorkspaceMember>
|
workspaceMembers: MongoDbCollection<WorkspaceMember>
|
||||||
|
|
||||||
@ -388,6 +392,8 @@ export class MongoAccountDB implements AccountDB {
|
|||||||
this.invite = new MongoDbCollection<WorkspaceInvite, 'id'>('invite', db, 'id')
|
this.invite = new MongoDbCollection<WorkspaceInvite, 'id'>('invite', db, 'id')
|
||||||
this.mailbox = new MongoDbCollection<Mailbox, 'mailbox'>('mailbox', db)
|
this.mailbox = new MongoDbCollection<Mailbox, 'mailbox'>('mailbox', db)
|
||||||
this.mailboxSecret = new MongoDbCollection<MailboxSecret>('mailboxSecrets', db)
|
this.mailboxSecret = new MongoDbCollection<MailboxSecret>('mailboxSecrets', db)
|
||||||
|
this.integration = new MongoDbCollection<Integration>('integration', db)
|
||||||
|
this.integrationSecret = new MongoDbCollection<IntegrationSecret>('integrationSecret', db)
|
||||||
|
|
||||||
this.workspaceMembers = new MongoDbCollection<WorkspaceMember>('workspaceMembers', db)
|
this.workspaceMembers = new MongoDbCollection<WorkspaceMember>('workspaceMembers', db)
|
||||||
}
|
}
|
||||||
|
@ -41,7 +41,9 @@ import type {
|
|||||||
WorkspaceInfoWithStatus,
|
WorkspaceInfoWithStatus,
|
||||||
Sort,
|
Sort,
|
||||||
Mailbox,
|
Mailbox,
|
||||||
MailboxSecret
|
MailboxSecret,
|
||||||
|
Integration,
|
||||||
|
IntegrationSecret
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
function toSnakeCase (str: string): string {
|
function toSnakeCase (str: string): string {
|
||||||
@ -86,13 +88,18 @@ function convertKeysToSnakeCase (obj: any): any {
|
|||||||
return obj
|
return obj
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatVar (idx: number, type?: string): string {
|
||||||
|
return type != null ? `$${idx}::${type}` : `$${idx}`
|
||||||
|
}
|
||||||
|
|
||||||
export class PostgresDbCollection<T extends Record<string, any>, K extends keyof T | undefined = undefined>
|
export class PostgresDbCollection<T extends Record<string, any>, K extends keyof T | undefined = undefined>
|
||||||
implements DbCollection<T> {
|
implements DbCollection<T> {
|
||||||
constructor (
|
constructor (
|
||||||
readonly name: string,
|
readonly name: string,
|
||||||
readonly client: Sql,
|
readonly client: Sql,
|
||||||
readonly idKey?: K,
|
readonly idKey?: K,
|
||||||
readonly ns: string = 'global_account'
|
readonly ns?: string,
|
||||||
|
readonly fieldTypes: Record<string, string> = {}
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getTableName (): string {
|
getTableName (): string {
|
||||||
@ -108,7 +115,14 @@ implements DbCollection<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected buildWhereClause (query: Query<T>, lastRefIdx: number = 0): [string, any[]] {
|
protected buildWhereClause (query: Query<T>, lastRefIdx: number = 0): [string, any[]] {
|
||||||
if (Object.keys(query).length === 0) {
|
const filteredQuery = Object.entries(query).reduce<Query<T>>((acc, [key, value]) => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
acc[key as keyof Query<T>] = value
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
if (Object.keys(filteredQuery).length === 0) {
|
||||||
return ['', []]
|
return ['', []]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,9 +130,12 @@ implements DbCollection<T> {
|
|||||||
const values: any[] = []
|
const values: any[] = []
|
||||||
let currIdx: number = lastRefIdx
|
let currIdx: number = lastRefIdx
|
||||||
|
|
||||||
for (const key of Object.keys(query)) {
|
for (const key of Object.keys(filteredQuery)) {
|
||||||
const qKey = query[key]
|
const qKey = filteredQuery[key]
|
||||||
|
if (qKey === undefined) continue
|
||||||
|
|
||||||
const operator = qKey != null && typeof qKey === 'object' ? Object.keys(qKey)[0] : ''
|
const operator = qKey != null && typeof qKey === 'object' ? Object.keys(qKey)[0] : ''
|
||||||
|
const castType = this.fieldTypes[key]
|
||||||
const snakeKey = toSnakeCase(key)
|
const snakeKey = toSnakeCase(key)
|
||||||
switch (operator) {
|
switch (operator) {
|
||||||
case '$in': {
|
case '$in': {
|
||||||
@ -126,7 +143,7 @@ implements DbCollection<T> {
|
|||||||
const inVars: string[] = []
|
const inVars: string[] = []
|
||||||
for (const val of inVals) {
|
for (const val of inVals) {
|
||||||
currIdx++
|
currIdx++
|
||||||
inVars.push(`$${currIdx}`)
|
inVars.push(formatVar(currIdx, castType))
|
||||||
values.push(val)
|
values.push(val)
|
||||||
}
|
}
|
||||||
whereChunks.push(`"${snakeKey}" IN (${inVars.join(', ')})`)
|
whereChunks.push(`"${snakeKey}" IN (${inVars.join(', ')})`)
|
||||||
@ -134,38 +151,42 @@ implements DbCollection<T> {
|
|||||||
}
|
}
|
||||||
case '$lt': {
|
case '$lt': {
|
||||||
currIdx++
|
currIdx++
|
||||||
whereChunks.push(`"${snakeKey}" < $${currIdx}`)
|
whereChunks.push(`"${snakeKey}" < ${formatVar(currIdx, castType)}`)
|
||||||
values.push(Object.values(qKey as object)[0])
|
values.push(Object.values(qKey as object)[0])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case '$lte': {
|
case '$lte': {
|
||||||
currIdx++
|
currIdx++
|
||||||
whereChunks.push(`"${snakeKey}" <= $${currIdx}`)
|
whereChunks.push(`"${snakeKey}" <= ${formatVar(currIdx, castType)}`)
|
||||||
values.push(Object.values(qKey as object)[0])
|
values.push(Object.values(qKey as object)[0])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case '$gt': {
|
case '$gt': {
|
||||||
currIdx++
|
currIdx++
|
||||||
whereChunks.push(`"${snakeKey}" > $${currIdx}`)
|
whereChunks.push(`"${snakeKey}" > ${formatVar(currIdx, castType)}`)
|
||||||
values.push(Object.values(qKey as object)[0])
|
values.push(Object.values(qKey as object)[0])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case '$gte': {
|
case '$gte': {
|
||||||
currIdx++
|
currIdx++
|
||||||
whereChunks.push(`"${snakeKey}" >= $${currIdx}`)
|
whereChunks.push(`"${snakeKey}" >= ${formatVar(currIdx, castType)}`)
|
||||||
values.push(Object.values(qKey as object)[0])
|
values.push(Object.values(qKey as object)[0])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case '$ne': {
|
case '$ne': {
|
||||||
currIdx++
|
currIdx++
|
||||||
whereChunks.push(`"${key}" != $${currIdx}`)
|
whereChunks.push(`"${snakeKey}" != ${formatVar(currIdx, castType)}`)
|
||||||
values.push(Object.values(qKey as object)[0])
|
values.push(Object.values(qKey as object)[0])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
currIdx++
|
currIdx++
|
||||||
whereChunks.push(`"${snakeKey}" = $${currIdx}`)
|
if (qKey !== null) {
|
||||||
values.push(qKey)
|
whereChunks.push(`"${snakeKey}" = ${formatVar(currIdx, castType)}`)
|
||||||
|
values.push(qKey)
|
||||||
|
} else {
|
||||||
|
whereChunks.push(`"${snakeKey}" IS NULL`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -253,8 +274,9 @@ implements DbCollection<T> {
|
|||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
const snakeKey = toSnakeCase(key)
|
const snakeKey = toSnakeCase(key)
|
||||||
|
const castType = this.fieldTypes[key]
|
||||||
currIdx++
|
currIdx++
|
||||||
updateChunks.push(`"${snakeKey}" = $${currIdx}`)
|
updateChunks.push(`"${snakeKey}" = ${formatVar(currIdx, castType)}`)
|
||||||
values.push(ops[key])
|
values.push(ops[key])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -297,8 +319,11 @@ export class AccountPostgresDbCollection
|
|||||||
implements DbCollection<Account> {
|
implements DbCollection<Account> {
|
||||||
private readonly passwordKeys = ['hash', 'salt']
|
private readonly passwordKeys = ['hash', 'salt']
|
||||||
|
|
||||||
constructor (readonly client: Sql) {
|
constructor (
|
||||||
super('account', client, 'uuid')
|
readonly client: Sql,
|
||||||
|
readonly ns?: string
|
||||||
|
) {
|
||||||
|
super('account', client, 'uuid', ns)
|
||||||
}
|
}
|
||||||
|
|
||||||
getPasswordsTableName (): string {
|
getPasswordsTableName (): string {
|
||||||
@ -381,21 +406,25 @@ export class PostgresAccountDB implements AccountDB {
|
|||||||
invite: PostgresDbCollection<WorkspaceInvite, 'id'>
|
invite: PostgresDbCollection<WorkspaceInvite, 'id'>
|
||||||
mailbox: PostgresDbCollection<Mailbox, 'mailbox'>
|
mailbox: PostgresDbCollection<Mailbox, 'mailbox'>
|
||||||
mailboxSecret: PostgresDbCollection<MailboxSecret>
|
mailboxSecret: PostgresDbCollection<MailboxSecret>
|
||||||
|
integration: PostgresDbCollection<Integration>
|
||||||
|
integrationSecret: PostgresDbCollection<IntegrationSecret>
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
readonly client: Sql,
|
readonly client: Sql,
|
||||||
readonly ns: string = 'global_account'
|
readonly ns: string = 'global_account'
|
||||||
) {
|
) {
|
||||||
this.person = new PostgresDbCollection<Person, 'uuid'>('person', client, 'uuid')
|
this.person = new PostgresDbCollection<Person, 'uuid'>('person', client, 'uuid', ns)
|
||||||
this.account = new AccountPostgresDbCollection(client)
|
this.account = new AccountPostgresDbCollection(client, ns)
|
||||||
this.socialId = new PostgresDbCollection<SocialId, '_id'>('social_id', client, '_id')
|
this.socialId = new PostgresDbCollection<SocialId, '_id'>('social_id', client, '_id', ns)
|
||||||
this.workspaceStatus = new PostgresDbCollection<WorkspaceStatus>('workspace_status', client)
|
this.workspaceStatus = new PostgresDbCollection<WorkspaceStatus>('workspace_status', client, undefined, ns)
|
||||||
this.workspace = new PostgresDbCollection<Workspace, 'uuid'>('workspace', client, 'uuid')
|
this.workspace = new PostgresDbCollection<Workspace, 'uuid'>('workspace', client, 'uuid', ns)
|
||||||
this.accountEvent = new PostgresDbCollection<AccountEvent>('account_events', client)
|
this.accountEvent = new PostgresDbCollection<AccountEvent>('account_events', client, undefined, ns)
|
||||||
this.otp = new PostgresDbCollection<OTP>('otp', client)
|
this.otp = new PostgresDbCollection<OTP>('otp', client, undefined, ns)
|
||||||
this.invite = new PostgresDbCollection<WorkspaceInvite, 'id'>('invite', client, 'id')
|
this.invite = new PostgresDbCollection<WorkspaceInvite, 'id'>('invite', client, 'id', ns)
|
||||||
this.mailbox = new PostgresDbCollection<Mailbox, 'mailbox'>('mailbox', client)
|
this.mailbox = new PostgresDbCollection<Mailbox, 'mailbox'>('mailbox', client, undefined, ns)
|
||||||
this.mailboxSecret = new PostgresDbCollection<MailboxSecret>('mailbox_secrets', client)
|
this.mailboxSecret = new PostgresDbCollection<MailboxSecret>('mailbox_secrets', client, undefined, ns)
|
||||||
|
this.integration = new PostgresDbCollection<Integration>('integrations', client, undefined, ns)
|
||||||
|
this.integrationSecret = new PostgresDbCollection<IntegrationSecret>('integration_secrets', client, undefined, ns)
|
||||||
}
|
}
|
||||||
|
|
||||||
getWsMembersTableName (): string {
|
getWsMembersTableName (): string {
|
||||||
@ -646,7 +675,8 @@ export class PostgresAccountDB implements AccountDB {
|
|||||||
this.getV3Migration(),
|
this.getV3Migration(),
|
||||||
this.getV4Migration(),
|
this.getV4Migration(),
|
||||||
this.getV4Migration1(),
|
this.getV4Migration1(),
|
||||||
this.getV5Migration()
|
this.getV5Migration(),
|
||||||
|
this.getV6Migration()
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -901,4 +931,36 @@ export class PostgresAccountDB implements AccountDB {
|
|||||||
`
|
`
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getV6Migration (): [string, string] {
|
||||||
|
return [
|
||||||
|
'account_db_v6_add_social_id_integrations',
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS ${this.ns}.integrations (
|
||||||
|
social_id INT8 NOT NULL,
|
||||||
|
kind STRING NOT NULL,
|
||||||
|
workspace_uuid UUID,
|
||||||
|
_def_ws_uuid UUID NOT NULL GENERATED ALWAYS AS (COALESCE(workspace_uuid, '00000000-0000-0000-0000-000000000000')) STORED NOT VISIBLE,
|
||||||
|
data JSONB,
|
||||||
|
CONSTRAINT integrations_pk PRIMARY KEY (social_id, kind, _def_ws_uuid),
|
||||||
|
INDEX integrations_kind_idx (kind),
|
||||||
|
CONSTRAINT integrations_social_id_fk FOREIGN KEY (social_id) REFERENCES ${this.ns}.social_id(_id),
|
||||||
|
CONSTRAINT integrations_workspace_fk FOREIGN KEY (workspace_uuid) REFERENCES ${this.ns}.workspace(uuid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ${this.ns}.integration_secrets (
|
||||||
|
social_id INT8 NOT NULL,
|
||||||
|
kind STRING NOT NULL,
|
||||||
|
workspace_uuid UUID,
|
||||||
|
_def_ws_uuid UUID NOT NULL GENERATED ALWAYS AS (COALESCE(workspace_uuid, '00000000-0000-0000-0000-000000000000')) STORED NOT VISIBLE,
|
||||||
|
key STRING,
|
||||||
|
secret STRING NOT NULL,
|
||||||
|
CONSTRAINT integration_secrets_pk PRIMARY KEY (social_id, kind, _def_ws_uuid, key),
|
||||||
|
CONSTRAINT integration_secrets_integrations_fk FOREIGN KEY (social_id, kind, _def_ws_uuid)
|
||||||
|
REFERENCES ${this.ns}.integrations(social_id, kind, _def_ws_uuid)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -17,31 +17,20 @@ import {
|
|||||||
AccountRole,
|
AccountRole,
|
||||||
buildSocialIdString,
|
buildSocialIdString,
|
||||||
concatLink,
|
concatLink,
|
||||||
Data,
|
|
||||||
isActiveMode,
|
isActiveMode,
|
||||||
isWorkspaceCreating,
|
isWorkspaceCreating,
|
||||||
MeasureContext,
|
MeasureContext,
|
||||||
SocialIdType,
|
SocialIdType,
|
||||||
systemAccountUuid,
|
systemAccountUuid,
|
||||||
Version,
|
|
||||||
type BackupStatus,
|
|
||||||
type Branding,
|
type Branding,
|
||||||
type Person,
|
type Person,
|
||||||
type PersonId,
|
type PersonId,
|
||||||
type PersonInfo,
|
type PersonInfo,
|
||||||
type PersonUuid,
|
type PersonUuid,
|
||||||
type WorkspaceMemberInfo,
|
type WorkspaceMemberInfo,
|
||||||
type WorkspaceMode,
|
|
||||||
type WorkspaceUuid
|
type WorkspaceUuid
|
||||||
} from '@hcengineering/core'
|
} from '@hcengineering/core'
|
||||||
import platform, {
|
import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform'
|
||||||
getMetadata,
|
|
||||||
PlatformError,
|
|
||||||
Severity,
|
|
||||||
Status,
|
|
||||||
translate,
|
|
||||||
unknownError
|
|
||||||
} from '@hcengineering/platform'
|
|
||||||
import { decodeTokenVerbose, generateToken } from '@hcengineering/server-token'
|
import { decodeTokenVerbose, generateToken } from '@hcengineering/server-token'
|
||||||
|
|
||||||
import { isAdminEmail } from './admin'
|
import { isAdminEmail } from './admin'
|
||||||
@ -55,13 +44,9 @@ import type {
|
|||||||
OtpInfo,
|
OtpInfo,
|
||||||
RegionInfo,
|
RegionInfo,
|
||||||
SocialId,
|
SocialId,
|
||||||
Workspace,
|
|
||||||
WorkspaceEvent,
|
|
||||||
WorkspaceInfoWithStatus,
|
WorkspaceInfoWithStatus,
|
||||||
WorkspaceInviteInfo,
|
WorkspaceInviteInfo,
|
||||||
WorkspaceLoginInfo,
|
WorkspaceLoginInfo
|
||||||
WorkspaceOperation,
|
|
||||||
WorkspaceStatus
|
|
||||||
} from './types'
|
} from './types'
|
||||||
import {
|
import {
|
||||||
checkInvite,
|
checkInvite,
|
||||||
@ -80,12 +65,9 @@ import {
|
|||||||
getRegions,
|
getRegions,
|
||||||
getRolePower,
|
getRolePower,
|
||||||
getMailUrl,
|
getMailUrl,
|
||||||
getSocialIdByKey,
|
|
||||||
getWorkspaceById,
|
getWorkspaceById,
|
||||||
getWorkspaceInfoWithStatusById,
|
getWorkspaceInfoWithStatusById,
|
||||||
getWorkspaceInvite,
|
getWorkspaceInvite,
|
||||||
getWorkspaces,
|
|
||||||
getWorkspacesInfoWithStatusByIds,
|
|
||||||
GUEST_ACCOUNT,
|
GUEST_ACCOUNT,
|
||||||
isOtpValid,
|
isOtpValid,
|
||||||
selectWorkspace,
|
selectWorkspace,
|
||||||
@ -103,11 +85,14 @@ import {
|
|||||||
isEmail,
|
isEmail,
|
||||||
generatePassword,
|
generatePassword,
|
||||||
addSocialId,
|
addSocialId,
|
||||||
releaseSocialId
|
releaseSocialId,
|
||||||
|
updateWorkspaceRole
|
||||||
} from './utils'
|
} from './utils'
|
||||||
|
import { type AccountServiceMethods, getServiceMethods } from './serviceOperations'
|
||||||
|
|
||||||
// Move to config?
|
// Note: it is IMPORTANT to always destructure params passed here to avoid sending extra params
|
||||||
const processingTimeoutMs = 30 * 1000
|
// to the database layer when searching/inserting as they may contain SQL injection
|
||||||
|
// !!! NEVER PASS "params" DIRECTLY in any DB functions !!!
|
||||||
|
|
||||||
const workspaceLimitPerUser =
|
const workspaceLimitPerUser =
|
||||||
process.env.WORKSPACE_LIMIT_PER_USER != null ? parseInt(process.env.WORKSPACE_LIMIT_PER_USER) : 10
|
process.env.WORKSPACE_LIMIT_PER_USER != null ? parseInt(process.env.WORKSPACE_LIMIT_PER_USER) : 10
|
||||||
@ -947,7 +932,7 @@ export async function requestPasswordReset (
|
|||||||
if (emailSocialId == null) {
|
if (emailSocialId == null) {
|
||||||
ctx.error('Email social id not found', { email, normalizedEmail })
|
ctx.error('Email social id not found', { email, normalizedEmail })
|
||||||
throw new PlatformError(
|
throw new PlatformError(
|
||||||
new Status(Severity.ERROR, platform.status.SocialIdNotFound, { socialId: email, type: SocialIdType.EMAIL })
|
new Status(Severity.ERROR, platform.status.SocialIdNotFound, { value: email, type: SocialIdType.EMAIL })
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1020,7 +1005,7 @@ export async function restorePassword (
|
|||||||
if (emailSocialId == null) {
|
if (emailSocialId == null) {
|
||||||
ctx.error('Email social id not found', { email })
|
ctx.error('Email social id not found', { email })
|
||||||
throw new PlatformError(
|
throw new PlatformError(
|
||||||
new Status(Severity.ERROR, platform.status.SocialIdNotFound, { socialId: email, type: SocialIdType.EMAIL })
|
new Status(Severity.ERROR, platform.status.SocialIdNotFound, { value: email, type: SocialIdType.EMAIL })
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1245,128 +1230,6 @@ export async function getWorkspaceInfo (
|
|||||||
return workspace
|
return workspace
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listWorkspaces (
|
|
||||||
ctx: MeasureContext,
|
|
||||||
db: AccountDB,
|
|
||||||
branding: Branding | null,
|
|
||||||
token: string,
|
|
||||||
params: {
|
|
||||||
region?: string | null
|
|
||||||
mode?: WorkspaceMode | null
|
|
||||||
}
|
|
||||||
): Promise<WorkspaceInfoWithStatus[]> {
|
|
||||||
const { region, mode } = params
|
|
||||||
const { extra } = decodeTokenVerbose(ctx, token)
|
|
||||||
|
|
||||||
if (!['tool', 'backup', 'admin'].includes(extra?.service) && extra?.admin !== 'true') {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
||||||
}
|
|
||||||
|
|
||||||
return await getWorkspaces(db, false, region, mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function performWorkspaceOperation (
|
|
||||||
ctx: MeasureContext,
|
|
||||||
db: AccountDB,
|
|
||||||
branding: Branding | null,
|
|
||||||
token: string,
|
|
||||||
parameters: {
|
|
||||||
workspaceId: WorkspaceUuid | WorkspaceUuid[]
|
|
||||||
event: 'archive' | 'migrate-to' | 'unarchive' | 'delete' | 'reset-attempts'
|
|
||||||
params: any[]
|
|
||||||
}
|
|
||||||
): Promise<boolean> {
|
|
||||||
const { workspaceId, event, params } = parameters
|
|
||||||
const { extra, workspace } = decodeTokenVerbose(ctx, token)
|
|
||||||
|
|
||||||
if (extra?.admin !== 'true') {
|
|
||||||
if (event !== 'unarchive' || workspaceId !== workspace) {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceUuids = Array.isArray(workspaceId) ? workspaceId : [workspaceId]
|
|
||||||
|
|
||||||
const workspaces = await getWorkspacesInfoWithStatusByIds(db, workspaceUuids)
|
|
||||||
if (workspaces.length === 0) {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, {}))
|
|
||||||
}
|
|
||||||
|
|
||||||
let ops = 0
|
|
||||||
for (const workspace of workspaces) {
|
|
||||||
const update: Partial<WorkspaceStatus> = {}
|
|
||||||
switch (event) {
|
|
||||||
case 'reset-attempts':
|
|
||||||
update.processingAttempts = 0
|
|
||||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
|
||||||
break
|
|
||||||
case 'delete':
|
|
||||||
if (workspace.status.mode !== 'active') {
|
|
||||||
throw new PlatformError(unknownError('Delete allowed only for active workspaces'))
|
|
||||||
}
|
|
||||||
|
|
||||||
update.mode = 'pending-deletion'
|
|
||||||
update.processingAttempts = 0
|
|
||||||
update.processingProgress = 0
|
|
||||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
|
||||||
break
|
|
||||||
case 'archive':
|
|
||||||
if (!isActiveMode(workspace.status.mode)) {
|
|
||||||
throw new PlatformError(unknownError('Archiving allowed only for active workspaces'))
|
|
||||||
}
|
|
||||||
|
|
||||||
update.mode = 'archiving-pending-backup'
|
|
||||||
update.processingAttempts = 0
|
|
||||||
update.processingProgress = 0
|
|
||||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
|
||||||
break
|
|
||||||
case 'unarchive':
|
|
||||||
if (event === 'unarchive') {
|
|
||||||
if (workspace.status.mode !== 'archived') {
|
|
||||||
throw new PlatformError(unknownError('Unarchive allowed only for archived workspaces'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
update.mode = 'pending-restore'
|
|
||||||
update.processingAttempts = 0
|
|
||||||
update.processingProgress = 0
|
|
||||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
|
||||||
break
|
|
||||||
case 'migrate-to': {
|
|
||||||
if (!isActiveMode(workspace.status.mode)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (params.length !== 1 && params[0] == null) {
|
|
||||||
throw new PlatformError(unknownError('Invalid region passed to migrate operation'))
|
|
||||||
}
|
|
||||||
const regions = getRegions()
|
|
||||||
if (regions.find((it) => it.region === params[0]) === undefined) {
|
|
||||||
throw new PlatformError(unknownError('Invalid region passed to migrate operation'))
|
|
||||||
}
|
|
||||||
if ((workspace.region ?? '') === params[0]) {
|
|
||||||
throw new PlatformError(unknownError('Invalid region passed to migrate operation'))
|
|
||||||
}
|
|
||||||
|
|
||||||
update.mode = 'migration-pending-backup'
|
|
||||||
// NOTE: will only work for Mongo accounts
|
|
||||||
update.targetRegion = params[0]
|
|
||||||
update.processingAttempts = 0
|
|
||||||
update.processingProgress = 0
|
|
||||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
|
||||||
break
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(update).length !== 0) {
|
|
||||||
await db.workspaceStatus.updateOne({ workspaceUuid: workspace.uuid }, update)
|
|
||||||
ops++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ops > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the token and returns the decoded account information.
|
* Validates the token and returns the decoded account information.
|
||||||
*/
|
*/
|
||||||
@ -1630,357 +1493,6 @@ export async function getWorkspaceMembers (
|
|||||||
return await db.getWorkspaceMembers(workspace)
|
return await db.getWorkspaceMembers(workspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateWorkspaceRoleBySocialKey (
|
|
||||||
ctx: MeasureContext,
|
|
||||||
db: AccountDB,
|
|
||||||
branding: Branding | null,
|
|
||||||
token: string,
|
|
||||||
params: {
|
|
||||||
socialKey: string
|
|
||||||
targetRole: AccountRole
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
|
||||||
const { socialKey, targetRole } = params
|
|
||||||
const { extra } = decodeTokenVerbose(ctx, token)
|
|
||||||
|
|
||||||
if (!['workspace', 'tool'].includes(extra?.service)) {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const socialId = await getSocialIdByKey(db, socialKey.toLowerCase() as PersonId)
|
|
||||||
if (socialId == null) {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateWorkspaceRole(ctx, db, branding, token, { targetAccount: socialId.personUuid, targetRole })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateWorkspaceRole (
|
|
||||||
ctx: MeasureContext,
|
|
||||||
db: AccountDB,
|
|
||||||
branding: Branding | null,
|
|
||||||
token: string,
|
|
||||||
params: {
|
|
||||||
targetAccount: PersonUuid
|
|
||||||
targetRole: AccountRole
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
|
||||||
const { targetAccount, targetRole } = params
|
|
||||||
|
|
||||||
const { account, workspace } = decodeTokenVerbose(ctx, token)
|
|
||||||
|
|
||||||
if (workspace === null) {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: workspace }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const accRole = account === systemAccountUuid ? AccountRole.Owner : await db.getWorkspaceRole(account, workspace)
|
|
||||||
|
|
||||||
if (
|
|
||||||
accRole == null ||
|
|
||||||
getRolePower(accRole) < getRolePower(AccountRole.Maintainer) ||
|
|
||||||
getRolePower(accRole) < getRolePower(targetRole)
|
|
||||||
) {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentRole = await db.getWorkspaceRole(targetAccount, workspace)
|
|
||||||
|
|
||||||
if (currentRole == null || getRolePower(accRole) < getRolePower(currentRole)) {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentRole === targetRole) return
|
|
||||||
|
|
||||||
if (currentRole === AccountRole.Owner) {
|
|
||||||
// Check if there are other owners
|
|
||||||
const owners = (await db.getWorkspaceMembers(workspace)).filter((m) => m.role === AccountRole.Owner)
|
|
||||||
if (owners.length === 1) {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.updateWorkspaceRole(targetAccount, workspace, targetRole)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =================================== */
|
|
||||||
/* ===WORKSPACE SERVICE OPERATIONS==== */
|
|
||||||
/* =================================== */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves one workspace for which there are things to process.
|
|
||||||
*
|
|
||||||
* Workspace is provided for 30seconds. This timeout is reset
|
|
||||||
* on every progress update.
|
|
||||||
* If no progress is reported for the workspace during this time,
|
|
||||||
* it will become available again to be processed by another executor.
|
|
||||||
*/
|
|
||||||
export async function getPendingWorkspace (
|
|
||||||
ctx: MeasureContext,
|
|
||||||
db: AccountDB,
|
|
||||||
branding: Branding | null,
|
|
||||||
token: string,
|
|
||||||
params: {
|
|
||||||
region: string
|
|
||||||
version: Data<Version>
|
|
||||||
operation: WorkspaceOperation
|
|
||||||
}
|
|
||||||
): Promise<WorkspaceInfoWithStatus | undefined> {
|
|
||||||
const { region, version, operation } = params
|
|
||||||
const { extra } = decodeTokenVerbose(ctx, token)
|
|
||||||
if (extra?.service !== 'workspace') {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const wsLivenessDays = getMetadata(accountPlugin.metadata.WsLivenessDays)
|
|
||||||
const wsLivenessMs = wsLivenessDays !== undefined ? wsLivenessDays * 24 * 60 * 60 * 1000 : undefined
|
|
||||||
|
|
||||||
const result = await db.getPendingWorkspace(region, version, operation, processingTimeoutMs, wsLivenessMs)
|
|
||||||
|
|
||||||
if (result != null) {
|
|
||||||
ctx.info('getPendingWorkspace', {
|
|
||||||
workspaceId: result.uuid,
|
|
||||||
workspaceName: result.name,
|
|
||||||
mode: result.status.mode,
|
|
||||||
operation,
|
|
||||||
region,
|
|
||||||
major: result.status.versionMajor,
|
|
||||||
minor: result.status.versionMinor,
|
|
||||||
patch: result.status.versionPatch,
|
|
||||||
requestedVersion: version
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateWorkspaceInfo (
|
|
||||||
ctx: MeasureContext,
|
|
||||||
db: AccountDB,
|
|
||||||
branding: Branding | null,
|
|
||||||
token: string,
|
|
||||||
params: {
|
|
||||||
workspaceUuid: WorkspaceUuid
|
|
||||||
event: WorkspaceEvent
|
|
||||||
version: Data<Version> // A worker version
|
|
||||||
progress: number
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
|
||||||
const { workspaceUuid, event, version, message } = params
|
|
||||||
let progress = params.progress
|
|
||||||
|
|
||||||
const { extra } = decodeTokenVerbose(ctx, token)
|
|
||||||
if (!['workspace', 'tool'].includes(extra?.service)) {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = await getWorkspaceInfoWithStatusById(db, workspaceUuid)
|
|
||||||
if (workspace === null) {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
|
|
||||||
}
|
|
||||||
progress = Math.round(progress)
|
|
||||||
|
|
||||||
const update: Partial<WorkspaceStatus> = {}
|
|
||||||
const wsUpdate: Partial<Workspace> = {}
|
|
||||||
switch (event) {
|
|
||||||
case 'create-started':
|
|
||||||
update.mode = 'creating'
|
|
||||||
if (workspace.status.mode !== 'creating') {
|
|
||||||
update.processingAttempts = 0
|
|
||||||
}
|
|
||||||
update.processingProgress = progress
|
|
||||||
break
|
|
||||||
case 'upgrade-started':
|
|
||||||
if (workspace.status.mode !== 'upgrading') {
|
|
||||||
update.processingAttempts = 0
|
|
||||||
}
|
|
||||||
update.mode = 'upgrading'
|
|
||||||
update.processingProgress = progress
|
|
||||||
break
|
|
||||||
case 'create-done':
|
|
||||||
ctx.info('Updating workspace info on create-done', { workspaceUuid, event, version, progress })
|
|
||||||
update.mode = 'active'
|
|
||||||
update.isDisabled = false
|
|
||||||
update.versionMajor = version.major
|
|
||||||
update.versionMinor = version.minor
|
|
||||||
update.versionPatch = version.patch
|
|
||||||
update.processingProgress = progress
|
|
||||||
break
|
|
||||||
case 'upgrade-done':
|
|
||||||
ctx.info('Updating workspace info on upgrade-done', { workspaceUuid, event, version, progress })
|
|
||||||
update.mode = 'active'
|
|
||||||
update.versionMajor = version.major
|
|
||||||
update.versionMinor = version.minor
|
|
||||||
update.versionPatch = version.patch
|
|
||||||
update.processingProgress = progress
|
|
||||||
break
|
|
||||||
case 'progress':
|
|
||||||
update.processingProgress = progress
|
|
||||||
break
|
|
||||||
case 'migrate-backup-started':
|
|
||||||
update.mode = 'migration-backup'
|
|
||||||
update.processingProgress = progress
|
|
||||||
break
|
|
||||||
case 'migrate-backup-done':
|
|
||||||
update.mode = 'migration-pending-clean'
|
|
||||||
update.processingProgress = progress
|
|
||||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
|
||||||
break
|
|
||||||
case 'migrate-clean-started':
|
|
||||||
update.mode = 'migration-clean'
|
|
||||||
update.processingAttempts = 0
|
|
||||||
update.processingProgress = progress
|
|
||||||
break
|
|
||||||
case 'migrate-clean-done':
|
|
||||||
wsUpdate.region = workspace.status.targetRegion ?? ''
|
|
||||||
update.mode = 'pending-restore'
|
|
||||||
update.processingProgress = progress
|
|
||||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
|
||||||
break
|
|
||||||
case 'restore-started':
|
|
||||||
update.mode = 'restoring'
|
|
||||||
update.processingAttempts = 0
|
|
||||||
update.processingProgress = progress
|
|
||||||
break
|
|
||||||
case 'restore-done':
|
|
||||||
update.mode = 'active'
|
|
||||||
update.processingProgress = 100
|
|
||||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
|
||||||
break
|
|
||||||
case 'archiving-backup-started':
|
|
||||||
update.mode = 'archiving-backup'
|
|
||||||
update.processingAttempts = 0
|
|
||||||
update.processingProgress = progress
|
|
||||||
break
|
|
||||||
case 'archiving-backup-done':
|
|
||||||
update.mode = 'archiving-pending-clean'
|
|
||||||
update.processingProgress = progress
|
|
||||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
|
||||||
break
|
|
||||||
case 'archiving-clean-started':
|
|
||||||
update.mode = 'archiving-clean'
|
|
||||||
update.processingAttempts = 0
|
|
||||||
update.processingProgress = progress
|
|
||||||
break
|
|
||||||
case 'archiving-clean-done':
|
|
||||||
update.mode = 'archived'
|
|
||||||
update.processingProgress = 100
|
|
||||||
break
|
|
||||||
case 'ping':
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message != null) {
|
|
||||||
update.processingMessage = message
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.workspaceStatus.updateOne(
|
|
||||||
{ workspaceUuid: workspace.uuid },
|
|
||||||
{
|
|
||||||
lastProcessingTime: Date.now(), // Some operations override it.
|
|
||||||
...update
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (Object.keys(wsUpdate).length !== 0) {
|
|
||||||
await db.workspace.updateOne({ uuid: workspace.uuid }, wsUpdate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function workerHandshake (
|
|
||||||
ctx: MeasureContext,
|
|
||||||
db: AccountDB,
|
|
||||||
branding: Branding | null,
|
|
||||||
token: string,
|
|
||||||
params: {
|
|
||||||
region: string
|
|
||||||
version: Data<Version>
|
|
||||||
operation: WorkspaceOperation
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
|
||||||
const { region, version, operation } = params
|
|
||||||
const { extra } = decodeTokenVerbose(ctx, token)
|
|
||||||
if (extra?.service !== 'workspace') {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.info('Worker handshake happened', { region, version, operation })
|
|
||||||
// Nothing else to do now but keeping to have track of workers in logs
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateBackupInfo (
|
|
||||||
ctx: MeasureContext,
|
|
||||||
db: AccountDB,
|
|
||||||
branding: Branding | null,
|
|
||||||
token: string,
|
|
||||||
params: { backupInfo: BackupStatus }
|
|
||||||
): Promise<void> {
|
|
||||||
const { backupInfo } = params
|
|
||||||
const { extra, workspace } = decodeTokenVerbose(ctx, token)
|
|
||||||
if (extra?.service !== 'backup') {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceInfo = await getWorkspaceById(db, workspace)
|
|
||||||
if (workspaceInfo === null) {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: workspace }))
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.workspaceStatus.updateOne(
|
|
||||||
{ workspaceUuid: workspace },
|
|
||||||
{
|
|
||||||
backupInfo,
|
|
||||||
lastProcessingTime: Date.now()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function assignWorkspace (
|
|
||||||
ctx: MeasureContext,
|
|
||||||
db: AccountDB,
|
|
||||||
branding: Branding | null,
|
|
||||||
token: string,
|
|
||||||
params: {
|
|
||||||
email: string
|
|
||||||
workspaceUuid: WorkspaceUuid
|
|
||||||
role: AccountRole
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
|
||||||
const { email, workspaceUuid, role } = params
|
|
||||||
const { extra } = decodeTokenVerbose(ctx, token)
|
|
||||||
if (!['aibot', 'tool', 'workspace'].includes(extra?.service)) {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedEmail = cleanEmail(email)
|
|
||||||
const emailSocialId = await getEmailSocialId(db, normalizedEmail)
|
|
||||||
|
|
||||||
if (emailSocialId == null) {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const account = await getAccount(db, emailSocialId.personUuid)
|
|
||||||
|
|
||||||
if (account == null) {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = await getWorkspaceById(db, workspaceUuid)
|
|
||||||
|
|
||||||
if (workspace == null) {
|
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentRole = await db.getWorkspaceRole(account.uuid, workspaceUuid)
|
|
||||||
|
|
||||||
if (currentRole == null) {
|
|
||||||
await db.assignWorkspace(account.uuid, workspaceUuid, role)
|
|
||||||
} else if (getRolePower(currentRole) < getRolePower(role)) {
|
|
||||||
await db.updateWorkspaceRole(account.uuid, workspaceUuid, role)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensurePerson (
|
export async function ensurePerson (
|
||||||
ctx: MeasureContext,
|
ctx: MeasureContext,
|
||||||
db: AccountDB,
|
db: AccountDB,
|
||||||
@ -2021,7 +1533,14 @@ export async function ensurePerson (
|
|||||||
return { uuid: personUuid, socialId: newSocialId }
|
return { uuid: personUuid, socialId: newSocialId }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMailboxOptions (): Promise<MailboxOptions> {
|
async function getMailboxOptions (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string
|
||||||
|
): Promise<MailboxOptions> {
|
||||||
|
decodeTokenVerbose(ctx, token)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
availableDomains: process.env.MAILBOX_DOMAINS?.split(',') ?? [],
|
availableDomains: process.env.MAILBOX_DOMAINS?.split(',') ?? [],
|
||||||
minNameLength: parseInt(process.env.MAILBOX_MIN_NAME_LENGTH ?? '6'),
|
minNameLength: parseInt(process.env.MAILBOX_MIN_NAME_LENGTH ?? '6'),
|
||||||
@ -2045,7 +1564,7 @@ async function createMailbox (
|
|||||||
const normalizedName = cleanEmail(name)
|
const normalizedName = cleanEmail(name)
|
||||||
const normalizedDomain = cleanEmail(domain)
|
const normalizedDomain = cleanEmail(domain)
|
||||||
const mailbox = normalizedName + '@' + normalizedDomain
|
const mailbox = normalizedName + '@' + normalizedDomain
|
||||||
const opts = await getMailboxOptions()
|
const opts = await getMailboxOptions(ctx, db, branding, token)
|
||||||
|
|
||||||
if (normalizedName.length === 0 || normalizedDomain.length === 0 || !isEmail(mailbox)) {
|
if (normalizedName.length === 0 || normalizedDomain.length === 0 || !isEmail(mailbox)) {
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.MailboxError, { reason: 'invalid-name' }))
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.MailboxError, { reason: 'invalid-name' }))
|
||||||
@ -2107,22 +1626,8 @@ async function deleteMailbox (
|
|||||||
ctx.info('Mailbox deleted', { mailbox, account })
|
ctx.info('Mailbox deleted', { mailbox, account })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addSocialIdToPerson (
|
|
||||||
ctx: MeasureContext,
|
|
||||||
db: AccountDB,
|
|
||||||
branding: Branding | null,
|
|
||||||
token: string,
|
|
||||||
params: { person: PersonUuid, type: SocialIdType, value: string, confirmed: boolean }
|
|
||||||
): Promise<PersonId> {
|
|
||||||
const { person, type, value, confirmed } = params
|
|
||||||
const { extra } = decodeTokenVerbose(ctx, token)
|
|
||||||
|
|
||||||
verifyAllowedServices(['github'], extra)
|
|
||||||
|
|
||||||
return await addSocialId(db, person, type, value, confirmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AccountMethods =
|
export type AccountMethods =
|
||||||
|
| AccountServiceMethods
|
||||||
| 'login'
|
| 'login'
|
||||||
| 'loginOtp'
|
| 'loginOtp'
|
||||||
| 'signUp'
|
| 'signUp'
|
||||||
@ -2150,14 +1655,8 @@ export type AccountMethods =
|
|||||||
| 'getUserWorkspaces'
|
| 'getUserWorkspaces'
|
||||||
| 'getWorkspaceInfo'
|
| 'getWorkspaceInfo'
|
||||||
| 'getWorkspacesInfo'
|
| 'getWorkspacesInfo'
|
||||||
| 'listWorkspaces'
|
|
||||||
| 'getLoginInfoByToken'
|
| 'getLoginInfoByToken'
|
||||||
| 'getSocialIds'
|
| 'getSocialIds'
|
||||||
| 'getPendingWorkspace'
|
|
||||||
| 'updateWorkspaceInfo'
|
|
||||||
| 'workerHandshake'
|
|
||||||
| 'updateBackupInfo'
|
|
||||||
| 'assignWorkspace'
|
|
||||||
| 'getPerson'
|
| 'getPerson'
|
||||||
| 'getPersonInfo'
|
| 'getPersonInfo'
|
||||||
| 'getWorkspaceMembers'
|
| 'getWorkspaceMembers'
|
||||||
@ -2165,14 +1664,11 @@ export type AccountMethods =
|
|||||||
| 'findPersonBySocialKey'
|
| 'findPersonBySocialKey'
|
||||||
| 'findPersonBySocialId'
|
| 'findPersonBySocialId'
|
||||||
| 'findSocialIdBySocialKey'
|
| 'findSocialIdBySocialKey'
|
||||||
| 'performWorkspaceOperation'
|
|
||||||
| 'updateWorkspaceRoleBySocialKey'
|
|
||||||
| 'ensurePerson'
|
| 'ensurePerson'
|
||||||
| 'getMailboxOptions'
|
| 'getMailboxOptions'
|
||||||
| 'createMailbox'
|
| 'createMailbox'
|
||||||
| 'getMailboxes'
|
| 'getMailboxes'
|
||||||
| 'deleteMailbox'
|
| 'deleteMailbox'
|
||||||
| 'addSocialIdToPerson'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
@ -2207,6 +1703,7 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
|
|||||||
createMailbox: wrap(createMailbox),
|
createMailbox: wrap(createMailbox),
|
||||||
getMailboxes: wrap(getMailboxes),
|
getMailboxes: wrap(getMailboxes),
|
||||||
deleteMailbox: wrap(deleteMailbox),
|
deleteMailbox: wrap(deleteMailbox),
|
||||||
|
ensurePerson: wrap(ensurePerson),
|
||||||
|
|
||||||
/* READ OPERATIONS */
|
/* READ OPERATIONS */
|
||||||
getRegionInfo: wrap(getRegionInfo),
|
getRegionInfo: wrap(getRegionInfo),
|
||||||
@ -2224,18 +1721,10 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
|
|||||||
getMailboxOptions: wrap(getMailboxOptions),
|
getMailboxOptions: wrap(getMailboxOptions),
|
||||||
|
|
||||||
/* SERVICE METHODS */
|
/* SERVICE METHODS */
|
||||||
getPendingWorkspace: wrap(getPendingWorkspace),
|
...getServiceMethods()
|
||||||
updateWorkspaceInfo: wrap(updateWorkspaceInfo),
|
|
||||||
workerHandshake: wrap(workerHandshake),
|
|
||||||
updateBackupInfo: wrap(updateBackupInfo),
|
|
||||||
assignWorkspace: wrap(assignWorkspace),
|
|
||||||
listWorkspaces: wrap(listWorkspaces),
|
|
||||||
performWorkspaceOperation: wrap(performWorkspaceOperation),
|
|
||||||
updateWorkspaceRoleBySocialKey: wrap(updateWorkspaceRoleBySocialKey),
|
|
||||||
ensurePerson: wrap(ensurePerson),
|
|
||||||
addSocialIdToPerson: wrap(addSocialIdToPerson)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export * from './plugin'
|
export * from './plugin'
|
||||||
|
export * from './serviceOperations'
|
||||||
export default accountPlugin
|
export default accountPlugin
|
||||||
|
805
server/account/src/serviceOperations.ts
Normal file
805
server/account/src/serviceOperations.ts
Normal file
@ -0,0 +1,805 @@
|
|||||||
|
//
|
||||||
|
// Copyright © 2022-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 {
|
||||||
|
AccountRole,
|
||||||
|
Data,
|
||||||
|
isActiveMode,
|
||||||
|
MeasureContext,
|
||||||
|
SocialIdType,
|
||||||
|
Version,
|
||||||
|
WorkspaceMode,
|
||||||
|
type BackupStatus,
|
||||||
|
type Branding,
|
||||||
|
type PersonId,
|
||||||
|
type PersonUuid,
|
||||||
|
type WorkspaceUuid
|
||||||
|
} from '@hcengineering/core'
|
||||||
|
import platform, { getMetadata, PlatformError, Severity, Status, unknownError } from '@hcengineering/platform'
|
||||||
|
import { decodeTokenVerbose } from '@hcengineering/server-token'
|
||||||
|
|
||||||
|
import { accountPlugin } from './plugin'
|
||||||
|
import type {
|
||||||
|
AccountDB,
|
||||||
|
AccountMethodHandler,
|
||||||
|
Integration,
|
||||||
|
IntegrationKey,
|
||||||
|
IntegrationSecret,
|
||||||
|
IntegrationSecretKey,
|
||||||
|
Workspace,
|
||||||
|
WorkspaceEvent,
|
||||||
|
WorkspaceInfoWithStatus,
|
||||||
|
WorkspaceOperation,
|
||||||
|
WorkspaceStatus
|
||||||
|
} from './types'
|
||||||
|
import {
|
||||||
|
cleanEmail,
|
||||||
|
getAccount,
|
||||||
|
getEmailSocialId,
|
||||||
|
getRegions,
|
||||||
|
getRolePower,
|
||||||
|
getSocialIdByKey,
|
||||||
|
getWorkspaceById,
|
||||||
|
getWorkspaceInfoWithStatusById,
|
||||||
|
getWorkspacesInfoWithStatusByIds,
|
||||||
|
verifyAllowedServices,
|
||||||
|
wrap,
|
||||||
|
addSocialId,
|
||||||
|
getWorkspaces,
|
||||||
|
updateWorkspaceRole
|
||||||
|
} from './utils'
|
||||||
|
|
||||||
|
// Note: it is IMPORTANT to always destructure params passed here to avoid sending extra params
|
||||||
|
// to the database layer when searching/inserting as they may contain SQL injection
|
||||||
|
// !!! NEVER PASS "params" DIRECTLY in any DB functions !!!
|
||||||
|
|
||||||
|
// Move to config?
|
||||||
|
const processingTimeoutMs = 30 * 1000
|
||||||
|
|
||||||
|
export async function listWorkspaces (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: {
|
||||||
|
region?: string | null
|
||||||
|
mode?: WorkspaceMode | null
|
||||||
|
}
|
||||||
|
): Promise<WorkspaceInfoWithStatus[]> {
|
||||||
|
const { region, mode } = params
|
||||||
|
const { extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
|
||||||
|
if (!['tool', 'backup', 'admin'].includes(extra?.service) && extra?.admin !== 'true') {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return await getWorkspaces(db, false, region, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function performWorkspaceOperation (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
parameters: {
|
||||||
|
workspaceId: WorkspaceUuid | WorkspaceUuid[]
|
||||||
|
event: 'archive' | 'migrate-to' | 'unarchive' | 'delete' | 'reset-attempts'
|
||||||
|
params: any[]
|
||||||
|
}
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { workspaceId, event, params } = parameters
|
||||||
|
const { extra, workspace } = decodeTokenVerbose(ctx, token)
|
||||||
|
|
||||||
|
if (extra?.admin !== 'true') {
|
||||||
|
if (event !== 'unarchive' || workspaceId !== workspace) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceUuids = Array.isArray(workspaceId) ? workspaceId : [workspaceId]
|
||||||
|
|
||||||
|
const workspaces = await getWorkspacesInfoWithStatusByIds(db, workspaceUuids)
|
||||||
|
if (workspaces.length === 0) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
let ops = 0
|
||||||
|
for (const workspace of workspaces) {
|
||||||
|
const update: Partial<WorkspaceStatus> = {}
|
||||||
|
switch (event) {
|
||||||
|
case 'reset-attempts':
|
||||||
|
update.processingAttempts = 0
|
||||||
|
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||||
|
break
|
||||||
|
case 'delete':
|
||||||
|
if (workspace.status.mode !== 'active') {
|
||||||
|
throw new PlatformError(unknownError('Delete allowed only for active workspaces'))
|
||||||
|
}
|
||||||
|
|
||||||
|
update.mode = 'pending-deletion'
|
||||||
|
update.processingAttempts = 0
|
||||||
|
update.processingProgress = 0
|
||||||
|
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||||
|
break
|
||||||
|
case 'archive':
|
||||||
|
if (!isActiveMode(workspace.status.mode)) {
|
||||||
|
throw new PlatformError(unknownError('Archiving allowed only for active workspaces'))
|
||||||
|
}
|
||||||
|
|
||||||
|
update.mode = 'archiving-pending-backup'
|
||||||
|
update.processingAttempts = 0
|
||||||
|
update.processingProgress = 0
|
||||||
|
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||||
|
break
|
||||||
|
case 'unarchive':
|
||||||
|
if (event === 'unarchive') {
|
||||||
|
if (workspace.status.mode !== 'archived') {
|
||||||
|
throw new PlatformError(unknownError('Unarchive allowed only for archived workspaces'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update.mode = 'pending-restore'
|
||||||
|
update.processingAttempts = 0
|
||||||
|
update.processingProgress = 0
|
||||||
|
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||||
|
break
|
||||||
|
case 'migrate-to': {
|
||||||
|
if (!isActiveMode(workspace.status.mode)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (params.length !== 1 && params[0] == null) {
|
||||||
|
throw new PlatformError(unknownError('Invalid region passed to migrate operation'))
|
||||||
|
}
|
||||||
|
const regions = getRegions()
|
||||||
|
if (regions.find((it) => it.region === params[0]) === undefined) {
|
||||||
|
throw new PlatformError(unknownError('Invalid region passed to migrate operation'))
|
||||||
|
}
|
||||||
|
if ((workspace.region ?? '') === params[0]) {
|
||||||
|
throw new PlatformError(unknownError('Invalid region passed to migrate operation'))
|
||||||
|
}
|
||||||
|
|
||||||
|
update.mode = 'migration-pending-backup'
|
||||||
|
// NOTE: will only work for Mongo accounts
|
||||||
|
update.targetRegion = params[0]
|
||||||
|
update.processingAttempts = 0
|
||||||
|
update.processingProgress = 0
|
||||||
|
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(update).length !== 0) {
|
||||||
|
await db.workspaceStatus.updateOne({ workspaceUuid: workspace.uuid }, update)
|
||||||
|
ops++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ops > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWorkspaceRoleBySocialKey (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: {
|
||||||
|
socialKey: string
|
||||||
|
targetRole: AccountRole
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const { socialKey, targetRole } = params
|
||||||
|
const { extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
|
||||||
|
if (!['workspace', 'tool'].includes(extra?.service)) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const socialId = await getSocialIdByKey(db, socialKey.toLowerCase() as PersonId)
|
||||||
|
if (socialId == null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateWorkspaceRole(ctx, db, branding, token, { targetAccount: socialId.personUuid, targetRole })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves one workspace for which there are things to process.
|
||||||
|
*
|
||||||
|
* Workspace is provided for 30seconds. This timeout is reset
|
||||||
|
* on every progress update.
|
||||||
|
* If no progress is reported for the workspace during this time,
|
||||||
|
* it will become available again to be processed by another executor.
|
||||||
|
*/
|
||||||
|
export async function getPendingWorkspace (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: {
|
||||||
|
region: string
|
||||||
|
version: Data<Version>
|
||||||
|
operation: WorkspaceOperation
|
||||||
|
}
|
||||||
|
): Promise<WorkspaceInfoWithStatus | undefined> {
|
||||||
|
const { region, version, operation } = params
|
||||||
|
const { extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
if (extra?.service !== 'workspace') {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsLivenessDays = getMetadata(accountPlugin.metadata.WsLivenessDays)
|
||||||
|
const wsLivenessMs = wsLivenessDays !== undefined ? wsLivenessDays * 24 * 60 * 60 * 1000 : undefined
|
||||||
|
|
||||||
|
const result = await db.getPendingWorkspace(region, version, operation, processingTimeoutMs, wsLivenessMs)
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
ctx.info('getPendingWorkspace', {
|
||||||
|
workspaceId: result.uuid,
|
||||||
|
workspaceName: result.name,
|
||||||
|
mode: result.status.mode,
|
||||||
|
operation,
|
||||||
|
region,
|
||||||
|
major: result.status.versionMajor,
|
||||||
|
minor: result.status.versionMinor,
|
||||||
|
patch: result.status.versionPatch,
|
||||||
|
requestedVersion: version
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWorkspaceInfo (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: {
|
||||||
|
workspaceUuid: WorkspaceUuid
|
||||||
|
event: WorkspaceEvent
|
||||||
|
version: Data<Version> // A worker version
|
||||||
|
progress: number
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const { workspaceUuid, event, version, message } = params
|
||||||
|
let progress = params.progress
|
||||||
|
|
||||||
|
const { extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
if (!['workspace', 'tool'].includes(extra?.service)) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await getWorkspaceInfoWithStatusById(db, workspaceUuid)
|
||||||
|
if (workspace === null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
|
||||||
|
}
|
||||||
|
progress = Math.round(progress)
|
||||||
|
|
||||||
|
const update: Partial<WorkspaceStatus> = {}
|
||||||
|
const wsUpdate: Partial<Workspace> = {}
|
||||||
|
switch (event) {
|
||||||
|
case 'create-started':
|
||||||
|
update.mode = 'creating'
|
||||||
|
if (workspace.status.mode !== 'creating') {
|
||||||
|
update.processingAttempts = 0
|
||||||
|
}
|
||||||
|
update.processingProgress = progress
|
||||||
|
break
|
||||||
|
case 'upgrade-started':
|
||||||
|
if (workspace.status.mode !== 'upgrading') {
|
||||||
|
update.processingAttempts = 0
|
||||||
|
}
|
||||||
|
update.mode = 'upgrading'
|
||||||
|
update.processingProgress = progress
|
||||||
|
break
|
||||||
|
case 'create-done':
|
||||||
|
ctx.info('Updating workspace info on create-done', { workspaceUuid, event, version, progress })
|
||||||
|
update.mode = 'active'
|
||||||
|
update.isDisabled = false
|
||||||
|
update.versionMajor = version.major
|
||||||
|
update.versionMinor = version.minor
|
||||||
|
update.versionPatch = version.patch
|
||||||
|
update.processingProgress = progress
|
||||||
|
break
|
||||||
|
case 'upgrade-done':
|
||||||
|
ctx.info('Updating workspace info on upgrade-done', { workspaceUuid, event, version, progress })
|
||||||
|
update.mode = 'active'
|
||||||
|
update.versionMajor = version.major
|
||||||
|
update.versionMinor = version.minor
|
||||||
|
update.versionPatch = version.patch
|
||||||
|
update.processingProgress = progress
|
||||||
|
break
|
||||||
|
case 'progress':
|
||||||
|
update.processingProgress = progress
|
||||||
|
break
|
||||||
|
case 'migrate-backup-started':
|
||||||
|
update.mode = 'migration-backup'
|
||||||
|
update.processingProgress = progress
|
||||||
|
break
|
||||||
|
case 'migrate-backup-done':
|
||||||
|
update.mode = 'migration-pending-clean'
|
||||||
|
update.processingProgress = progress
|
||||||
|
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||||
|
break
|
||||||
|
case 'migrate-clean-started':
|
||||||
|
update.mode = 'migration-clean'
|
||||||
|
update.processingAttempts = 0
|
||||||
|
update.processingProgress = progress
|
||||||
|
break
|
||||||
|
case 'migrate-clean-done':
|
||||||
|
wsUpdate.region = workspace.status.targetRegion ?? ''
|
||||||
|
update.mode = 'pending-restore'
|
||||||
|
update.processingProgress = progress
|
||||||
|
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||||
|
break
|
||||||
|
case 'restore-started':
|
||||||
|
update.mode = 'restoring'
|
||||||
|
update.processingAttempts = 0
|
||||||
|
update.processingProgress = progress
|
||||||
|
break
|
||||||
|
case 'restore-done':
|
||||||
|
update.mode = 'active'
|
||||||
|
update.processingProgress = 100
|
||||||
|
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||||
|
break
|
||||||
|
case 'archiving-backup-started':
|
||||||
|
update.mode = 'archiving-backup'
|
||||||
|
update.processingAttempts = 0
|
||||||
|
update.processingProgress = progress
|
||||||
|
break
|
||||||
|
case 'archiving-backup-done':
|
||||||
|
update.mode = 'archiving-pending-clean'
|
||||||
|
update.processingProgress = progress
|
||||||
|
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||||
|
break
|
||||||
|
case 'archiving-clean-started':
|
||||||
|
update.mode = 'archiving-clean'
|
||||||
|
update.processingAttempts = 0
|
||||||
|
update.processingProgress = progress
|
||||||
|
break
|
||||||
|
case 'archiving-clean-done':
|
||||||
|
update.mode = 'archived'
|
||||||
|
update.processingProgress = 100
|
||||||
|
break
|
||||||
|
case 'ping':
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message != null) {
|
||||||
|
update.processingMessage = message
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.workspaceStatus.updateOne(
|
||||||
|
{ workspaceUuid: workspace.uuid },
|
||||||
|
{
|
||||||
|
lastProcessingTime: Date.now(), // Some operations override it.
|
||||||
|
...update
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Object.keys(wsUpdate).length !== 0) {
|
||||||
|
await db.workspace.updateOne({ uuid: workspace.uuid }, wsUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function workerHandshake (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: {
|
||||||
|
region: string
|
||||||
|
version: Data<Version>
|
||||||
|
operation: WorkspaceOperation
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const { region, version, operation } = params
|
||||||
|
const { extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
if (extra?.service !== 'workspace') {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.info('Worker handshake happened', { region, version, operation })
|
||||||
|
// Nothing else to do now but keeping to have track of workers in logs
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBackupInfo (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: { backupInfo: BackupStatus }
|
||||||
|
): Promise<void> {
|
||||||
|
const { backupInfo } = params
|
||||||
|
const { extra, workspace } = decodeTokenVerbose(ctx, token)
|
||||||
|
if (extra?.service !== 'backup') {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceInfo = await getWorkspaceById(db, workspace)
|
||||||
|
if (workspaceInfo === null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: workspace }))
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.workspaceStatus.updateOne(
|
||||||
|
{ workspaceUuid: workspace },
|
||||||
|
{
|
||||||
|
backupInfo,
|
||||||
|
lastProcessingTime: Date.now()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignWorkspace (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: {
|
||||||
|
email: string
|
||||||
|
workspaceUuid: WorkspaceUuid
|
||||||
|
role: AccountRole
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const { email, workspaceUuid, role } = params
|
||||||
|
const { extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
if (!['aibot', 'tool', 'workspace'].includes(extra?.service)) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = cleanEmail(email)
|
||||||
|
const emailSocialId = await getEmailSocialId(db, normalizedEmail)
|
||||||
|
|
||||||
|
if (emailSocialId == null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await getAccount(db, emailSocialId.personUuid)
|
||||||
|
|
||||||
|
if (account == null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await getWorkspaceById(db, workspaceUuid)
|
||||||
|
|
||||||
|
if (workspace == null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRole = await db.getWorkspaceRole(account.uuid, workspaceUuid)
|
||||||
|
|
||||||
|
if (currentRole == null) {
|
||||||
|
await db.assignWorkspace(account.uuid, workspaceUuid, role)
|
||||||
|
} else if (getRolePower(currentRole) < getRolePower(role)) {
|
||||||
|
await db.updateWorkspaceRole(account.uuid, workspaceUuid, role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addSocialIdToPerson (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: { person: PersonUuid, type: SocialIdType, value: string, confirmed: boolean }
|
||||||
|
): Promise<PersonId> {
|
||||||
|
const { person, type, value, confirmed } = params
|
||||||
|
const { extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
|
||||||
|
verifyAllowedServices(['github'], extra)
|
||||||
|
|
||||||
|
return await addSocialId(db, person, type, value, confirmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to config?
|
||||||
|
const integrationServices = ['github', 'telegram-bot', 'telegram', 'mailbox']
|
||||||
|
|
||||||
|
export async function createIntegration (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: Integration
|
||||||
|
): Promise<void> {
|
||||||
|
const { extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
verifyAllowedServices(integrationServices, extra)
|
||||||
|
const { socialId, kind, workspaceUuid, data } = params
|
||||||
|
|
||||||
|
if (kind == null || socialId == null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSocialId = await db.socialId.findOne({ _id: socialId })
|
||||||
|
if (existingSocialId == null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.SocialIdNotFound, { _id: socialId }))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workspaceUuid != null) {
|
||||||
|
const workspace = await getWorkspaceById(db, workspaceUuid)
|
||||||
|
if (workspace == null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.integration.findOne({ socialId, kind, workspaceUuid })
|
||||||
|
if (existing != null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationAlreadyExists, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.integration.insertOne({ socialId, kind, workspaceUuid, data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateIntegration (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: Integration
|
||||||
|
): Promise<void> {
|
||||||
|
const { extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
verifyAllowedServices(integrationServices, extra)
|
||||||
|
const { socialId, kind, workspaceUuid, data } = params
|
||||||
|
|
||||||
|
const existing = await db.integration.findOne({ socialId, kind, workspaceUuid })
|
||||||
|
if (existing == null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.integration.updateOne({ socialId, kind, workspaceUuid }, { data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteIntegration (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: IntegrationKey
|
||||||
|
): Promise<void> {
|
||||||
|
const { extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
verifyAllowedServices(integrationServices, extra)
|
||||||
|
const { socialId, kind, workspaceUuid } = params
|
||||||
|
|
||||||
|
const existing = await db.integration.findOne({ socialId, kind, workspaceUuid })
|
||||||
|
if (existing == null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.integration.deleteMany({ socialId, kind, workspaceUuid })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listIntegrations (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: {
|
||||||
|
socialId?: PersonId
|
||||||
|
kind?: string
|
||||||
|
workspaceUuid?: WorkspaceUuid | null
|
||||||
|
}
|
||||||
|
): Promise<Integration[]> {
|
||||||
|
const { account, extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
const isAllowedService = verifyAllowedServices(integrationServices, extra, false)
|
||||||
|
const { socialId, kind, workspaceUuid } = params
|
||||||
|
let socialIds: PersonId[] | undefined
|
||||||
|
|
||||||
|
if (isAllowedService) {
|
||||||
|
socialIds = socialId != null ? [socialId] : undefined
|
||||||
|
} else {
|
||||||
|
const socialIdObjs = await db.socialId.find({ personUuid: account, verifiedOn: { $gt: 0 } })
|
||||||
|
|
||||||
|
if (socialIdObjs.length === 0) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedSocialIds = socialIdObjs.map((it) => it._id)
|
||||||
|
|
||||||
|
if (socialId !== undefined) {
|
||||||
|
if (!allowedSocialIds.includes(socialId)) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
socialIds = [socialId]
|
||||||
|
} else {
|
||||||
|
socialIds = allowedSocialIds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db.integration.find({
|
||||||
|
...(socialIds != null ? { socialId: { $in: socialIds } } : {}),
|
||||||
|
kind,
|
||||||
|
workspaceUuid
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIntegration (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: IntegrationKey
|
||||||
|
): Promise<Integration | null> {
|
||||||
|
const { account, extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
const isAllowedService = verifyAllowedServices(integrationServices, extra, false)
|
||||||
|
const { socialId, kind, workspaceUuid } = params
|
||||||
|
|
||||||
|
if (!isAllowedService) {
|
||||||
|
const existingSocialId = await db.socialId.findOne({ _id: socialId, personUuid: account, verifiedOn: { $gt: 0 } })
|
||||||
|
|
||||||
|
if (existingSocialId == null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db.integration.findOne({ socialId, kind, workspaceUuid })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addIntegrationSecret (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: IntegrationSecret
|
||||||
|
): Promise<void> {
|
||||||
|
const { extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
verifyAllowedServices(integrationServices, extra)
|
||||||
|
const { socialId, kind, workspaceUuid, key, secret } = params
|
||||||
|
|
||||||
|
if (kind == null || socialId == null || key == null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const integrationKey: IntegrationKey = { socialId, kind, workspaceUuid }
|
||||||
|
const secretKey: IntegrationSecretKey = { ...integrationKey, key }
|
||||||
|
|
||||||
|
const existingIntegration = await db.integration.findOne(integrationKey)
|
||||||
|
if (existingIntegration == null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSecret = await db.integrationSecret.findOne(secretKey)
|
||||||
|
if (existingSecret != null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretAlreadyExists, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.integrationSecret.insertOne({ ...secretKey, secret })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateIntegrationSecret (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: IntegrationSecret
|
||||||
|
): Promise<void> {
|
||||||
|
const { extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
verifyAllowedServices(integrationServices, extra)
|
||||||
|
const { socialId, kind, workspaceUuid, key, secret } = params
|
||||||
|
const secretKey: IntegrationSecretKey = { socialId, kind, workspaceUuid, key }
|
||||||
|
|
||||||
|
const existingSecret = await db.integrationSecret.findOne(secretKey)
|
||||||
|
if (existingSecret == null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.integrationSecret.updateOne(secretKey, { secret })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteIntegrationSecret (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: IntegrationSecretKey
|
||||||
|
): Promise<void> {
|
||||||
|
const { extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
verifyAllowedServices(integrationServices, extra)
|
||||||
|
const { socialId, kind, workspaceUuid, key } = params
|
||||||
|
const secretKey: IntegrationSecretKey = { socialId, kind, workspaceUuid, key }
|
||||||
|
|
||||||
|
const existingSecret = await db.integrationSecret.findOne(secretKey)
|
||||||
|
if (existingSecret == null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.integrationSecret.deleteMany(secretKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIntegrationSecret (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: IntegrationSecretKey
|
||||||
|
): Promise<IntegrationSecret | null> {
|
||||||
|
const { extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
verifyAllowedServices(integrationServices, extra)
|
||||||
|
const { socialId, kind, workspaceUuid, key } = params
|
||||||
|
|
||||||
|
const existing = await db.integrationSecret.findOne({ socialId, kind, workspaceUuid, key })
|
||||||
|
if (existing == null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listIntegrationsSecrets (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: {
|
||||||
|
socialId?: PersonId
|
||||||
|
kind?: string
|
||||||
|
workspaceUuid?: WorkspaceUuid | null
|
||||||
|
key?: string
|
||||||
|
}
|
||||||
|
): Promise<IntegrationSecret[]> {
|
||||||
|
const { extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
verifyAllowedServices(integrationServices, extra)
|
||||||
|
const { socialId, kind, workspaceUuid, key } = params
|
||||||
|
|
||||||
|
return await db.integrationSecret.find({ socialId, kind, workspaceUuid, key })
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccountServiceMethods =
|
||||||
|
| 'getPendingWorkspace'
|
||||||
|
| 'updateWorkspaceInfo'
|
||||||
|
| 'workerHandshake'
|
||||||
|
| 'updateBackupInfo'
|
||||||
|
| 'assignWorkspace'
|
||||||
|
| 'listWorkspaces'
|
||||||
|
| 'performWorkspaceOperation'
|
||||||
|
| 'updateWorkspaceRoleBySocialKey'
|
||||||
|
| 'addSocialIdToPerson'
|
||||||
|
| 'createIntegration'
|
||||||
|
| 'updateIntegration'
|
||||||
|
| 'deleteIntegration'
|
||||||
|
| 'listIntegrations'
|
||||||
|
| 'getIntegration'
|
||||||
|
| 'addIntegrationSecret'
|
||||||
|
| 'updateIntegrationSecret'
|
||||||
|
| 'deleteIntegrationSecret'
|
||||||
|
| 'getIntegrationSecret'
|
||||||
|
| 'listIntegrationsSecrets'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export function getServiceMethods (): Partial<Record<AccountServiceMethods, AccountMethodHandler>> {
|
||||||
|
return {
|
||||||
|
getPendingWorkspace: wrap(getPendingWorkspace),
|
||||||
|
updateWorkspaceInfo: wrap(updateWorkspaceInfo),
|
||||||
|
workerHandshake: wrap(workerHandshake),
|
||||||
|
updateBackupInfo: wrap(updateBackupInfo),
|
||||||
|
assignWorkspace: wrap(assignWorkspace),
|
||||||
|
listWorkspaces: wrap(listWorkspaces),
|
||||||
|
performWorkspaceOperation: wrap(performWorkspaceOperation),
|
||||||
|
updateWorkspaceRoleBySocialKey: wrap(updateWorkspaceRoleBySocialKey),
|
||||||
|
addSocialIdToPerson: wrap(addSocialIdToPerson),
|
||||||
|
createIntegration: wrap(createIntegration),
|
||||||
|
updateIntegration: wrap(updateIntegration),
|
||||||
|
deleteIntegration: wrap(deleteIntegration),
|
||||||
|
listIntegrations: wrap(listIntegrations),
|
||||||
|
getIntegration: wrap(getIntegration),
|
||||||
|
addIntegrationSecret: wrap(addIntegrationSecret),
|
||||||
|
updateIntegrationSecret: wrap(updateIntegrationSecret),
|
||||||
|
deleteIntegrationSecret: wrap(deleteIntegrationSecret),
|
||||||
|
getIntegrationSecret: wrap(getIntegrationSecret),
|
||||||
|
listIntegrationsSecrets: wrap(listIntegrationsSecrets)
|
||||||
|
}
|
||||||
|
}
|
@ -139,6 +139,25 @@ export interface MailboxInfo {
|
|||||||
mailbox: string
|
mailbox: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Integration {
|
||||||
|
socialId: PersonId
|
||||||
|
kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
|
||||||
|
workspaceUuid?: WorkspaceUuid
|
||||||
|
data?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntegrationKey = Omit<Integration, 'data'>
|
||||||
|
|
||||||
|
export interface IntegrationSecret {
|
||||||
|
socialId: PersonId
|
||||||
|
kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
|
||||||
|
workspaceUuid?: WorkspaceUuid
|
||||||
|
key: string // Key for the secret in the integration. Different secrets for the same integration must have different keys. Can be any string. E.g. '', 'user_app_1' etc.
|
||||||
|
secret: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntegrationSecretKey = Omit<IntegrationSecret, 'secret'>
|
||||||
|
|
||||||
/* ========= S U P P L E M E N T A R Y ========= */
|
/* ========= S U P P L E M E N T A R Y ========= */
|
||||||
|
|
||||||
export interface WorkspaceInfoWithStatus extends Workspace {
|
export interface WorkspaceInfoWithStatus extends Workspace {
|
||||||
@ -166,6 +185,8 @@ export interface AccountDB {
|
|||||||
invite: DbCollection<WorkspaceInvite>
|
invite: DbCollection<WorkspaceInvite>
|
||||||
mailbox: DbCollection<Mailbox>
|
mailbox: DbCollection<Mailbox>
|
||||||
mailboxSecret: DbCollection<MailboxSecret>
|
mailboxSecret: DbCollection<MailboxSecret>
|
||||||
|
integration: DbCollection<Integration>
|
||||||
|
integrationSecret: DbCollection<IntegrationSecret>
|
||||||
|
|
||||||
init: () => Promise<void>
|
init: () => Promise<void>
|
||||||
createWorkspace: (data: WorkspaceData, status: WorkspaceStatusData) => Promise<WorkspaceUuid>
|
createWorkspace: (data: WorkspaceData, status: WorkspaceStatusData) => Promise<WorkspaceUuid>
|
||||||
|
@ -610,6 +610,53 @@ export async function selectWorkspace (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateWorkspaceRole (
|
||||||
|
ctx: MeasureContext,
|
||||||
|
db: AccountDB,
|
||||||
|
branding: Branding | null,
|
||||||
|
token: string,
|
||||||
|
params: {
|
||||||
|
targetAccount: PersonUuid
|
||||||
|
targetRole: AccountRole
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const { targetAccount, targetRole } = params
|
||||||
|
|
||||||
|
const { account, workspace } = decodeTokenVerbose(ctx, token)
|
||||||
|
|
||||||
|
if (workspace === null) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: workspace }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const accRole = account === systemAccountUuid ? AccountRole.Owner : await db.getWorkspaceRole(account, workspace)
|
||||||
|
|
||||||
|
if (
|
||||||
|
accRole == null ||
|
||||||
|
getRolePower(accRole) < getRolePower(AccountRole.Maintainer) ||
|
||||||
|
getRolePower(accRole) < getRolePower(targetRole)
|
||||||
|
) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRole = await db.getWorkspaceRole(targetAccount, workspace)
|
||||||
|
|
||||||
|
if (currentRole == null || getRolePower(accRole) < getRolePower(currentRole)) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRole === targetRole) return
|
||||||
|
|
||||||
|
if (currentRole === AccountRole.Owner) {
|
||||||
|
// Check if there are other owners
|
||||||
|
const owners = (await db.getWorkspaceMembers(workspace)).filter((m) => m.role === AccountRole.Owner)
|
||||||
|
if (owners.length === 1) {
|
||||||
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.updateWorkspaceRole(targetAccount, workspace, targetRole)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert workspace name to a URL-friendly string following these rules:
|
* Convert workspace name to a URL-friendly string following these rules:
|
||||||
*
|
*
|
||||||
@ -832,7 +879,7 @@ export async function confirmEmail (
|
|||||||
ctx.error('Email social id not found', { account, normalizedEmail })
|
ctx.error('Email social id not found', { account, normalizedEmail })
|
||||||
throw new PlatformError(
|
throw new PlatformError(
|
||||||
new Status(Severity.ERROR, platform.status.SocialIdNotFound, {
|
new Status(Severity.ERROR, platform.status.SocialIdNotFound, {
|
||||||
socialId: normalizedEmail,
|
value: normalizedEmail,
|
||||||
type: SocialIdType.EMAIL
|
type: SocialIdType.EMAIL
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user