UBERF-5140: Any workspace names (#4489)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2024-02-02 16:27:27 +07:00 committed by GitHub
parent 27035518f5
commit 9dcec23815
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 701 additions and 346 deletions

View File

@ -83,7 +83,7 @@ Before you can begin, you need to create a workspace and an account and associat
```bash
cd ./tool # dev/tool in the repository root
rushx run-local create-workspace ws1 -o DevWorkspace # Create workspace
rushx run-local create-workspace ws1 -w DevWorkspace # Create workspace
rushx run-local create-account user1 -p 1234 -f John -l Appleseed # Create account
rushx run-local configure ws1 --list --enable '*' # Enable all modules, even if they are not yet intended to be used by a wide audience.
rushx run-local assign-workspace user1 ws1 # Assign workspace to user.

View File

@ -155,7 +155,7 @@ export async function connect (handler: (tx: Tx) => void): Promise<ClientConnect
}
},
defaultContentAdapter: 'default',
workspace: getWorkspaceId('')
workspace: { ...getWorkspaceId(''), workspaceUrl: '', workspaceName: '' }
}
const ctx = new MeasureMetricsContext('client', {})
const serverStorage = await createServerStorage(ctx, conf, {

View File

@ -46,7 +46,6 @@ services:
links:
- mongodb
- minio
- transactor
ports:
- 3000:3000
environment:
@ -136,6 +135,7 @@ services:
- elastic
- minio
- rekoni
- account
# - apm-server
ports:
- 3333:3333
@ -154,6 +154,7 @@ services:
- FRONT_URL=http://localhost:8087
# - APM_SERVER_URL=http://apm-server:8200
- SERVER_PROVIDER=ws
- ACCOUNTS_URL=http://account:3000
restart: unless-stopped
rekoni:
image: hardcoreeng/rekoni-service

View File

@ -14,7 +14,7 @@
// limitations under the License.
//
import { DOMAIN_TX, getWorkspaceId, MeasureMetricsContext } from '@hcengineering/core'
import { DOMAIN_TX, MeasureMetricsContext } from '@hcengineering/core'
import { createInMemoryTxAdapter } from '@hcengineering/dev-storage'
import {
ContentTextAdapter,
@ -44,7 +44,7 @@ async function createNullContentTextAdapter (): Promise<ContentTextAdapter> {
export async function start (port: number, host?: string): Promise<void> {
const ctx = new MeasureMetricsContext('server', {})
startJsonRpc(ctx, {
pipelineFactory: (ctx) => {
pipelineFactory: (ctx, workspaceId) => {
const conf: DbConfiguration = {
domains: {
[DOMAIN_TX]: 'InMemoryTx'
@ -74,13 +74,14 @@ export async function start (port: number, host?: string): Promise<void> {
}
},
defaultContentAdapter: 'default',
workspace: getWorkspaceId('')
workspace: workspaceId
}
return createPipeline(ctx, conf, [], false, () => {})
},
sessionFactory: (token, pipeline, broadcast) => new ClientSession(broadcast, token, pipeline),
port,
productId: '',
serverFactory: startHttpServer
serverFactory: startHttpServer,
accountsUrl: ''
})
}

View File

@ -207,14 +207,19 @@ export async function benchmark (
operations = 0
requestTime = 0
transfer = 0
for (const w of workspaceId) {
const r = extract(json.metrics as Metrics, w.name, 'client', 'handleRequest', 'process', 'find-all')
operations += r?.operations ?? 0
requestTime += (r?.value ?? 0) / (((r?.operations as number) ?? 0) + 1)
const r = extract(
json.metrics as Metrics,
'🧲 session',
'client',
'handleRequest',
'process',
'find-all'
)
operations += r?.operations ?? 0
requestTime += (r?.value ?? 0) / (((r?.operations as number) ?? 0) + 1)
const tr = extract(json.metrics as Metrics, w.name, 'client', 'handleRequest', '#send-data')
transfer += tr?.value ?? 0
}
const tr = extract(json.metrics as Metrics, '🧲 session', 'client', 'handleRequest', '#send-data')
transfer += tr?.value ?? 0
})
.catch((err) => {
console.log(err)

View File

@ -17,20 +17,20 @@
import {
ACCOUNT_DB,
assignWorkspace,
ClientWorkspaceInfo,
confirmEmail,
createAcc,
createWorkspace,
dropAccount,
dropWorkspace,
getAccount,
getWorkspace,
getWorkspaceById,
listAccounts,
listWorkspaces,
replacePassword,
setAccountAdmin,
setRole,
upgradeWorkspace,
WorkspaceInfoOnly
upgradeWorkspace
} from '@hcengineering/account'
import { setMetadata } from '@hcengineering/platform'
import {
@ -43,7 +43,7 @@ import {
import serverToken, { decodeToken, generateToken } from '@hcengineering/server-token'
import toolPlugin, { FileModelLogger } from '@hcengineering/server-tool'
import { program, Command } from 'commander'
import { Command, program } from 'commander'
import { Db, MongoClient } from 'mongodb'
import { clearTelegramHistory } from './telegram'
import { diffWorkspace } from './workspace'
@ -155,7 +155,16 @@ export function devTool (
const { mongodbUri } = prepareTools()
await withDatabase(mongodbUri, async (db, client) => {
console.log(`assigning user ${email} to ${workspace}...`)
await assignWorkspace(db, productId, email, workspace)
const workspaceInfo = await getWorkspaceById(db, productId, workspace)
if (workspaceInfo === null) {
throw new Error(`workspace ${workspace} not found`)
}
console.log('assigning to workspace', workspaceInfo)
try {
await assignWorkspace(db, productId, email, workspaceInfo?.workspaceUrl ?? workspaceInfo.workspace)
} catch (err: any) {
console.error(err)
}
})
})
@ -197,11 +206,12 @@ export function devTool (
program
.command('create-workspace <name>')
.description('create workspace')
.requiredOption('-o, --organization <organization>', 'organization name')
.requiredOption('-w, --workspaceName <workspaceName>', 'Workspace name')
.option('-e, --email <email>', 'Author email', 'platform@email.com')
.action(async (workspace, cmd) => {
const { mongodbUri, txes, version, migrateOperations } = prepareTools()
await withDatabase(mongodbUri, async (db) => {
await createWorkspace(version, txes, migrateOperations, db, productId, workspace, cmd.organization)
await createWorkspace(version, txes, migrateOperations, db, productId, cmd.email, cmd.workspaceName, workspace)
})
})
@ -230,7 +240,11 @@ export function devTool (
.action(async (workspace, cmd) => {
const { mongodbUri, version, txes, migrateOperations } = prepareTools()
await withDatabase(mongodbUri, async (db) => {
await upgradeWorkspace(version, txes, migrateOperations, productId, db, workspace)
const info = await getWorkspaceById(db, productId, workspace)
if (info === null) {
throw new Error(`workspace ${workspace} not found`)
}
await upgradeWorkspace(version, txes, migrateOperations, productId, db, info.workspaceUrl ?? info.workspace)
})
})
@ -252,7 +266,7 @@ export function devTool (
const workspaces = await listWorkspaces(db, productId)
const withError: string[] = []
async function _upgradeWorkspace (ws: WorkspaceInfoOnly): Promise<void> {
async function _upgradeWorkspace (ws: ClientWorkspaceInfo): Promise<void> {
const t = Date.now()
const logger = cmd.console
? consoleModelLogger
@ -298,7 +312,7 @@ export function devTool (
.action(async (workspace, cmd) => {
const { mongodbUri } = prepareTools()
await withDatabase(mongodbUri, async (db) => {
const ws = await getWorkspace(db, productId, workspace)
const ws = await getWorkspaceById(db, productId, workspace)
if (ws === null) {
console.log('no workspace exists')
return

View File

@ -229,7 +229,7 @@ export async function createClient (
let lastTx: number
function txHandler (tx: Tx): void {
if (tx === null) {
if (tx == null) {
return
}
if (client === null) {

View File

@ -45,8 +45,8 @@ function count (): string {
* @public
* @returns
*/
export function generateId<T extends Doc> (): Ref<T> {
return (timestamp() + random + count()) as Ref<T>
export function generateId<T extends Doc> (join: string = ''): Ref<T> {
return (timestamp() + join + random + join + count()) as Ref<T>
}
let currentAccount: Account
@ -89,6 +89,14 @@ export interface WorkspaceId {
productId: string
}
/**
* @public
*/
export interface WorkspaceIdWithUrl extends WorkspaceId {
workspaceUrl: string
workspaceName: string
}
/**
* @public
*
@ -507,7 +515,7 @@ export function cutObjectArray (obj: any): any {
for (const key of Object.keys(obj)) {
if (Array.isArray(obj[key])) {
if (obj[key].length > 3) {
Object.assign(r, { [key]: `[${obj[key].slice(0, 3)}, ... and ${obj[key].length - 3} more]` })
Object.assign(r, { [key]: [...obj[key].slice(0, 3), `... and ${obj[key].length - 3} more`] })
} else Object.assign(r, { [key]: obj[key] })
continue
}

View File

@ -18,6 +18,7 @@ import core, {
AccountClient,
ClientConnectEvent,
TxHandler,
TxPersistenceStore,
TxWorkspaceEvent,
WorkspaceEvent,
createClient
@ -68,34 +69,7 @@ export default async () => {
return connect(url.href, upgradeHandler, onUpgrade, onUnauthorized, onConnect)
},
filterModel ? [...getPlugins(), ...(getMetadata(clientPlugin.metadata.ExtraPlugins) ?? [])] : undefined,
{
load: async () => {
if (typeof localStorage !== 'undefined') {
const storedValue = localStorage.getItem('platform.model') ?? null
try {
const model = storedValue != null ? JSON.parse(storedValue) : undefined
if (token !== model?.token) {
return {
full: false,
transactions: [],
hash: []
}
}
return model.model
} catch {}
}
return {
full: true,
transactions: [],
hash: []
}
},
store: async (model) => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('platform.model', JSON.stringify({ token, model }))
}
}
}
createModelPersistence(token)
)
// Check if we had dev hook for client.
client = hookClient(client)
@ -104,6 +78,37 @@ export default async () => {
}
}
}
function createModelPersistence (token: string): TxPersistenceStore | undefined {
return {
load: async () => {
if (typeof localStorage !== 'undefined') {
const storedValue = localStorage.getItem('platform.model') ?? null
try {
const model = storedValue != null ? JSON.parse(storedValue) : undefined
if (token !== model?.token) {
return {
full: false,
transactions: [],
hash: []
}
}
return model.model
} catch {}
}
return {
full: true,
transactions: [],
hash: []
}
},
store: async (model) => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('platform.model', JSON.stringify({ token, model }))
}
}
}
}
async function hookClient (client: Promise<AccountClient>): Promise<AccountClient> {
const hook = getMetadata(clientPlugin.metadata.ClientHook)
if (hook !== undefined) {

View File

@ -15,7 +15,7 @@
"Join": "Join",
"Email": "Email",
"Password": "Password",
"Workspace": "Workspace",
"Workspace": "Workspace name",
"DoNotHaveAnAccount": "Do not have an account?",
"PasswordRepeat": "Repeat password",
"HaveAccount": "Already have an account?",
@ -27,12 +27,6 @@
"WantAnotherWorkspace": "Want to create another workspace?",
"ChangeAccount": "Change account",
"NotSeeingWorkspace": "Not seeing your workspace?",
"WorkspaceNameRule": "Workspace name cannot contain special characters except -",
"WorkspaceNameRuleCapital": "The workspace name must contain only lowercase letters",
"WorkspaceNameRuleHyphen": "The workspace name cannot start with a dash (-)",
"WorkspaceNameRuleHyphenEnd": "The workspace name cannot end with a dash (-)",
"WorkspaceNameRuleLengthLow": "Workspace name must be at least 3 characters",
"WorkspaceNameRuleLengthHigh": "Workspace name must be no longer than 63 characters",
"ForgotPassword": "Forgot your password?",
"KnowPassword": "Know your password?",
"Recover": "Recover",

View File

@ -27,12 +27,6 @@
"WantAnotherWorkspace": "Хотите создать другое рабочее пространство?",
"ChangeAccount": "Сменить пользователя",
"NotSeeingWorkspace": "Не видите ваше рабочее пространство?",
"WorkspaceNameRule": "Название рабочего пространства не может содержать специальные символы кроме -",
"WorkspaceNameRuleCapital": "Название рабочего пространства должно содержать только строчные буквы",
"WorkspaceNameRuleHyphen": "Название рабочего пространства не может начинаться с символа 'тире' (-)",
"WorkspaceNameRuleHyphenEnd": "Название рабочего пространства не может заканчиваться символом 'тире' (-)",
"WorkspaceNameRuleLengthLow": "Название рабочего пространства должно быть не короче 3 символов",
"WorkspaceNameRuleLengthHigh": "Название рабочего пространства должно быть не длиннее 63 символов",
"ForgotPassword": "Забыли пароль?",
"KnowPassword": "Знаете пароль?",
"Recover": "Восстановить",

View File

@ -31,7 +31,7 @@
navigate(loc)
}
function goToLogin () {
function goToLogin (): void {
const loc = getCurrentLocation()
loc.query = undefined
loc.path[1] = 'login'
@ -39,7 +39,7 @@
navigate(loc)
}
async function check () {
async function check (): Promise<void> {
const location = getCurrentLocation()
if (location.query?.id === undefined || location.query?.id === null) return
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
@ -50,6 +50,7 @@
if (result !== undefined) {
setMetadata(presentation.metadata.Token, result.token)
setMetadataLocalStorage(login.metadata.LastToken, result.token)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, result.email)
goToWorkspaces()

View File

@ -28,38 +28,7 @@
{
name: 'workspace',
i18n: login.string.Workspace,
rules: [
{
rule: /^-/,
notMatch: true,
ruleDescr: login.string.WorkspaceNameRuleHyphen
},
{
rule: /-$/,
notMatch: true,
ruleDescr: login.string.WorkspaceNameRuleHyphenEnd
},
{
rule: /[A-Z]/,
notMatch: true,
ruleDescr: login.string.WorkspaceNameRuleCapital
},
{
rule: /^[0-9a-z-]+$/,
notMatch: false,
ruleDescr: login.string.WorkspaceNameRule
},
{
rule: /^[0-9a-z-]{3,}$/,
notMatch: false,
ruleDescr: login.string.WorkspaceNameRuleLengthLow
},
{
rule: /^[0-9a-z-]{3,63}$/,
notMatch: false,
ruleDescr: login.string.WorkspaceNameRuleLengthHigh
}
]
rules: []
}
]
@ -89,12 +58,13 @@
if (result !== undefined) {
setMetadata(presentation.metadata.Token, result.token)
setMetadataLocalStorage(login.metadata.LastToken, result.token)
const tokens: Record<string, string> = fetchMetadataLocalStorage(login.metadata.LoginTokens) ?? {}
tokens[object.workspace] = result.token
setMetadataLocalStorage(login.metadata.LoginTokens, tokens)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, result.email)
navigate({ path: [workbenchId, object.workspace] })
navigate({ path: [workbenchId, result.workspace] })
}
}
}

View File

@ -72,7 +72,7 @@
for (const field of fields) {
const v = object[field.name]
const f = field
if (!f.optional && (!v || v === '')) {
if (!f.optional && (!v || v.trim() === '')) {
status = new Status(Severity.INFO, login.status.RequiredField, {
field: await translate(field.i18n, {}, language)
})

View File

@ -75,6 +75,7 @@
if (result !== undefined) {
setMetadata(presentation.metadata.Token, result.token)
setMetadataLocalStorage(login.metadata.LastToken, result.token)
const tokens: Record<string, string> = fetchMetadataLocalStorage(login.metadata.LoginTokens) ?? {}
tokens[result.workspace] = result.token
setMetadataLocalStorage(login.metadata.LoginTokens, tokens)
@ -128,10 +129,10 @@
}
onMount(() => {
check()
void check()
})
async function check () {
async function check (): Promise<void> {
if (location.query?.inviteId === undefined || location.query?.inviteId === null) return
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [, result] = await checkJoined(location.query.inviteId)
@ -139,6 +140,7 @@
if (result !== undefined) {
const tokens: Record<string, string> = fetchMetadataLocalStorage(login.metadata.LoginTokens) ?? {}
setMetadata(presentation.metadata.Token, result.token)
setMetadataLocalStorage(login.metadata.LastToken, result.token)
tokens[result.workspace] = result.token
setMetadataLocalStorage(login.metadata.LoginTokens, tokens)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)

View File

@ -63,6 +63,7 @@
onDestroy(
location.subscribe((loc) => {
void (async (loc) => {
token = getMetadata(presentation.metadata.Token)
page = loc.path[1] ?? (token ? 'selectWorkspace' : 'login')
if (!pages.includes(page)) {
page = 'login'

View File

@ -14,13 +14,21 @@
// limitations under the License.
-->
<script lang="ts">
import { OK, setMetadata, Severity, Status } from '@hcengineering/platform'
import { getCurrentLocation, navigate, Location, setMetadataLocalStorage } from '@hcengineering/ui'
import { getMetadata, OK, setMetadata, Severity, Status } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import {
fetchMetadataLocalStorage,
getCurrentLocation,
Location,
navigate,
setMetadataLocalStorage,
ticker
} from '@hcengineering/ui'
import { doLogin, getWorkspaces, navigateToWorkspace, selectWorkspace } from '../utils'
import { doLogin, getAccount, getWorkspaces, navigateToWorkspace, selectWorkspace } from '../utils'
import Form from './Form.svelte'
import { LoginInfo } from '@hcengineering/login'
import login from '../plugin'
export let navigateUrl: string | undefined = undefined
@ -40,48 +48,62 @@
password: ''
}
async function doLoginNavigate (
result: LoginInfo | undefined,
updateStatus: (status: Status<any>) => void,
token?: string
): Promise<boolean> {
if (result !== undefined) {
if (result.token != null && getMetadata(presentation.metadata.Token) === result.token) {
return false
}
setMetadata(presentation.metadata.Token, token ?? result.token)
setMetadataLocalStorage(login.metadata.LastToken, token ?? result.token)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, result.email)
if (navigateUrl !== undefined) {
try {
const loc = JSON.parse(decodeURIComponent(navigateUrl)) as Location
const workspace = loc.path[1]
if (workspace !== undefined) {
const workspaces = await getWorkspaces()
if (workspaces.find((p) => p.workspace === workspace) !== undefined) {
updateStatus(new Status(Severity.INFO, login.status.ConnectingToServer, {}))
const [loginStatus, result] = await selectWorkspace(workspace)
updateStatus(loginStatus)
navigateToWorkspace(workspace, result, navigateUrl)
return true
}
}
} catch (err: any) {
// Json parse error could be ignored
}
}
const loc = getCurrentLocation()
loc.path[1] = result.confirmed ? 'selectWorkspace' : 'confirmationSend'
loc.path.length = 2
if (navigateUrl !== undefined) {
loc.query = { ...loc.query, navigateUrl }
}
navigate(loc)
return true
}
return false
}
let status = OK
const action = {
i18n: login.string.LogIn,
func: async () => {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [loginStatus, result] = await doLogin(object.username, object.password)
status = loginStatus
if (result !== undefined) {
setMetadata(presentation.metadata.Token, result.token)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, result.email)
if (navigateUrl !== undefined) {
try {
const loc = JSON.parse(decodeURIComponent(navigateUrl)) as Location
const workspace = loc.path[1]
if (workspace !== undefined) {
const workspaces = await getWorkspaces()
if (workspaces.find((p) => p.workspace === workspace) !== undefined) {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [loginStatus, result] = await selectWorkspace(workspace)
status = loginStatus
navigateToWorkspace(workspace, result, navigateUrl)
return
}
}
} catch (err: any) {
// Json parse error could be ignored
}
}
const loc = getCurrentLocation()
loc.path[1] = result.confirmed ? 'selectWorkspace' : 'confirmationSend'
loc.path.length = 2
if (navigateUrl !== undefined) {
loc.query = { ...loc.query, navigateUrl }
}
navigate(loc)
}
await doLoginNavigate(result, (st) => {
status = st
})
}
}
@ -95,6 +117,30 @@
navigate(loc)
}
}
async function chooseToken (time: number): Promise<void> {
if (getMetadata(presentation.metadata.Token) == null) {
const lastToken = fetchMetadataLocalStorage(login.metadata.LastToken)
if (lastToken != null) {
try {
const info = await getAccount(false, lastToken)
if (info !== undefined) {
await doLoginNavigate(
info,
(st) => {
status = st
},
lastToken
)
}
} catch (err: any) {
setMetadataLocalStorage(login.metadata.LastToken, null)
}
}
}
}
$: chooseToken($ticker)
</script>
<Form

View File

@ -111,8 +111,11 @@
}
</script>
<form class="container" style:padding={$deviceInfo.docWidth <= 480 ? '1.25rem' : '5rem'}>
<form class="container h-60" style:padding={$deviceInfo.docWidth <= 480 ? '1.25rem' : '5rem'}>
<div class="grow-separator" />
<div class="fs-title">
{account?.email}
</div>
<div class="title"><Label label={login.string.SelectWorkspace} /></div>
<div class="status">
<StatusControl {status} />
@ -126,7 +129,7 @@
class="workspace flex-center fs-title cursor-pointer focused-button bordered form-row"
on:click={() => select(workspace.workspace)}
>
{workspace.workspace}
{workspace.workspaceName ?? workspace.workspace}
</div>
{/each}
{#if workspaces.length === 0 && account?.confirmed === true}

View File

@ -16,7 +16,7 @@
<script lang="ts">
import { OK, Severity, Status, setMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { getCurrentLocation, navigate } from '@hcengineering/ui'
import { getCurrentLocation, navigate, setMetadataLocalStorage } from '@hcengineering/ui'
import login from '../plugin'
import { signUp } from '../utils'
import Form from './Form.svelte'
@ -50,6 +50,7 @@
if (result !== undefined) {
setMetadata(presentation.metadata.Token, result.token)
setMetadataLocalStorage(login.metadata.LastToken, result.token)
const loc = getCurrentLocation()
loc.path[1] = 'confirmationSend'
loc.path.length = 2

View File

@ -48,12 +48,6 @@ export default mergeIds(loginId, login, {
WantAnotherWorkspace: '' as IntlString,
NotSeeingWorkspace: '' as IntlString,
ChangeAccount: '' as IntlString,
WorkspaceNameRule: '' as IntlString,
WorkspaceNameRuleHyphen: '' as IntlString,
WorkspaceNameRuleHyphenEnd: '' as IntlString,
WorkspaceNameRuleLengthLow: '' as IntlString,
WorkspaceNameRuleLengthHigh: '' as IntlString,
WorkspaceNameRuleCapital: '' as IntlString,
ForgotPassword: '' as IntlString,
Recover: '' as IntlString,
KnowPassword: '' as IntlString,

View File

@ -116,7 +116,9 @@ export async function signUp (
}
}
export async function createWorkspace (workspace: string): Promise<[Status, LoginInfo | undefined]> {
export async function createWorkspace (
workspaceName: string
): Promise<[Status, (LoginInfo & { workspace: string }) | undefined]> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
if (accountsUrl === undefined) {
@ -128,7 +130,7 @@ export async function createWorkspace (workspace: string): Promise<[Status, Logi
if (overrideToken !== undefined) {
const endpoint = getMetadata(login.metadata.OverrideEndpoint)
if (endpoint !== undefined) {
return [OK, { token: overrideToken, endpoint, email, confirmed: true }]
return [OK, { token: overrideToken, endpoint, email, confirmed: true, workspace: workspaceName }]
}
}
@ -143,7 +145,7 @@ export async function createWorkspace (workspace: string): Promise<[Status, Logi
const request = {
method: 'createWorkspace',
params: [workspace]
params: [workspaceName]
}
try {
@ -214,7 +216,7 @@ export async function getWorkspaces (): Promise<Workspace[]> {
}
}
export async function getAccount (): Promise<LoginInfo | undefined> {
export async function getAccount (doNavigate: boolean = true, token?: string): Promise<LoginInfo | undefined> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
if (accountsUrl === undefined) {
@ -230,12 +232,14 @@ export async function getAccount (): Promise<LoginInfo | undefined> {
}
}
const token = getMetadata(presentation.metadata.Token)
token = token ?? getMetadata(presentation.metadata.Token)
if (token === undefined) {
const loc = getCurrentLocation()
loc.path[1] = 'login'
loc.path.length = 2
navigate(loc)
if (doNavigate) {
const loc = getCurrentLocation()
loc.path[1] = 'login'
loc.path.length = 2
navigate(loc)
}
return
}

View File

@ -26,7 +26,8 @@ export const loginId = 'login' as Plugin
* @public
*/
export interface Workspace {
workspace: string
workspace: string //
workspaceName?: string // A company name
}
/**
@ -50,6 +51,7 @@ export default plugin(loginId, {
metadata: {
AccountsUrl: '' as Asset,
LoginTokens: '' as Metadata<Record<string, string>>,
LastToken: '' as Metadata<string>,
LoginEndpoint: '' as Metadata<string>,
LoginEmail: '' as Metadata<string>,
OverrideLoginToken: '' as Metadata<string>, // debug purposes

View File

@ -98,6 +98,7 @@
setMetadataLocalStorage(login.metadata.LoginTokens, tokens)
}
setMetadata(presentation.metadata.Token, null)
setMetadataLocalStorage(login.metadata.LastToken, null)
setMetadataLocalStorage(login.metadata.LoginEndpoint, null)
setMetadataLocalStorage(login.metadata.LoginEmail, null)
void closeClient()

View File

@ -17,7 +17,7 @@
export let workspace: string
</script>
<div class="antiLogo red" class:mini>{workspace?.toUpperCase()?.[0]}</div>
<div class="antiLogo red" class:mini>{workspace?.toUpperCase()?.[0] ?? ''}</div>
<style lang="scss">
.antiLogo {

View File

@ -136,7 +136,7 @@
<!-- <div class="drag"><Drag size={'small'} /></div> -->
<!-- <div class="logo empty" /> -->
<!-- <div class="flex-col flex-grow"> -->
<span class="label overflow-label flex-grow">{ws.workspace}</span>
<span class="label overflow-label flex-grow">{ws.workspaceName ?? ws.workspace}</span>
<!-- <span class="description overflow-label">Description</span> -->
<!-- </div> -->
<div class="ap-check">

View File

@ -32,10 +32,14 @@
let admin = false
onDestroy(
ticker.subscribe(() => {
void fetch(endpoint + `/api/v1/statistics?token=${token}`, {}).then(async (json) => {
data = await json.json()
admin = data?.admin ?? false
})
void fetch(endpoint + `/api/v1/statistics?token=${token}`, {})
.then(async (json) => {
data = await json.json()
admin = data?.admin ?? false
})
.catch((err) => {
console.error(err)
})
})
)
const tabs: TabItem[] = [

View File

@ -124,8 +124,9 @@
}
onMount(() => {
getResource(login.function.GetWorkspaces).then(async (getWorkspaceFn) => {
void getResource(login.function.GetWorkspaces).then(async (getWorkspaceFn) => {
$workspacesStore = await getWorkspaceFn()
await updateWindowTitle(getLocation())
})
})
@ -188,8 +189,15 @@
})
)
let windowWorkspaceName = ''
async function updateWindowTitle (loc: Location): Promise<void> {
const ws = loc.path[1]
let ws = loc.path[1]
const wsName = $workspacesStore.find((it) => it.workspace === ws)
if (wsName !== undefined) {
ws = wsName?.workspaceName ?? wsName.workspace
windowWorkspaceName = ws
}
const docTitle = await getWindowTitle(loc)
if (docTitle !== undefined && docTitle !== '') {
document.title = ws == null ? docTitle : `${docTitle} - ${ws}`
@ -663,7 +671,7 @@
showPopup(SelectWorkspaceMenu, {}, popupSpacePosition)
}}
>
<Logo mini={appsMini} workspace={$resolvedLocationStore.path[1]} />
<Logo mini={appsMini} workspace={windowWorkspaceName ?? $resolvedLocationStore.path[1]} />
</div>
<div class="topmenu-container clear-mins flex-no-shrink" class:mini={appsMini}>
<AppItem

View File

@ -53,7 +53,7 @@
</div>
</FixedColumn>
</svelte:fragment>
{#each Object.entries(metrics.measurements) as [k, v], i}
{#each Object.entries(metrics.measurements) as [k, v], i (k)}
<div style:margin-left={`${level * 0.5}rem`}>
<svelte:self metrics={v} name="{i}. {k}" level={level + 1} />
</div>

View File

@ -1,12 +1,12 @@
import client from '@hcengineering/client'
import core, {
type AccountClient,
type Client,
ClientConnectEvent,
type Version,
getCurrentAccount,
setCurrentAccount,
versionToString
versionToString,
type AccountClient,
type Client,
type Version
} from '@hcengineering/core'
import login, { loginId } from '@hcengineering/login'
import { addEventListener, broadcastEvent, getMetadata, getResource, setMetadata } from '@hcengineering/platform'
@ -217,6 +217,7 @@ function clearMetadata (ws: string): void {
setMetadataLocalStorage(login.metadata.LoginTokens, tokens)
}
setMetadata(presentation.metadata.Token, null)
setMetadataLocalStorage(login.metadata.LastToken, null)
document.cookie =
encodeURIComponent(presentation.metadata.Token.replaceAll(':', '-')) + '=' + encodeURIComponent('') + '; path=/'
setMetadataLocalStorage(login.metadata.LoginEndpoint, null)

View File

@ -195,6 +195,7 @@ export function signOut (): void {
setMetadataLocalStorage(login.metadata.LoginTokens, tokens)
}
setMetadata(presentation.metadata.Token, null)
setMetadataLocalStorage(login.metadata.LastToken, null)
setMetadataLocalStorage(login.metadata.LoginEndpoint, null)
setMetadataLocalStorage(login.metadata.LoginEmail, null)
void closeClient()

View File

@ -82,6 +82,12 @@ if (frontUrl === undefined) {
process.exit(1)
}
const accountsUrl = process.env.ACCOUNTS_URL
if (accountsUrl === undefined) {
console.log('Please provide ACCOUNTS_URL url')
process.exit(1)
}
const sesUrl = process.env.SES_URL
const cursorMaxTime = process.env.SERVER_CURSOR_MAXTIMEMS
@ -105,7 +111,8 @@ const shutdown = start(url, {
indexParallel: 2,
indexProcessing: 50,
productId: '',
enableCompression
enableCompression,
accountsUrl
})
const close = (): void => {

View File

@ -188,6 +188,8 @@ export function start (
indexParallel: number // 2
enableCompression?: boolean
accountsUrl: string
}
): () => Promise<void> {
addLocation(serverAttachmentId, () => import('@hcengineering/server-attachment-resources'))
@ -270,7 +272,7 @@ export function start (
}
const pipelineFactory: PipelineFactory = (ctx, workspace, upgrade, broadcast) => {
const wsMetrics = metrics.newChild('🧲 ' + workspace.name, {})
const wsMetrics = metrics.newChild('🧲 session', {})
const conf: DbConfiguration = {
domains: {
[DOMAIN_TX]: 'MongoTx',
@ -357,6 +359,7 @@ export function start (
port: opt.port,
productId: opt.productId,
serverFactory: opt.serverFactory,
enableCompression: opt.enableCompression
enableCompression: opt.enableCompression,
accountsUrl: opt.accountsUrl
})
}

View File

@ -1,5 +1,5 @@
cd ./dev/tool
rushx run-local create-workspace ws1 -o DevWorkspace # Create workspace
rushx run-local create-workspace ws1 -w DevWorkspace # Create workspace
rushx run-local create-account user1 -p 1234 -f John -l Appleseed # Create account
rushx run-local configure ws1 --list --enable '*' # Enable all modules, even if they are not yet intended to be used by a wide audience.
rushx run-local assign-workspace user1 ws1 # Assign workspace to user.

View File

@ -131,7 +131,7 @@ async function getUpdateBacklinksTxes (
export async function channelHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> {
const channel = doc as ChunterSpace
const front = getMetadata(serverCore.metadata.FrontUrl) ?? ''
const path = `${workbenchId}/${control.workspace.name}/${chunterId}/${channel._id}`
const path = `${workbenchId}/${control.workspace.workspaceUrl}/${chunterId}/${channel._id}`
const link = concatLink(front, path)
return `<a href='${link}'>${channel.name}</a>`
}

View File

@ -134,7 +134,7 @@ export async function OnChannelUpdate (tx: Tx, control: TriggerControl): Promise
export async function personHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> {
const person = doc as Person
const front = getMetadata(serverCore.metadata.FrontUrl) ?? ''
const path = `${workbenchId}/${control.workspace.name}/${contactId}/${doc._id}`
const path = `${workbenchId}/${control.workspace.workspaceUrl}/${contactId}/${doc._id}`
const link = concatLink(front, path)
return `<a href="${link}">${getName(control.hierarchy, person)}</a>`
}
@ -153,7 +153,7 @@ export function personTextPresenter (doc: Doc, control: TriggerControl): string
export async function organizationHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> {
const organization = doc as Organization
const front = getMetadata(serverCore.metadata.FrontUrl) ?? ''
const path = `${workbenchId}/${control.workspace.name}/${contactId}/${doc._id}`
const path = `${workbenchId}/${control.workspace.workspaceUrl}/${contactId}/${doc._id}`
const link = concatLink(front, path)
return `<a href="${link}">${organization.name}</a>`
}

View File

@ -26,7 +26,7 @@ import { workbenchId } from '@hcengineering/workbench'
export async function productHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> {
const product = doc as Product
const front = getMetadata(serverCore.metadata.FrontUrl) ?? ''
const path = `${workbenchId}/${control.workspace.name}/${inventoryId}/Products/#${view.component.EditDoc}|${product._id}|${product._class}|content`
const path = `${workbenchId}/${control.workspace.workspaceUrl}/${inventoryId}/Products/#${view.component.EditDoc}|${product._id}|${product._class}|content`
const link = concatLink(front, path)
return `<a href="${link}">${product.name}</a>`
}

View File

@ -26,7 +26,7 @@ import { workbenchId } from '@hcengineering/workbench'
export async function leadHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> {
const lead = doc as Lead
const front = getMetadata(serverCore.metadata.FrontUrl) ?? ''
const path = `${workbenchId}/${control.workspace.name}/${leadId}/${lead.space}/#${view.component.EditDoc}|${lead._id}|${lead._class}|content`
const path = `${workbenchId}/${control.workspace.workspaceUrl}/${leadId}/${lead.space}/#${view.component.EditDoc}|${lead._id}|${lead._class}|content`
const link = concatLink(front, path)
return `<a href="${link}">${lead.title}</a>`
}

View File

@ -47,7 +47,7 @@ function getSequenceId (doc: Vacancy | Applicant, control: TriggerControl): stri
export async function vacancyHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> {
const vacancy = doc as Vacancy
const front = getMetadata(serverCore.metadata.FrontUrl) ?? ''
const path = `${workbenchId}/${control.workspace.name}/${recruitId}/${getSequenceId(vacancy, control)}`
const path = `${workbenchId}/${control.workspace.workspaceUrl}/${recruitId}/${getSequenceId(vacancy, control)}`
const link = concatLink(front, path)
return `<a href="${link}">${vacancy.name}</a>`
}
@ -67,7 +67,7 @@ export async function applicationHTMLPresenter (doc: Doc, control: TriggerContro
const applicant = doc as Applicant
const front = getMetadata(serverCore.metadata.FrontUrl) ?? ''
const id = getSequenceId(applicant, control)
const path = `${workbenchId}/${control.workspace.name}/${recruitId}/${id}`
const path = `${workbenchId}/${control.workspace.workspaceUrl}/${recruitId}/${id}`
const link = concatLink(front, path)
return `<a href="${link}">${id}</a>`
}

View File

@ -59,7 +59,7 @@ export async function issueHTMLPresenter (doc: Doc, control: TriggerControl): Pr
const issue = doc as Issue
const issueId = await getIssueId(issue, control)
const front = getMetadata(serverCore.metadata.FrontUrl) ?? ''
const path = `${workbenchId}/${control.workspace.name}/${trackerId}/${issueId}`
const path = `${workbenchId}/${control.workspace.workspaceUrl}/${trackerId}/${issueId}`
const link = concatLink(front, path)
return `<a href="${link}">${issueId}</a> ${issue.title}`
}

View File

@ -17,7 +17,7 @@
import builder, { migrateOperations, getModelVersion } from '@hcengineering/model-all'
import { randomBytes } from 'crypto'
import { Db, MongoClient } from 'mongodb'
import accountPlugin, { getAccount, getMethods, getWorkspace } from '..'
import accountPlugin, { getAccount, getMethods, getWorkspaceByUrl } from '..'
import { setMetadata } from '@hcengineering/platform'
const DB_NAME = 'test_accounts'
@ -147,14 +147,14 @@ describe('server', () => {
// Check we had one
expect((await getAccount(db, 'andrey'))?.workspaces.length).toEqual(1)
expect((await getWorkspace(db, '', workspace))?.accounts.length).toEqual(1)
expect((await getWorkspaceByUrl(db, '', workspace))?.accounts.length).toEqual(1)
await methods.removeWorkspace(db, '', {
method: 'removeWorkspace',
params: ['andrey', workspace]
})
expect((await getAccount(db, 'andrey'))?.workspaces.length).toEqual(0)
expect((await getWorkspace(db, '', workspace))?.accounts.length).toEqual(0)
expect((await getWorkspaceByUrl(db, '', workspace))?.accounts.length).toEqual(0)
})
afterAll(async () => {

View File

@ -27,6 +27,7 @@ import core, {
AccountRole,
concatLink,
Data,
generateId,
getWorkspaceId,
Ref,
systemAccountEmail,
@ -93,12 +94,14 @@ export interface Account {
*/
export interface Workspace {
_id: ObjectId
workspace: string
organisation: string
workspace: string // An uniq workspace name, Database names
accounts: ObjectId[]
productId: string
disabled?: boolean
version?: Data<Version>
workspaceUrl?: string | null // An optional url to the workspace, if not set workspace will be used
workspaceName?: string // An displayed workspace name
}
/**
@ -175,13 +178,30 @@ function withProductId (productId: string, query: Filter<Workspace>): Filter<Wor
}
: { productId, ...query }
}
/**
* @public
* @param db -
* @param workspaceUrl -
* @returns
*/
export async function getWorkspaceByUrl (db: Db, productId: string, workspaceUrl: string): Promise<Workspace | null> {
const res = await db.collection<Workspace>(WORKSPACE_COLLECTION).findOne(withProductId(productId, { workspaceUrl }))
if (res != null) {
return res
}
// Fallback to old workspaces.
return await db
.collection<Workspace>(WORKSPACE_COLLECTION)
.findOne(withProductId(productId, { workspace: workspaceUrl, workspaceUrl: { $exists: false } }))
}
/**
* @public
* @param db -
* @param workspace -
* @returns
*/
export async function getWorkspace (db: Db, productId: string, workspace: string): Promise<Workspace | null> {
export async function getWorkspaceById (db: Db, productId: string, workspace: string): Promise<Workspace | null> {
return await db.collection<Workspace>(WORKSPACE_COLLECTION).findOne(withProductId(productId, { workspace }))
}
@ -203,7 +223,12 @@ async function getAccountInfo (db: Db, email: string, password: string): Promise
}
async function getAccountInfoByToken (db: Db, productId: string, token: string): Promise<AccountInfo> {
const { email } = decodeToken(token)
let email: string = ''
try {
email = decodeToken(token)?.email
} catch (err: any) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Unauthorized, {}))
}
const account = await getAccount(db, email)
if (account === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email }))
@ -253,7 +278,7 @@ export async function selectWorkspace (
db: Db,
productId: string,
token: string,
workspace: string,
workspaceUrl: string,
allowAdmin: boolean = true
): Promise<WorkspaceLoginInfo> {
let { email } = decodeToken(token)
@ -263,21 +288,25 @@ export async function selectWorkspace (
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email }))
}
const workspaceInfo = await getWorkspaceByUrl(db, productId, workspaceUrl)
if (workspaceInfo == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspaceUrl }))
}
if (accountInfo.admin === true && allowAdmin) {
return {
endpoint: getEndpoint(),
email,
token: generateToken(email, getWorkspaceId(workspace, productId), getExtra(accountInfo)),
workspace,
token: generateToken(email, getWorkspaceId(workspaceInfo.workspace, productId), getExtra(accountInfo)),
workspace: workspaceUrl,
productId
}
}
const workspaceInfo = await getWorkspace(db, productId, workspace)
if (workspaceInfo !== null) {
if (workspaceInfo.disabled === true) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace }))
throw new PlatformError(
new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspaceUrl })
)
}
const workspaces = accountInfo.workspaces
@ -286,8 +315,8 @@ export async function selectWorkspace (
const result = {
endpoint: getEndpoint(),
email,
token: generateToken(email, getWorkspaceId(workspace, productId), getExtra(accountInfo)),
workspace,
token: generateToken(email, getWorkspaceId(workspaceInfo.workspace, productId), getExtra(accountInfo)),
workspace: workspaceUrl,
productId
}
return result
@ -546,11 +575,11 @@ export async function createAccount (
/**
* @public
*/
export async function listWorkspaces (db: Db, productId: string): Promise<WorkspaceInfoOnly[]> {
export async function listWorkspaces (db: Db, productId: string): Promise<ClientWorkspaceInfo[]> {
return (await db.collection<Workspace>(WORKSPACE_COLLECTION).find(withProductId(productId, {})).toArray())
.map((it) => ({ ...it, productId }))
.filter((it) => it.disabled !== true)
.map(trimWorkspace)
.map(mapToClientWorkspace)
}
/**
@ -560,6 +589,97 @@ export async function listAccounts (db: Db): Promise<Account[]> {
return await db.collection<Account>(ACCOUNT_COLLECTION).find({}).toArray()
}
const workspaceReg = /[a-z0-9]/
function stripId (name: string): string {
let workspaceId = ''
for (const c of name.toLowerCase()) {
if (workspaceReg.test(c) || c === '-') {
workspaceId += c
}
}
return workspaceId
}
function getEmailName (email: string): string {
return email.split('@')[0]
}
async function generateWorkspaceRecord (
db: Db,
email: string,
productId: string,
version: Data<Version>,
workspaceName: string,
fixedWorkspace?: string
): Promise<Workspace> {
const coll = db.collection<Omit<Workspace, '_id'>>(WORKSPACE_COLLECTION)
if (fixedWorkspace !== undefined) {
const ws = await coll.find<Workspace>({ workspaceUrl: fixedWorkspace }).toArray()
if ((await getWorkspaceById(db, productId, fixedWorkspace)) !== null || ws.length > 0) {
throw new PlatformError(
new Status(Severity.ERROR, platform.status.WorkspaceAlreadyExists, { workspace: fixedWorkspace })
)
}
const data = {
workspace: fixedWorkspace,
workspaceUrl: fixedWorkspace,
productId,
version,
workspaceName,
accounts: [],
disabled: false
}
// Add fixed workspace
const id = await coll.insertOne(data)
return { _id: id.insertedId, ...data }
}
const workspaceUrlPrefix = stripId(workspaceName)
const workspaceIdPrefix = stripId(getEmailName(email)).slice(0, 12) + '-' + workspaceUrlPrefix.slice(12)
let iteration = 0
let idPostfix = generateId('-')
let urlPostfix = ''
while (true) {
const workspace = 'w-' + workspaceIdPrefix + '-' + idPostfix
let workspaceUrl =
workspaceUrlPrefix + (workspaceUrlPrefix.length > 0 && urlPostfix.length > 0 ? '-' : '') + urlPostfix
if (workspaceUrl.trim().length === 0) {
workspaceUrl = generateId('-')
}
const ws = await coll.find<Workspace>({ $or: [{ workspaceUrl }, { workspace }] }).toArray()
if (ws.length === 0) {
const data = {
workspace,
workspaceUrl,
productId,
version,
workspaceName,
accounts: [],
disabled: false
}
// Nice we do not have a workspace or workspaceUrl duplicated.
const id = await coll.insertOne(data)
return { _id: id.insertedId, ...data }
}
for (const w of ws) {
if (w.workspace === workspaceUrl) {
idPostfix = generateId('-')
}
if (w.workspaceUrl === workspaceUrl) {
urlPostfix = generateId('-')
}
}
iteration++
// A stupid check, but for sure we not hang.
if (iteration > 10000) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceRateLimit, { workspace }))
}
}
}
let searchPromise: Promise<Workspace> | undefined
/**
* @public
*/
@ -569,32 +689,36 @@ export async function createWorkspace (
migrationOperation: [string, MigrateOperation][],
db: Db,
productId: string,
workspace: string,
organisation: string
): Promise<string> {
if ((await getWorkspace(db, productId, workspace)) !== null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceAlreadyExists, { workspace }))
}
const result = await db
.collection(WORKSPACE_COLLECTION)
.insertOne({
workspace,
organisation,
version,
productId
})
.then((e) => e.insertedId.toHexString())
const initWS = getMetadata(toolPlugin.metadata.InitWorkspace)
if (initWS !== undefined) {
if ((await getWorkspace(db, productId, initWS)) !== null) {
await initModel(getTransactor(), getWorkspaceId(workspace, productId), txes, [])
await cloneWorkspace(getTransactor(), getWorkspaceId(initWS, productId), getWorkspaceId(workspace, productId))
await upgradeModel(getTransactor(), getWorkspaceId(workspace, productId), txes, migrationOperation)
return result
email: string,
workspaceName: string,
workspace?: string
): Promise<{ workspaceInfo: Workspace, err?: any }> {
// We need to search for duplicate workspaceUrl
await searchPromise
// Safe generate workspace record.
searchPromise = generateWorkspaceRecord(db, email, productId, version, workspaceName, workspace)
const workspaceInfo = await searchPromise
try {
const initWS = getMetadata(toolPlugin.metadata.InitWorkspace)
const wsId = getWorkspaceId(workspaceInfo.workspace, productId)
if (initWS !== undefined) {
if ((await getWorkspaceById(db, productId, initWS)) !== null) {
await initModel(getTransactor(), wsId, txes, [])
await cloneWorkspace(
getTransactor(),
getWorkspaceId(initWS, productId),
getWorkspaceId(workspaceInfo.workspace, productId)
)
await upgradeModel(getTransactor(), wsId, txes, migrationOperation)
}
}
await initModel(getTransactor(), wsId, txes, migrationOperation)
} catch (err: any) {
return { workspaceInfo, err }
}
await initModel(getTransactor(), getWorkspaceId(workspace, productId), txes, migrationOperation)
return result
return { workspaceInfo }
}
/**
@ -606,13 +730,13 @@ export async function upgradeWorkspace (
migrationOperation: [string, MigrateOperation][],
productId: string,
db: Db,
workspace: string,
workspaceUrl: string,
logger: ModelLogger = consoleModelLogger,
forceUpdate: boolean = true
): Promise<string> {
const ws = await getWorkspace(db, productId, workspace)
const ws = await getWorkspaceByUrl(db, productId, workspaceUrl)
if (ws === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace }))
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspaceUrl }))
}
if (ws.productId !== productId) {
if (productId !== '' || ws.productId !== undefined) {
@ -621,7 +745,7 @@ export async function upgradeWorkspace (
}
const versionStr = versionToString(version)
const currentVersion = await db.collection<Workspace>(WORKSPACE_COLLECTION).findOne({ workspace })
const currentVersion = await db.collection<Workspace>(WORKSPACE_COLLECTION).findOne({ workspace: workspaceUrl })
console.log(
`${forceUpdate ? 'force-' : ''}upgrade from "${
currentVersion?.version !== undefined ? versionToString(currentVersion.version) : ''
@ -632,12 +756,12 @@ export async function upgradeWorkspace (
return versionStr
}
await db.collection(WORKSPACE_COLLECTION).updateOne(
{ workspace },
{ workspace: workspaceUrl },
{
$set: { version }
}
)
await upgradeModel(getTransactor(), getWorkspaceId(workspace, productId), txes, migrationOperation, logger)
await upgradeModel(getTransactor(), getWorkspaceId(workspaceUrl, productId), txes, migrationOperation, logger)
return versionStr
}
@ -646,14 +770,12 @@ export async function upgradeWorkspace (
*/
export const createUserWorkspace =
(version: Data<Version>, txes: Tx[], migrationOperation: [string, MigrateOperation][]) =>
async (db: Db, productId: string, token: string, workspace: string): Promise<LoginInfo> => {
if (!/^[0-9a-z][0-9a-z-]{2,62}[0-9a-z]$/.test(workspace)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidId, { id: workspace }))
}
async (db: Db, productId: string, token: string, workspaceName: string): Promise<LoginInfo> => {
const { email, extra } = decodeToken(token)
const nonConfirmed = extra?.confirmed === false
console.log(`Creating workspace ${workspace} for ${email} ${nonConfirmed ? 'non confirmed' : 'confirmed'}`)
console.log(
`Creating workspace for "${workspaceName}" for ${email} ${nonConfirmed ? 'non confirmed' : 'confirmed'}`
)
if (nonConfirmed) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email }))
@ -663,27 +785,31 @@ export const createUserWorkspace =
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email }))
}
if (info.lastWorkspace !== undefined) {
if (info.lastWorkspace !== undefined && info.admin === false) {
if (Date.now() - info.lastWorkspace < 60 * 1000) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceRateLimit, { workspace }))
throw new PlatformError(
new Status(Severity.ERROR, platform.status.WorkspaceRateLimit, { workspace: workspaceName })
)
}
}
if ((await getWorkspace(db, productId, workspace)) !== null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceAlreadyExists, { workspace }))
}
try {
await createWorkspace(version, txes, migrationOperation, db, productId, workspace, '')
} catch (err: any) {
const { workspaceInfo, err } = await createWorkspace(
version,
txes,
migrationOperation,
db,
productId,
email,
workspaceName
)
if (err != null) {
console.error(err)
// We need to drop workspace, to prevent wrong data usage.
const ws = await getWorkspace(db, productId, workspace)
if (ws === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace }))
}
await db.collection(WORKSPACE_COLLECTION).updateOne(
{
_id: ws._id
_id: workspaceInfo._id
},
{ $set: { disabled: true, message: JSON.stringify(err?.message ?? ''), err: JSON.stringify(err) } }
)
@ -695,16 +821,17 @@ export const createUserWorkspace =
await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: info._id }, { $set: { lastWorkspace: Date.now() } })
const initWS = getMetadata(toolPlugin.metadata.InitWorkspace)
const shouldUpdateAccount = initWS !== undefined && (await getWorkspace(db, productId, initWS)) !== null
await assignWorkspace(db, productId, email, workspace, shouldUpdateAccount)
await setRole(email, workspace, productId, AccountRole.Owner)
const shouldUpdateAccount = initWS !== undefined && (await getWorkspaceById(db, productId, initWS)) !== null
await assignWorkspace(db, productId, email, workspaceInfo.workspace, shouldUpdateAccount)
await setRole(email, workspaceInfo.workspace, productId, AccountRole.Owner)
const result = {
endpoint: getEndpoint(),
email,
token: generateToken(email, getWorkspaceId(workspace, productId), getExtra(info)),
productId
token: generateToken(email, getWorkspaceId(workspaceInfo.workspace, productId), getExtra(info)),
productId,
workspace: workspaceInfo.workspaceUrl
}
console.log(`Creating workspace ${workspace} Done`)
console.log(`Creating workspace "${workspaceName}" Done`)
return result
}
@ -720,7 +847,7 @@ export async function getInviteLink (
limit: number
): Promise<ObjectId> {
const { workspace } = decodeToken(token)
const wsPromise = await getWorkspace(db, productId, workspace.name)
const wsPromise = await getWorkspaceById(db, productId, workspace.name)
if (wsPromise === null) {
throw new PlatformError(
new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspace.name })
@ -738,17 +865,17 @@ export async function getInviteLink (
/**
* @public
*/
export type WorkspaceInfoOnly = Omit<Workspace, '_id' | 'accounts'>
export type ClientWorkspaceInfo = Omit<Workspace, '_id' | 'accounts' | 'workspaceUrl'>
function trimWorkspace (ws: Workspace): WorkspaceInfoOnly {
function mapToClientWorkspace (ws: Workspace): ClientWorkspaceInfo {
const { _id, accounts, ...data } = ws
return data
return { ...data, workspace: ws.workspaceUrl ?? ws.workspace }
}
/**
* @public
*/
export async function getUserWorkspaces (db: Db, productId: string, token: string): Promise<WorkspaceInfoOnly[]> {
export async function getUserWorkspaces (db: Db, productId: string, token: string): Promise<ClientWorkspaceInfo[]> {
const { email } = decodeToken(token)
const account = await getAccount(db, email)
if (account === null) return []
@ -759,19 +886,49 @@ export async function getUserWorkspaces (db: Db, productId: string, token: strin
.toArray()
)
.filter((it) => it.disabled !== true)
.map(trimWorkspace)
.map(mapToClientWorkspace)
}
/**
* @public
*/
export async function getWorkspaceInfo (db: Db, productId: string, token: string): Promise<ClientWorkspaceInfo> {
const { email, workspace } = decodeToken(token)
let account: Pick<Account, 'admin' | 'workspaces'> | null = null
if (email !== systemAccountEmail) {
account = await getAccount(db, email)
if (account === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
} else {
account = {
admin: true,
workspaces: []
}
}
const [ws] = (
await db
.collection<Workspace>(WORKSPACE_COLLECTION)
.find(withProductId(productId, account.admin === true ? {} : { _id: { $in: account.workspaces } }))
.toArray()
).filter((it) => it.disabled !== true && it.workspace === workspace.name)
if (ws == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
return mapToClientWorkspace(ws)
}
async function getWorkspaceAndAccount (
db: Db,
productId: string,
_email: string,
workspace: string
workspaceUrl: string
): Promise<{ accountId: ObjectId, workspaceId: ObjectId }> {
const email = cleanEmail(_email)
const wsPromise = await getWorkspace(db, productId, workspace)
const wsPromise = await getWorkspaceById(db, productId, workspaceUrl)
if (wsPromise === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace }))
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspaceUrl }))
}
const workspaceId = wsPromise._id
const account = await getAccount(db, email)
@ -787,7 +944,7 @@ async function getWorkspaceAndAccount (
*/
export async function setRole (_email: string, workspace: string, productId: string, role: AccountRole): Promise<void> {
const email = cleanEmail(_email)
const connection = await connect(getTransactor(), getWorkspaceId(workspace, productId), email)
const connection = await connect(getTransactor(), getWorkspaceId(workspace, productId))
try {
const ops = new TxOperations(connection, core.account.System)
@ -811,24 +968,30 @@ export async function assignWorkspace (
db: Db,
productId: string,
_email: string,
workspace: string,
workspaceId: string,
shouldReplaceAccount: boolean = false
): Promise<void> {
const email = cleanEmail(_email)
const initWS = getMetadata(toolPlugin.metadata.InitWorkspace)
if (initWS !== undefined && initWS === workspace) {
if (initWS !== undefined && initWS === workspaceId) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const { workspaceId, accountId } = await getWorkspaceAndAccount(db, productId, email, workspace)
const workspaceInfo = await getWorkspaceAndAccount(db, productId, email, workspaceId)
const account = await db.collection<Account>(ACCOUNT_COLLECTION).findOne({ _id: accountId })
if (account !== null) await createPersonAccount(account, productId, workspace, shouldReplaceAccount)
if (account !== null) {
await createPersonAccount(account, productId, workspaceId, shouldReplaceAccount)
}
// Add account into workspace.
await db.collection(WORKSPACE_COLLECTION).updateOne({ _id: workspaceId }, { $addToSet: { accounts: accountId } })
await db
.collection(WORKSPACE_COLLECTION)
.updateOne({ _id: workspaceInfo.workspaceId }, { $addToSet: { accounts: workspaceInfo.accountId } })
// Add workspace to account
await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: accountId }, { $addToSet: { workspaces: workspaceId } })
await db
.collection(ACCOUNT_COLLECTION)
.updateOne({ _id: workspaceInfo.accountId }, { $addToSet: { workspaces: workspaceInfo.workspaceId } })
}
async function createEmployee (ops: TxOperations, name: string, _email: string): Promise<Ref<Person>> {
@ -1090,10 +1253,10 @@ export async function checkJoin (
/**
* @public
*/
export async function dropWorkspace (db: Db, productId: string, workspace: string): Promise<void> {
const ws = await getWorkspace(db, productId, workspace)
export async function dropWorkspace (db: Db, productId: string, workspaceId: string): Promise<void> {
const ws = await getWorkspaceById(db, productId, workspaceId)
if (ws === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace }))
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspaceId }))
}
await db.collection(WORKSPACE_COLLECTION).deleteOne({ _id: ws._id })
await db
@ -1138,7 +1301,7 @@ export async function leaveWorkspace (db: Db, productId: string, token: string,
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: tokenData.email }))
}
const workspace = await getWorkspace(db, productId, tokenData.workspace.name)
const workspace = await getWorkspaceById(db, productId, tokenData.workspace.name)
if (workspace === null) {
throw new PlatformError(
new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: tokenData.workspace.name })
@ -1168,7 +1331,7 @@ export async function sendInvite (db: Db, productId: string, token: string, emai
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: tokenData.email }))
}
const workspace = await getWorkspace(db, productId, tokenData.workspace.name)
const workspace = await getWorkspaceById(db, productId, tokenData.workspace.name)
if (workspace === null) {
throw new PlatformError(
new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: tokenData.workspace.name })
@ -1214,7 +1377,7 @@ export async function sendInvite (db: Db, productId: string, token: string, emai
}
async function deactivatePersonAccount (email: string, workspace: string, productId: string): Promise<void> {
const connection = await connect(getTransactor(), getWorkspaceId(workspace, productId), email)
const connection = await connect(getTransactor(), getWorkspaceId(workspace, productId))
try {
const ops = new TxOperations(connection, core.account.System)
@ -1244,12 +1407,17 @@ function wrap (f: (db: Db, productId: string, ...args: any[]) => Promise<any>):
return await f(db, productId, ...request.params)
.then((result) => ({ id: request.id, result }))
.catch((err) => {
console.error(err)
const status =
err instanceof PlatformError
? err.status
: new Status(Severity.ERROR, platform.status.InternalServerError, {})
if (status.code === platform.status.InternalServerError) {
console.error(status, err)
} else {
console.error(status)
}
return {
error:
err instanceof PlatformError
? err.status
: new Status(Severity.ERROR, platform.status.InternalServerError, {})
error: status
}
})
}
@ -1272,6 +1440,7 @@ export function getMethods (
getUserWorkspaces: wrap(getUserWorkspaces),
getInviteLink: wrap(getInviteLink),
getAccountInfo: wrap(getAccountInfo),
getWorkspaceInfo: wrap(getWorkspaceInfo),
createAccount: wrap(createAccount),
createWorkspace: wrap(createUserWorkspace(version, txes, migrateOperations)),
assignWorkspace: wrap(assignWorkspace),

View File

@ -54,6 +54,7 @@ import core, {
TxWorkspaceEvent,
WorkspaceEvent,
WorkspaceId,
WorkspaceIdWithUrl,
generateId
} from '@hcengineering/core'
import { MinioService } from '@hcengineering/minio'
@ -92,7 +93,7 @@ export interface DbConfiguration {
adapters: Record<string, DbAdapterConfiguration>
domains: Record<string, string>
defaultAdapter: string
workspace: WorkspaceId
workspace: WorkspaceIdWithUrl
metrics: MeasureContext
fulltextAdapter: {
factory: FullTextAdapterFactory
@ -121,7 +122,7 @@ class TServerStorage implements ServerStorage {
private readonly fulltextAdapter: FullTextAdapter,
readonly storageAdapter: MinioService | undefined,
readonly modelDb: ModelDb,
private readonly workspace: WorkspaceId,
private readonly workspace: WorkspaceIdWithUrl,
readonly indexFactory: (storage: ServerStorage) => FullTextIndex,
readonly options: ServerStorageOptions,
metrics: MeasureContext,

View File

@ -37,7 +37,8 @@ import {
Tx,
TxFactory,
TxResult,
WorkspaceId
WorkspaceId,
WorkspaceIdWithUrl
} from '@hcengineering/core'
import { MinioService } from '@hcengineering/minio'
import type { Asset, Resource } from '@hcengineering/platform'
@ -114,7 +115,7 @@ export interface Pipeline extends LowLevelStorage {
*/
export interface TriggerControl {
ctx: MeasureContext
workspace: WorkspaceId
workspace: WorkspaceIdWithUrl
txFactory: TxFactory
findAll: Storage['findAll']
findAllCtx: <T extends Doc>(

View File

@ -157,7 +157,7 @@ describe('mongo operations', () => {
}
},
defaultContentAdapter: 'default',
workspace: getWorkspaceId(dbId, ''),
workspace: { ...getWorkspaceId(dbId, ''), workspaceName: '', workspaceUrl: '' },
storageFactory: () => createNullStorageFactory()
}
const ctx = new MeasureMetricsContext('client', {})

View File

@ -88,7 +88,8 @@ describe('server', () => {
sessionFactory: (token, pipeline, broadcast) => new ClientSession(broadcast, token, pipeline),
port: 3335,
productId: '',
serverFactory: startHttpServer
serverFactory: startHttpServer,
accountsUrl: ''
})
function connect (): WebSocket {
@ -186,7 +187,8 @@ describe('server', () => {
sessionFactory: (token, pipeline, broadcast) => new ClientSession(broadcast, token, pipeline),
port: 3336,
productId: '',
serverFactory: startHttpServer
serverFactory: startHttpServer,
accountsUrl: ''
})
async function findClose (token: string, timeoutPromise: Promise<void>, code: number): Promise<string> {

View File

@ -29,7 +29,6 @@ import { unknownError } from '@hcengineering/platform'
import { readRequest, type HelloRequest, type HelloResponse, type Request, type Response } from '@hcengineering/rpc'
import type { Pipeline, SessionContext } from '@hcengineering/server-core'
import { type Token } from '@hcengineering/server-token'
// import WebSocket, { RawData } from 'ws'
import {
LOGGING_ENABLED,
@ -42,6 +41,11 @@ import {
type Workspace
} from './types'
interface WorkspaceLoginInfo {
workspaceName?: string // A company name
workspace: string
}
function timeoutPromise (time: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, time)
@ -154,45 +158,96 @@ class TSessionManager implements SessionManager {
return this.sessionFactory(token, pipeline, this.broadcast.bind(this))
}
async getWorkspaceInfo (
accounts: string,
token: string
): Promise<{
workspace: string
workspaceUrl?: string | null
workspaceName?: string
}> {
const userInfo = await (
await fetch(accounts, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
method: 'getWorkspaceInfo',
params: []
})
})
).json()
return userInfo.result as WorkspaceLoginInfo
}
async addSession (
baseCtx: MeasureContext,
ws: ConnectionSocket,
token: Token,
rawToken: string,
pipelineFactory: PipelineFactory,
productId: string,
sessionId?: string
): Promise<{ session: Session, context: MeasureContext } | { upgrade: true }> {
sessionId: string | undefined,
accountsUrl: string
): Promise<
{ session: Session, context: MeasureContext, workspaceName: string } | { upgrade: true } | { error: any }
> {
return await baseCtx.with('📲 add-session', {}, async (ctx) => {
const wsString = toWorkspaceString(token.workspace, '@')
const workspaceInfo =
accountsUrl !== ''
? await this.getWorkspaceInfo(accountsUrl, rawToken)
: {
workspace: token.workspace.name,
workspaceUrl: token.workspace.name,
workspaceName: token.workspace.name
}
if (workspaceInfo === undefined) {
// No access to workspace for token.
return { error: new Error(`No access to workspace for token ${token.email} ${token.workspace.name}`) }
}
let workspace = this.workspaces.get(wsString)
await workspace?.closing
workspace = this.workspaces.get(wsString)
const workspaceName = workspaceInfo.workspaceName ?? workspaceInfo.workspaceUrl ?? workspaceInfo.workspace
if (workspace === undefined) {
workspace = this.createWorkspace(baseCtx, pipelineFactory, token)
workspace = this.createWorkspace(
baseCtx,
pipelineFactory,
token,
workspaceInfo.workspaceUrl ?? workspaceInfo.workspace,
workspaceName
)
}
let pipeline: Pipeline
if (token.extra?.model === 'upgrade') {
if (workspace.upgrade) {
pipeline = await ctx.with(
'💤 wait ' + token.workspace.name,
{},
async () => await (workspace as Workspace).pipeline
)
pipeline = await ctx.with('💤 wait ' + workspaceName, {}, async () => await (workspace as Workspace).pipeline)
} else {
pipeline = await this.createUpgradeSession(token, sessionId, ctx, wsString, workspace, pipelineFactory, ws)
pipeline = await this.createUpgradeSession(
token,
sessionId,
ctx,
wsString,
workspace,
pipelineFactory,
ws,
workspaceInfo.workspaceUrl ?? workspaceInfo.workspace,
workspaceName
)
}
} else {
if (workspace.upgrade) {
return { upgrade: true }
}
pipeline = await ctx.with(
'💤 wait ' + token.workspace.name,
{},
async () => await (workspace as Workspace).pipeline
)
pipeline = await ctx.with('💤 wait ' + workspaceName, {}, async () => await (workspace as Workspace).pipeline)
}
const session = this.createSession(token, pipeline)
@ -211,7 +266,7 @@ class TSessionManager implements SessionManager {
session.useCompression
)
}
return { session, context: workspace.context }
return { session, context: workspace.context, workspaceName }
})
}
@ -222,15 +277,18 @@ class TSessionManager implements SessionManager {
wsString: string,
workspace: Workspace,
pipelineFactory: PipelineFactory,
ws: ConnectionSocket
ws: ConnectionSocket,
workspaceUrl: string,
workspaceName: string
): Promise<Pipeline> {
if (LOGGING_ENABLED) {
console.log(token.workspace.name, 'reloading workspace', JSON.stringify(token))
console.log(workspaceName, 'reloading workspace', JSON.stringify(token))
}
// If upgrade client is used.
// Drop all existing clients
await this.closeAll(wsString, workspace, 0, 'upgrade')
// Wipe workspace and update values.
workspace.workspaceName = workspaceName
if (!workspace.upgrade) {
// This is previous workspace, intended to be closed.
workspace.id = generateId()
@ -238,9 +296,14 @@ class TSessionManager implements SessionManager {
workspace.upgrade = token.extra?.model === 'upgrade'
}
// Re-create pipeline.
workspace.pipeline = pipelineFactory(ctx, token.workspace, true, (tx, targets) => {
this.broadcastAll(workspace, tx, targets)
})
workspace.pipeline = pipelineFactory(
ctx,
{ ...token.workspace, workspaceUrl, workspaceName },
true,
(tx, targets) => {
this.broadcastAll(workspace, tx, targets)
}
)
return await workspace.pipeline
}
@ -271,20 +334,32 @@ class TSessionManager implements SessionManager {
send()
}
private createWorkspace (ctx: MeasureContext, pipelineFactory: PipelineFactory, token: Token): Workspace {
private createWorkspace (
ctx: MeasureContext,
pipelineFactory: PipelineFactory,
token: Token,
workspaceUrl: string,
workspaceName: string
): Workspace {
const upgrade = token.extra?.model === 'upgrade'
const context = ctx.newChild('🧲 ' + token.workspace.name, {})
const context = ctx.newChild('🧲 session', {})
const workspace: Workspace = {
context,
id: generateId(),
pipeline: pipelineFactory(context, token.workspace, upgrade, (tx, targets) => {
this.broadcastAll(workspace, tx, targets)
}),
pipeline: pipelineFactory(
context,
{ ...token.workspace, workspaceUrl, workspaceName },
upgrade,
(tx, targets) => {
this.broadcastAll(workspace, tx, targets)
}
),
sessions: new Map(),
upgrade
upgrade,
workspaceName
}
if (LOGGING_ENABLED) console.time(token.workspace.name)
if (LOGGING_ENABLED) console.timeLog(token.workspace.name, 'Creating Workspace:', workspace.id)
if (LOGGING_ENABLED) console.time(workspaceName)
if (LOGGING_ENABLED) console.timeLog(workspaceName, 'Creating Workspace:', workspace.id)
this.workspaces.set(toWorkspaceString(token.workspace), workspace)
return workspace
}
@ -498,7 +573,9 @@ class TSessionManager implements SessionManager {
msg: any,
workspace: string
): Promise<void> {
const userCtx = requestCtx.newChild('📞 client', {}) as SessionContext
const userCtx = requestCtx.newChild('📞 client', {
workspace: '🧲 ' + workspace
}) as SessionContext
userCtx.sessionId = service.sessionInstanceId ?? ''
// Calculate total number of clients
@ -668,6 +745,7 @@ export function start (
productId: string
serverFactory: ServerFactory
enableCompression?: boolean
accountsUrl: string
} & Partial<Timeouts>
): () => Promise<void> {
const sessions = new TSessionManager(ctx, opt.sessionFactory, {
@ -682,6 +760,7 @@ export function start (
opt.pipelineFactory,
opt.port,
opt.productId,
opt.enableCompression ?? true
opt.enableCompression ?? true,
opt.accountsUrl
)
}

View File

@ -13,20 +13,20 @@
// limitations under the License.
//
import { type MeasureContext, generateId } from '@hcengineering/core'
import { generateId, type MeasureContext } from '@hcengineering/core'
import { UNAUTHORIZED } from '@hcengineering/platform'
import { type Response, serialize } from '@hcengineering/rpc'
import { type Token, decodeToken } from '@hcengineering/server-token'
import { serialize, type Response } from '@hcengineering/rpc'
import { decodeToken, type Token } from '@hcengineering/server-token'
import compression from 'compression'
import cors from 'cors'
import express from 'express'
import http, { type IncomingMessage } from 'http'
import { type RawData, type WebSocket, WebSocketServer } from 'ws'
import { WebSocketServer, type RawData, type WebSocket } from 'ws'
import { getStatistics } from './stats'
import {
LOGGING_ENABLED,
type ConnectionSocket,
type HandleRequestFunction,
LOGGING_ENABLED,
type PipelineFactory,
type SessionManager
} from './types'
@ -44,7 +44,8 @@ export function startHttpServer (
pipelineFactory: PipelineFactory,
port: number,
productId: string,
enableCompression: boolean
enableCompression: boolean,
accountsUrl: string
): () => Promise<void> {
if (LOGGING_ENABLED) console.log(`starting server on port ${port} ...`)
@ -151,7 +152,13 @@ export function startHttpServer (
skipUTF8Validation: true
})
// eslint-disable-next-line @typescript-eslint/no-misused-promises
wss.on('connection', async (ws: WebSocket, request: IncomingMessage, token: Token, sessionId?: string) => {
const handleConnection = async (
ws: WebSocket,
request: IncomingMessage,
token: Token,
rawToken: string,
sessionId?: string
): Promise<void> => {
let buffer: Buffer[] | undefined = []
const data = {
@ -190,8 +197,20 @@ export function startHttpServer (
ws.on('message', (msg: Buffer) => {
buffer?.push(msg)
})
const session = await sessions.addSession(ctx, cs, token, pipelineFactory, productId, sessionId)
if ('upgrade' in session) {
const session = await sessions.addSession(
ctx,
cs,
token,
rawToken,
pipelineFactory,
productId,
sessionId,
accountsUrl
)
if ('upgrade' in session || 'error' in session) {
if ('error' in session) {
console.error(session.error)
}
cs.close()
return
}
@ -204,7 +223,7 @@ export function startHttpServer (
buff = Buffer.concat(msg).toString()
}
if (buff !== undefined) {
void handleRequest(session.context, session.session, cs, buff, token.workspace.name)
void handleRequest(session.context, session.session, cs, buff, session.workspaceName)
}
})
// eslint-disable-next-line @typescript-eslint/no-misused-promises
@ -218,9 +237,10 @@ export function startHttpServer (
const b = buffer
buffer = undefined
for (const msg of b) {
await handleRequest(session.context, session.session, cs, msg, token.workspace.name)
await handleRequest(session.context, session.session, cs, msg, session.workspaceName)
}
})
}
wss.on('connection', handleConnection as any)
httpServer.on('upgrade', (request: IncomingMessage, socket: any, head: Buffer) => {
const url = new URL('http://localhost' + (request.url ?? ''))
@ -234,7 +254,7 @@ export function startHttpServer (
throw new Error('Invalid workspace product')
}
wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws, request, payload, sessionId))
wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws, request, payload, token, sessionId))
} catch (err) {
if (LOGGING_ENABLED) console.error('invalid token', err)
wss.handleUpgrade(request, socket, head, (ws) => {

View File

@ -1,4 +1,5 @@
import {
type WorkspaceIdWithUrl,
type Class,
type Doc,
type DocumentQuery,
@ -78,7 +79,7 @@ export type BroadcastCall = (
*/
export type PipelineFactory = (
ctx: MeasureContext,
ws: WorkspaceId,
ws: WorkspaceIdWithUrl,
upgrade: boolean,
broadcast: BroadcastFunc
) => Promise<Pipeline>
@ -115,6 +116,8 @@ export interface Workspace {
sessions: Map<string, { session: Session, socket: ConnectionSocket }>
upgrade: boolean
closing?: Promise<void>
workspaceName: string
}
/**
@ -130,10 +133,14 @@ export interface SessionManager {
ctx: MeasureContext,
ws: ConnectionSocket,
token: Token,
rawToken: string,
pipelineFactory: PipelineFactory,
productId: string,
sessionId?: string
) => Promise<{ session: Session, context: MeasureContext } | { upgrade: true }>
sessionId: string | undefined,
accountsUrl: string
) => Promise<
{ session: Session, context: MeasureContext, workspaceName: string } | { upgrade: true } | { error: any }
>
broadcastAll: (workspace: Workspace, tx: Tx[], targets?: string[]) => void
@ -170,5 +177,6 @@ export type ServerFactory = (
pipelineFactory: PipelineFactory,
port: number,
productId: string,
enableCompression: boolean
enableCompression: boolean,
accountsUrl: string
) => () => Promise<void>

View File

@ -37,7 +37,6 @@ services:
links:
- mongodb
- minio
- transactor
ports:
- 3003:3003
environment:
@ -85,6 +84,7 @@ services:
- elastic
- minio
- rekoni
- account
ports:
- 3334:3334
environment:
@ -99,6 +99,7 @@ services:
- MINIO_SECRET_KEY=minioadmin
- REKONI_URL=http://rekoni:4005
- FRONT_URL=http://localhost:8083
- ACCOUNTS_URL=http://account:3003
collaborator:
image: hardcoreeng/collaborator
links:

View File

@ -7,10 +7,12 @@ docker compose -p sanity up -d --force-recreate --renew-anon-volumes
./wait-elastic.sh 9201
# Create workspace record in accounts
./tool.sh create-workspace sanity-ws -o SanityTest
./tool.sh create-workspace sanity-ws -w SanityTest
# Create user record in accounts
./tool.sh create-account user1 -f John -l Appleseed -p 1234
./tool.sh create-account user2 -f Kainin -l Dirak -p 1234
./tool.sh assign-workspace user1 sanity-ws
./tool.sh assign-workspace user2 sanity-ws
# Make user the workspace maintainer
./tool.sh set-user-role user1 sanity-ws 1
./tool.sh confirm-email user1

View File

@ -14,6 +14,7 @@ node ../dev/tool/bundle.js upgrade-workspace sanity-ws
# Re-assign user to workspace.
node ../dev/tool/bundle.js assign-workspace user1 sanity-ws
node ../dev/tool/bundle.js assign-workspace user2 sanity-ws
node ../dev/tool/bundle.js configure sanity-ws --enable=*
node ../dev/tool/bundle.js configure sanity-ws --list

View File

@ -17,6 +17,6 @@ test.describe('login test', () => {
await loginPage.login(PlatformUser, '1234')
const selectWorkspacePage = new SelectWorkspacePage(page)
await selectWorkspacePage.selectWorkspace('sanity-ws')
await selectWorkspacePage.selectWorkspace('SanityTest')
})
})