EZQMS-951: Server branding (#5858)

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
This commit is contained in:
Alexey Zinoviev 2024-06-20 17:13:57 +04:00 committed by GitHub
parent ed95c59859
commit dedff23b31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 395 additions and 148 deletions

View File

@ -285,7 +285,8 @@ export function devTool (
.requiredOption('-w, --workspaceName <workspaceName>', 'Workspace name') .requiredOption('-w, --workspaceName <workspaceName>', 'Workspace name')
.option('-e, --email <email>', 'Author email', 'platform@email.com') .option('-e, --email <email>', 'Author email', 'platform@email.com')
.option('-i, --init <ws>', 'Init from workspace') .option('-i, --init <ws>', 'Init from workspace')
.action(async (workspace, cmd: { email: string, workspaceName: string, init?: string }) => { .option('-b, --branding <key>', 'Branding key')
.action(async (workspace, cmd: { email: string, workspaceName: string, init?: string, branding?: string }) => {
const { mongodbUri, txes, version, migrateOperations } = prepareTools() const { mongodbUri, txes, version, migrateOperations } = prepareTools()
await withDatabase(mongodbUri, async (db) => { await withDatabase(mongodbUri, async (db) => {
await createWorkspace( await createWorkspace(
@ -295,7 +296,9 @@ export function devTool (
migrateOperations, migrateOperations,
db, db,
productId, productId,
cmd.init !== undefined ? { initWorkspace: cmd.init } : null, cmd.init !== undefined || cmd.branding !== undefined
? { initWorkspace: cmd.init, key: cmd.branding ?? 'huly' }
: null,
cmd.email, cmd.email,
cmd.workspaceName, cmd.workspaceName,
workspace workspace

View File

@ -661,6 +661,7 @@ export interface BaseWorkspaceInfo {
productId: string productId: string
disabled?: boolean disabled?: boolean
version?: Data<Version> version?: Data<Version>
branding?: string
workspaceUrl?: string | null // An optional url to the workspace, if not set workspace will be used workspaceUrl?: string | null // An optional url to the workspace, if not set workspace will be used
workspaceName?: string // An displayed workspace name workspaceName?: string // An displayed workspace name

View File

@ -67,3 +67,14 @@ export interface LowLevelStorage {
// Remove a list of documents. // Remove a list of documents.
clean: (ctx: MeasureContext, domain: Domain, docs: Ref<Doc>[]) => Promise<void> clean: (ctx: MeasureContext, domain: Domain, docs: Ref<Doc>[]) => Promise<void>
} }
export interface Branding {
key?: string
front?: string
title?: string
language?: string
initWorkspace?: string
lastNameFirst?: string
}
export type BrandingMap = Record<string, Branding>

View File

@ -19,7 +19,8 @@ import {
type MeasureContext, type MeasureContext,
type Ref, type Ref,
type WorkspaceId, type WorkspaceId,
type WorkspaceIdWithUrl type WorkspaceIdWithUrl,
type Branding
} from '@hcengineering/core' } from '@hcengineering/core'
import type { BlobLookup } from '@hcengineering/core/src/classes' import type { BlobLookup } from '@hcengineering/core/src/classes'
import { type Readable } from 'stream' import { type Readable } from 'stream'
@ -84,7 +85,12 @@ export interface StorageAdapter {
) => Promise<Readable> ) => Promise<Readable>
// Lookup will extend Blob with lookup information. // Lookup will extend Blob with lookup information.
lookup: (ctx: MeasureContext, workspaceId: WorkspaceIdWithUrl, docs: Blob[]) => Promise<BlobLookupResult> lookup: (
ctx: MeasureContext,
workspaceId: WorkspaceIdWithUrl,
branding: Branding | null,
docs: Blob[]
) => Promise<BlobLookupResult>
} }
export interface StorageAdapterEx extends StorageAdapter { export interface StorageAdapterEx extends StorageAdapter {
@ -174,7 +180,12 @@ export class DummyStorageAdapter implements StorageAdapter, StorageAdapterEx {
throw new Error('not implemented') throw new Error('not implemented')
} }
async lookup (ctx: MeasureContext, workspaceId: WorkspaceIdWithUrl, docs: Blob[]): Promise<BlobLookupResult> { async lookup (
ctx: MeasureContext,
workspaceId: WorkspaceIdWithUrl,
branding: Branding | null,
docs: Blob[]
): Promise<BlobLookupResult> {
return { lookups: [] } return { lookups: [] }
} }
} }

View File

@ -211,8 +211,10 @@ export function getLastName (name: string): string {
/** /**
* @public * @public
*/ */
export function formatName (name: string): string { export function formatName (name: string, lastNameFirst?: string): string {
return getMetadata(contactPlugin.metadata.LastNameFirst) === true const lastNameFirstCombined =
lastNameFirst !== undefined ? lastNameFirst === 'true' : getMetadata(contactPlugin.metadata.LastNameFirst) === true
return lastNameFirstCombined
? getLastName(name) + ' ' + getFirstName(name) ? getLastName(name) + ' ' + getFirstName(name)
: getFirstName(name) + ' ' + getLastName(name) : getFirstName(name) + ' ' + getLastName(name)
} }
@ -220,9 +222,9 @@ export function formatName (name: string): string {
/** /**
* @public * @public
*/ */
export function getName (hierarchy: Hierarchy, value: Contact): string { export function getName (hierarchy: Hierarchy, value: Contact, lastNameFirst?: string): string {
if (isPerson(hierarchy, value)) { if (isPerson(hierarchy, value)) {
return formatName(value.name) return formatName(value.name, lastNameFirst)
} }
return value.name return value.name
} }
@ -238,9 +240,14 @@ function isPersonClass (hierarchy: Hierarchy, _class: Ref<Class<Doc>>): boolean
/** /**
* @public * @public
*/ */
export function formatContactName (hierarchy: Hierarchy, _class: Ref<Class<Doc>>, name: string): string { export function formatContactName (
hierarchy: Hierarchy,
_class: Ref<Class<Doc>>,
name: string,
lastNameFirst?: string
): string {
if (isPersonClass(hierarchy, _class)) { if (isPersonClass(hierarchy, _class)) {
return formatName(name) return formatName(name, lastNameFirst)
} }
return name return name
} }

View File

@ -62,6 +62,7 @@
"@koa/cors": "^3.1.0", "@koa/cors": "^3.1.0",
"@hcengineering/server-tool": "^0.6.0", "@hcengineering/server-tool": "^0.6.0",
"@hcengineering/server-token": "^0.6.11", "@hcengineering/server-token": "^0.6.11",
"@hcengineering/server-core": "^0.6.1",
"@hcengineering/model-all": "^0.6.0" "@hcengineering/model-all": "^0.6.0"
} }
} }

View File

@ -13,8 +13,7 @@
// limitations under the License. // limitations under the License.
// //
import fs from 'fs' import { loadBrandingMap } from '@hcengineering/server-core'
import { type BrandingMap } from '@hcengineering/account'
import { serveAccount } from '@hcengineering/account-service' import { serveAccount } from '@hcengineering/account-service'
import { MeasureMetricsContext, newMetrics, type Tx } from '@hcengineering/core' import { MeasureMetricsContext, newMetrics, type Tx } from '@hcengineering/core'
import builder, { getModelVersion, migrateOperations } from '@hcengineering/model-all' import builder, { getModelVersion, migrateOperations } from '@hcengineering/model-all'
@ -28,9 +27,4 @@ const metricsContext = new MeasureMetricsContext('account', {}, {}, newMetrics()
const brandingPath = process.env.BRANDING_PATH const brandingPath = process.env.BRANDING_PATH
let brandings: BrandingMap = {} serveAccount(metricsContext, getModelVersion(), txes, migrateOperations, '', loadBrandingMap(brandingPath))
if (brandingPath !== undefined && brandingPath !== '') {
brandings = JSON.parse(fs.readFileSync(brandingPath, 'utf8'))
}
serveAccount(metricsContext, getModelVersion(), txes, migrateOperations, '', brandings)

View File

@ -1,9 +1,10 @@
import { joinWithProvider, loginWithProvider } from '@hcengineering/account' import { joinWithProvider, loginWithProvider } from '@hcengineering/account'
import { concatLink, MeasureContext } from '@hcengineering/core' import { BrandingMap, concatLink, MeasureContext } from '@hcengineering/core'
import Router from 'koa-router' import Router from 'koa-router'
import { Db } from 'mongodb' import { Db } from 'mongodb'
import { Strategy as GitHubStrategy } from 'passport-github2' import { Strategy as GitHubStrategy } from 'passport-github2'
import { Passport } from '.' import { Passport } from '.'
import { getBranding, getHost, safeParseAuthState } from './utils'
export function registerGithub ( export function registerGithub (
measureCtx: MeasureContext, measureCtx: MeasureContext,
@ -12,7 +13,8 @@ export function registerGithub (
accountsUrl: string, accountsUrl: string,
db: Db, db: Db,
productId: string, productId: string,
frontUrl: string frontUrl: string,
brandings: BrandingMap
): string | undefined { ): string | undefined {
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET
@ -34,21 +36,39 @@ export function registerGithub (
) )
router.get('/auth/github', async (ctx, next) => { router.get('/auth/github', async (ctx, next) => {
const state = ctx.query?.inviteId
measureCtx.info('try auth via', { provider: 'github' }) measureCtx.info('try auth via', { provider: 'github' })
const host = getHost(ctx.request.headers)
const branding = host !== undefined ? brandings[host]?.key ?? '' : ''
const state = encodeURIComponent(
JSON.stringify({
inviteId: ctx.query?.inviteId,
branding
})
)
passport.authenticate('github', { scope: ['user:email'], session: true, state })(ctx, next) passport.authenticate('github', { scope: ['user:email'], session: true, state })(ctx, next)
}) })
router.get( router.get(
redirectURL, redirectURL,
passport.authenticate('github', { failureRedirect: concatLink(frontUrl, '/login'), session: true }), async (ctx, next) => {
const state = safeParseAuthState(ctx.query?.state)
const branding = getBranding(brandings, state?.branding)
passport.authenticate('github', {
failureRedirect: concatLink(branding?.front ?? frontUrl, '/login'),
session: true
})(ctx, next)
},
async (ctx, next) => { async (ctx, next) => {
try { try {
const email = ctx.state.user.emails?.[0]?.value ?? `github:${ctx.state.user.username}` const email = ctx.state.user.emails?.[0]?.value ?? `github:${ctx.state.user.username}`
const [first, last] = ctx.state.user.displayName?.split(' ') ?? [ctx.state.user.username, ''] const [first, last] = ctx.state.user.displayName?.split(' ') ?? [ctx.state.user.username, '']
measureCtx.info('Provider auth handler', { email, type: 'github' }) measureCtx.info('Provider auth handler', { email, type: 'github' })
if (email !== undefined) { if (email !== undefined) {
if (ctx.query?.state != null) { const state = safeParseAuthState(ctx.query?.state)
const branding = getBranding(brandings, state?.branding)
if (state.inviteId != null && state.inviteId !== '') {
const loginInfo = await joinWithProvider( const loginInfo = await joinWithProvider(
measureCtx, measureCtx,
db, db,
@ -57,7 +77,7 @@ export function registerGithub (
email, email,
first, first,
last, last,
ctx.query.state, state.inviteId as any,
{ {
githubId: ctx.state.user.id githubId: ctx.state.user.id
} }
@ -75,7 +95,7 @@ export function registerGithub (
} }
measureCtx.info('Success auth, redirect', { email, type: 'github' }) measureCtx.info('Success auth, redirect', { email, type: 'github' })
// Successful authentication, redirect to your application // Successful authentication, redirect to your application
ctx.redirect(concatLink(frontUrl, '/login/auth')) ctx.redirect(concatLink(branding?.front ?? frontUrl, '/login/auth'))
} }
} catch (err: any) { } catch (err: any) {
measureCtx.error('failed to auth', { err, type: 'github', user: ctx.state?.user }) measureCtx.error('failed to auth', { err, type: 'github', user: ctx.state?.user })

View File

@ -1,9 +1,10 @@
import { joinWithProvider, loginWithProvider } from '@hcengineering/account' import { joinWithProvider, loginWithProvider } from '@hcengineering/account'
import { concatLink, MeasureContext } from '@hcengineering/core' import { BrandingMap, concatLink, MeasureContext } from '@hcengineering/core'
import Router from 'koa-router' import Router from 'koa-router'
import { Db } from 'mongodb' import { Db } from 'mongodb'
import { Strategy as GoogleStrategy } from 'passport-google-oauth20' import { Strategy as GoogleStrategy } from 'passport-google-oauth20'
import { Passport } from '.' import { Passport } from '.'
import { getBranding, getHost, safeParseAuthState } from './utils'
export function registerGoogle ( export function registerGoogle (
measureCtx: MeasureContext, measureCtx: MeasureContext,
@ -12,7 +13,8 @@ export function registerGoogle (
accountsUrl: string, accountsUrl: string,
db: Db, db: Db,
productId: string, productId: string,
frontUrl: string frontUrl: string,
brandings: BrandingMap
): string | undefined { ): string | undefined {
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET
@ -34,14 +36,30 @@ export function registerGoogle (
) )
router.get('/auth/google', async (ctx, next) => { router.get('/auth/google', async (ctx, next) => {
const state = ctx.query?.inviteId
measureCtx.info('try auth via', { provider: 'google' }) measureCtx.info('try auth via', { provider: 'google' })
const host = getHost(ctx.request.headers)
const branding = host !== undefined ? brandings[host]?.key ?? '' : ''
const state = encodeURIComponent(
JSON.stringify({
inviteId: ctx.query?.inviteId,
branding
})
)
passport.authenticate('google', { scope: ['profile', 'email'], session: true, state })(ctx, next) passport.authenticate('google', { scope: ['profile', 'email'], session: true, state })(ctx, next)
}) })
router.get( router.get(
redirectURL, redirectURL,
passport.authenticate('google', { failureRedirect: concatLink(frontUrl, '/login'), session: true }), async (ctx, next) => {
const state = safeParseAuthState(ctx.query?.state)
const branding = getBranding(brandings, state?.branding)
passport.authenticate('google', {
failureRedirect: concatLink(branding?.front ?? frontUrl, '/login'),
session: true
})(ctx, next)
},
async (ctx, next) => { async (ctx, next) => {
const email = ctx.state.user.emails?.[0]?.value const email = ctx.state.user.emails?.[0]?.value
const first = ctx.state.user.name.givenName const first = ctx.state.user.name.givenName
@ -49,7 +67,9 @@ export function registerGoogle (
measureCtx.info('Provider auth handler', { email, type: 'google' }) measureCtx.info('Provider auth handler', { email, type: 'google' })
if (email !== undefined) { if (email !== undefined) {
try { try {
if (ctx.query?.state != null) { const state = safeParseAuthState(ctx.query?.state)
const branding = getBranding(brandings, state?.branding)
if (state.inviteId != null && state.inviteId !== '') {
const loginInfo = await joinWithProvider( const loginInfo = await joinWithProvider(
measureCtx, measureCtx,
db, db,
@ -58,7 +78,7 @@ export function registerGoogle (
email, email,
first, first,
last, last,
ctx.query.state state.inviteId as any
) )
if (ctx.session != null) { if (ctx.session != null) {
ctx.session.loginInfo = loginInfo ctx.session.loginInfo = loginInfo
@ -72,7 +92,7 @@ export function registerGoogle (
// Successful authentication, redirect to your application // Successful authentication, redirect to your application
measureCtx.info('Success auth, redirect', { email, type: 'google' }) measureCtx.info('Success auth, redirect', { email, type: 'google' })
ctx.redirect(concatLink(frontUrl, '/login/auth')) ctx.redirect(concatLink(branding?.front ?? frontUrl, '/login/auth'))
} catch (err: any) { } catch (err: any) {
measureCtx.error('failed to auth', { err, type: 'google', user: ctx.state?.user }) measureCtx.error('failed to auth', { err, type: 'google', user: ctx.state?.user })
} }

View File

@ -5,7 +5,7 @@ import session from 'koa-session'
import { Db } from 'mongodb' import { Db } from 'mongodb'
import { registerGithub } from './github' import { registerGithub } from './github'
import { registerGoogle } from './google' import { registerGoogle } from './google'
import { MeasureContext } from '@hcengineering/core' import { BrandingMap, MeasureContext } from '@hcengineering/core'
export type Passport = typeof passport export type Passport = typeof passport
@ -16,7 +16,8 @@ export type AuthProvider = (
accountsUrl: string, accountsUrl: string,
db: Db, db: Db,
productId: string, productId: string,
frontUrl: string frontUrl: string,
brandings: BrandingMap
) => string | undefined ) => string | undefined
export function registerProviders ( export function registerProviders (
@ -26,7 +27,8 @@ export function registerProviders (
db: Db, db: Db,
productId: string, productId: string,
serverSecret: string, serverSecret: string,
frontUrl: string | undefined frontUrl: string | undefined,
brandings: BrandingMap
): void { ): void {
const accountsUrl = process.env.ACCOUNTS_URL const accountsUrl = process.env.ACCOUNTS_URL
if (accountsUrl === undefined) { if (accountsUrl === undefined) {
@ -60,7 +62,7 @@ export function registerProviders (
const res: string[] = [] const res: string[] = []
const providers: AuthProvider[] = [registerGoogle, registerGithub] const providers: AuthProvider[] = [registerGoogle, registerGithub]
for (const provider of providers) { for (const provider of providers) {
const value = provider(ctx, passport, router, accountsUrl, db, productId, frontUrl) const value = provider(ctx, passport, router, accountsUrl, db, productId, frontUrl, brandings)
if (value !== undefined) res.push(value) if (value !== undefined) res.push(value)
} }

View File

@ -0,0 +1,49 @@
//
// Copyright © 2024 Hardcore Engineering, Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { Branding, BrandingMap } from '@hcengineering/core'
import { IncomingHttpHeaders } from 'http'
export function getHost (headers: IncomingHttpHeaders): string | undefined {
let host: string | undefined
const origin = headers.origin ?? headers.referer
if (origin !== undefined) {
host = new URL(origin).host
}
return host
}
export function getBranding (brandings: BrandingMap, key: string | undefined): Branding | null {
if (key === undefined) return null
return Object.values(brandings).find((branding) => branding.key === key) ?? null
}
export interface AuthState {
inviteId?: string
branding?: string
}
export function safeParseAuthState (rawState: string | undefined): AuthState {
if (rawState == null) {
return {}
}
try {
return JSON.parse(decodeURIComponent(rawState))
} catch {
return {}
}
}

View File

@ -20,7 +20,7 @@ import notification from '@hcengineering/notification'
import { setMetadata } from '@hcengineering/platform' import { setMetadata } from '@hcengineering/platform'
import { serverConfigFromEnv } from '@hcengineering/server' import { serverConfigFromEnv } from '@hcengineering/server'
import { storageConfigFromEnv } from '@hcengineering/server-storage' import { storageConfigFromEnv } from '@hcengineering/server-storage'
import serverCore, { type StorageConfiguration } from '@hcengineering/server-core' import serverCore, { type StorageConfiguration, loadBrandingMap } from '@hcengineering/server-core'
import serverNotification from '@hcengineering/server-notification' import serverNotification from '@hcengineering/server-notification'
import serverToken from '@hcengineering/server-token' import serverToken from '@hcengineering/server-token'
import { start } from '.' import { start } from '.'
@ -61,6 +61,7 @@ const shutdown = start(config.url, {
indexParallel: 2, indexParallel: 2,
indexProcessing: 50, indexProcessing: 50,
productId: '', productId: '',
brandingMap: loadBrandingMap(config.brandingPath),
enableCompression: config.enableCompression, enableCompression: config.enableCompression,
accountsUrl: config.accountsUrl accountsUrl: config.accountsUrl
}) })

View File

@ -22,7 +22,9 @@ import {
DOMAIN_TRANSIENT, DOMAIN_TRANSIENT,
DOMAIN_TX, DOMAIN_TX,
type MeasureContext, type MeasureContext,
type WorkspaceId type WorkspaceId,
type BrandingMap,
type Branding
} from '@hcengineering/core' } from '@hcengineering/core'
import { createElasticAdapter, createElasticBackupDataAdapter } from '@hcengineering/elastic' import { createElasticAdapter, createElasticBackupDataAdapter } from '@hcengineering/elastic'
import { import {
@ -47,6 +49,7 @@ import {
createYDocAdapter, createYDocAdapter,
getMetricsContext getMetricsContext
} from '@hcengineering/server' } from '@hcengineering/server'
import { serverActivityId } from '@hcengineering/server-activity' import { serverActivityId } from '@hcengineering/server-activity'
import { serverAttachmentId } from '@hcengineering/server-attachment' import { serverAttachmentId } from '@hcengineering/server-attachment'
import { serverCalendarId } from '@hcengineering/server-calendar' import { serverCalendarId } from '@hcengineering/server-calendar'
@ -212,6 +215,7 @@ export function start (
rekoniUrl: string rekoniUrl: string
port: number port: number
productId: string productId: string
brandingMap: BrandingMap
serverFactory: ServerFactory serverFactory: ServerFactory
indexProcessing: number // 1000 indexProcessing: number // 1000
@ -267,6 +271,7 @@ export function start (
function createIndexStages ( function createIndexStages (
fullText: MeasureContext, fullText: MeasureContext,
workspace: WorkspaceId, workspace: WorkspaceId,
branding: Branding | null,
adapter: FullTextAdapter, adapter: FullTextAdapter,
storage: ServerStorage, storage: ServerStorage,
storageAdapter: StorageAdapter, storageAdapter: StorageAdapter,
@ -309,7 +314,7 @@ export function start (
stages.push(summaryStage) stages.push(summaryStage)
// Push all content to elastic search // Push all content to elastic search
const pushStage = new FullTextPushStage(storage, adapter, workspace) const pushStage = new FullTextPushStage(storage, adapter, workspace, branding)
stages.push(pushStage) stages.push(pushStage)
// OpenAI prepare stage // OpenAI prepare stage
@ -324,7 +329,7 @@ export function start (
return stages return stages
} }
const pipelineFactory: PipelineFactory = (ctx, workspace, upgrade, broadcast) => { const pipelineFactory: PipelineFactory = (ctx, workspace, upgrade, broadcast, branding) => {
const wsMetrics = metrics.newChild('🧲 session', {}) const wsMetrics = metrics.newChild('🧲 session', {})
const conf: DbConfiguration = { const conf: DbConfiguration = {
domains: { domains: {
@ -369,6 +374,7 @@ export function start (
createIndexStages( createIndexStages(
wsMetrics.newChild('stages', {}), wsMetrics.newChild('stages', {}),
workspace, workspace,
branding,
adapter, adapter,
storage, storage,
storageAdapter, storageAdapter,
@ -392,14 +398,14 @@ export function start (
storageFactory: externalStorage, storageFactory: externalStorage,
workspace workspace
} }
return createPipeline(ctx, conf, middlewares, upgrade, broadcast) return createPipeline(ctx, conf, middlewares, upgrade, broadcast, branding)
} }
const sessionFactory = (token: Token, pipeline: Pipeline): Session => { const sessionFactory = (token: Token, pipeline: Pipeline): Session => {
if (token.extra?.mode === 'backup') { if (token.extra?.mode === 'backup') {
return new BackupClientSession(token, pipeline) return new BackupClientSession(token, pipeline, opt.brandingMap)
} }
return new ClientSession(token, pipeline) return new ClientSession(token, pipeline, opt.brandingMap)
} }
const onClose = startJsonRpc(getMetricsContext(), { const onClose = startJsonRpc(getMetricsContext(), {
@ -407,6 +413,7 @@ export function start (
sessionFactory, sessionFactory,
port: opt.port, port: opt.port,
productId: opt.productId, productId: opt.productId,
brandingMap: opt.brandingMap,
serverFactory: opt.serverFactory, serverFactory: opt.serverFactory,
enableCompression: opt.enableCompression, enableCompression: opt.enableCompression,
accountsUrl: opt.accountsUrl, accountsUrl: opt.accountsUrl,

View File

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

View File

@ -202,10 +202,10 @@ export async function OnChannelUpdate (tx: Tx, control: TriggerControl): Promise
*/ */
export async function personHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> { export async function personHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> {
const person = doc as Person const person = doc as Person
const front = getMetadata(serverCore.metadata.FrontUrl) ?? '' const front = control.branding?.front ?? getMetadata(serverCore.metadata.FrontUrl) ?? ''
const path = `${workbenchId}/${control.workspace.workspaceUrl}/${contactId}/${doc._id}` const path = `${workbenchId}/${control.workspace.workspaceUrl}/${contactId}/${doc._id}`
const link = concatLink(front, path) const link = concatLink(front, path)
return `<a href="${link}">${getName(control.hierarchy, person)}</a>` return `<a href="${link}">${getName(control.hierarchy, person, control.branding?.lastNameFirst)}</a>`
} }
/** /**
@ -213,7 +213,7 @@ export async function personHTMLPresenter (doc: Doc, control: TriggerControl): P
*/ */
export function personTextPresenter (doc: Doc, control: TriggerControl): string { export function personTextPresenter (doc: Doc, control: TriggerControl): string {
const person = doc as Person const person = doc as Person
return `${getName(control.hierarchy, person)}` return `${getName(control.hierarchy, person, control.branding?.lastNameFirst)}`
} }
/** /**
@ -221,7 +221,7 @@ export function personTextPresenter (doc: Doc, control: TriggerControl): string
*/ */
export async function organizationHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> { export async function organizationHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> {
const organization = doc as Organization const organization = doc as Organization
const front = getMetadata(serverCore.metadata.FrontUrl) ?? '' const front = control.branding?.front ?? getMetadata(serverCore.metadata.FrontUrl) ?? ''
const path = `${workbenchId}/${control.workspace.workspaceUrl}/${contactId}/${doc._id}` const path = `${workbenchId}/${control.workspace.workspaceUrl}/${contactId}/${doc._id}`
const link = concatLink(front, path) const link = concatLink(front, path)
return `<a href="${link}">${organization.name}</a>` return `<a href="${link}">${organization.name}</a>`
@ -240,7 +240,7 @@ export function organizationTextPresenter (doc: Doc): string {
*/ */
export function contactNameProvider (hierarchy: Hierarchy, props: Record<string, string>): string { export function contactNameProvider (hierarchy: Hierarchy, props: Record<string, string>): string {
const _class = props._class !== undefined ? (props._class as Ref<Class<Doc>>) : contact.class.Contact const _class = props._class !== undefined ? (props._class as Ref<Class<Doc>>) : contact.class.Contact
return formatContactName(hierarchy, _class, props.name ?? '') return formatContactName(hierarchy, _class, props.name ?? '', props.lastNameFirst)
} }
export async function getCurrentEmployeeName (control: TriggerControl, context: Record<string, Doc>): Promise<string> { export async function getCurrentEmployeeName (control: TriggerControl, context: Record<string, Doc>): Promise<string> {
@ -249,7 +249,7 @@ export async function getCurrentEmployeeName (control: TriggerControl, context:
}) })
if (account === undefined) return '' if (account === undefined) return ''
const employee = (await control.findAll(contact.class.Person, { _id: account.person }))[0] const employee = (await control.findAll(contact.class.Person, { _id: account.person }))[0]
return employee !== undefined ? formatName(employee.name) : '' return employee !== undefined ? formatName(employee.name, control.branding?.lastNameFirst) : ''
} }
export async function getCurrentEmployeeEmail (control: TriggerControl, context: Record<string, Doc>): Promise<string> { export async function getCurrentEmployeeEmail (control: TriggerControl, context: Record<string, Doc>): Promise<string> {
@ -281,7 +281,7 @@ export async function getContactName (
const value = context[contact.class.Contact] as Contact const value = context[contact.class.Contact] as Contact
if (value === undefined) return if (value === undefined) return
if (control.hierarchy.isDerived(value._class, contact.class.Person)) { if (control.hierarchy.isDerived(value._class, contact.class.Person)) {
return getName(control.hierarchy, value) return getName(control.hierarchy, value, control.branding?.lastNameFirst)
} else { } else {
return value.name return value.name
} }

View File

@ -20,7 +20,7 @@ function getDocumentId (doc: Document): string {
*/ */
export async function documentHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> { export async function documentHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> {
const document = doc as Document const document = doc as Document
const front = getMetadata(serverCore.metadata.FrontUrl) ?? '' const front = control.branding?.front ?? getMetadata(serverCore.metadata.FrontUrl) ?? ''
const path = `${workbenchId}/${control.workspace.workspaceUrl}/${documentId}/${getDocumentId(document)}` const path = `${workbenchId}/${control.workspace.workspaceUrl}/${documentId}/${getDocumentId(document)}`
const link = concatLink(front, path) const link = concatLink(front, path)
return `<a href="${link}">${document.name}</a>` return `<a href="${link}">${document.name}</a>`

View File

@ -14,6 +14,7 @@
// //
import { import {
Branding,
Doc, Doc,
Hierarchy, Hierarchy,
Ref, Ref,
@ -46,7 +47,7 @@ export async function OnPublicLinkCreate (tx: Tx, control: TriggerControl): Prom
if (link.url !== '') return res if (link.url !== '') return res
const resTx = control.txFactory.createTxUpdateDoc(link._class, link.space, link._id, { const resTx = control.txFactory.createTxUpdateDoc(link._class, link.space, link._id, {
url: generateUrl(link._id, control.workspace) url: generateUrl(link._id, control.workspace, control.branding?.front)
}) })
res.push(resTx) res.push(resTx)
@ -54,22 +55,23 @@ export async function OnPublicLinkCreate (tx: Tx, control: TriggerControl): Prom
return res return res
} }
export function getPublicLinkUrl (workspace: WorkspaceIdWithUrl): string { export function getPublicLinkUrl (workspace: WorkspaceIdWithUrl, brandedFront?: string): string {
const front = getMetadata(serverCore.metadata.FrontUrl) ?? '' const front = brandedFront ?? getMetadata(serverCore.metadata.FrontUrl) ?? ''
const path = `${guestId}/${workspace.workspaceUrl}` const path = `${guestId}/${workspace.workspaceUrl}`
return concatLink(front, path) return concatLink(front, path)
} }
function generateUrl (linkId: Ref<PublicLink>, workspace: WorkspaceIdWithUrl): string { function generateUrl (linkId: Ref<PublicLink>, workspace: WorkspaceIdWithUrl, brandedFront?: string): string {
const token = generateToken(guestAccountEmail, workspace, { linkId, guest: 'true' }) const token = generateToken(guestAccountEmail, workspace, { linkId, guest: 'true' })
return `${getPublicLinkUrl(workspace)}?token=${token}` return `${getPublicLinkUrl(workspace, brandedFront)}?token=${token}`
} }
export async function getPublicLink ( export async function getPublicLink (
doc: Doc, doc: Doc,
client: TxOperations, client: TxOperations,
workspace: WorkspaceIdWithUrl, workspace: WorkspaceIdWithUrl,
revokable: boolean = true revokable: boolean = true,
branding: Branding | null
): Promise<string> { ): Promise<string> {
const current = await client.findOne(guest.class.PublicLink, { attachedTo: doc._id }) const current = await client.findOne(guest.class.PublicLink, { attachedTo: doc._id })
if (current !== undefined) { if (current !== undefined) {
@ -79,7 +81,7 @@ export async function getPublicLink (
return current.url return current.url
} }
const id = generateId<PublicLink>() const id = generateId<PublicLink>()
const url = generateUrl(id, workspace) const url = generateUrl(id, workspace, branding?.front)
const fragment = getDocFragment(doc, client) const fragment = getDocFragment(doc, client)
await client.createDoc( await client.createDoc(
guest.class.PublicLink, guest.class.PublicLink,

View File

@ -278,7 +278,7 @@ async function sendEmailNotifications (
const senderPerson = (await control.findAll(contact.class.Person, { _id: sender.person }))[0] const senderPerson = (await control.findAll(contact.class.Person, { _id: sender.person }))[0]
const senderName = senderPerson !== undefined ? formatName(senderPerson.name) : '' const senderName = senderPerson !== undefined ? formatName(senderPerson.name, control.branding?.lastNameFirst) : ''
const content = await getContent(doc, senderName, type, control, '') const content = await getContent(doc, senderName, type, control, '')
if (content === undefined) return if (content === undefined) return
@ -340,7 +340,7 @@ export async function OnRequestRemove (tx: Tx, control: TriggerControl): Promise
export async function RequestHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> { export async function RequestHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> {
const request = doc as Request const request = doc as Request
const employee = (await control.findAll(contact.mixin.Employee, { _id: request.attachedTo }))[0] const employee = (await control.findAll(contact.mixin.Employee, { _id: request.attachedTo }))[0]
const who = getName(control.hierarchy, employee) const who = getName(control.hierarchy, employee, control.branding?.lastNameFirst)
const type = await translate(control.modelDb.getObject(request.type).label, {}) const type = await translate(control.modelDb.getObject(request.type).label, {})
const date = tzDateEqual(request.tzDate, request.tzDueDate) const date = tzDateEqual(request.tzDate, request.tzDueDate)
@ -358,7 +358,7 @@ export async function RequestHTMLPresenter (doc: Doc, control: TriggerControl):
export async function RequestTextPresenter (doc: Doc, control: TriggerControl): Promise<string> { export async function RequestTextPresenter (doc: Doc, control: TriggerControl): Promise<string> {
const request = doc as Request const request = doc as Request
const employee = (await control.findAll(contact.mixin.Employee, { _id: request.attachedTo }))[0] const employee = (await control.findAll(contact.mixin.Employee, { _id: request.attachedTo }))[0]
const who = getName(control.hierarchy, employee) const who = getName(control.hierarchy, employee, control.branding?.lastNameFirst)
const type = await translate(control.modelDb.getObject(request.type).label, {}) const type = await translate(control.modelDb.getObject(request.type).label, {})
const date = tzDateEqual(request.tzDate, request.tzDueDate) const date = tzDateEqual(request.tzDate, request.tzDueDate)
@ -401,7 +401,7 @@ export async function PublicHolidayHTMLPresenter (doc: Doc, control: TriggerCont
if (sender === undefined) return '' if (sender === undefined) return ''
const employee = await getEmployee(sender.person as Ref<Employee>, control) const employee = await getEmployee(sender.person as Ref<Employee>, control)
if (employee === undefined) return '' if (employee === undefined) return ''
const who = formatName(employee.name) const who = formatName(employee.name, control.branding?.lastNameFirst)
const date = `on ${new Date(fromTzDate(holiday.date)).toLocaleDateString()}` const date = `on ${new Date(fromTzDate(holiday.date)).toLocaleDateString()}`
@ -417,7 +417,7 @@ export async function PublicHolidayTextPresenter (doc: Doc, control: TriggerCont
if (sender === undefined) return '' if (sender === undefined) return ''
const employee = await getEmployee(sender.person as Ref<Employee>, control) const employee = await getEmployee(sender.person as Ref<Employee>, control)
if (employee === undefined) return '' if (employee === undefined) return ''
const who = formatName(employee.name) const who = formatName(employee.name, control.branding?.lastNameFirst)
const date = `on ${new Date(fromTzDate(holiday.date)).toLocaleDateString()}` const date = `on ${new Date(fromTzDate(holiday.date)).toLocaleDateString()}`

View File

@ -25,7 +25,7 @@ import { workbenchId } from '@hcengineering/workbench'
*/ */
export async function productHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> { export async function productHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> {
const product = doc as Product const product = doc as Product
const front = getMetadata(serverCore.metadata.FrontUrl) ?? '' const front = control.branding?.front ?? getMetadata(serverCore.metadata.FrontUrl) ?? ''
const path = `${workbenchId}/${control.workspace.workspaceUrl}/${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) const link = concatLink(front, path)
return `<a href="${link}">${product.name}</a>` 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> { export async function leadHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> {
const lead = doc as Lead const lead = doc as Lead
const front = getMetadata(serverCore.metadata.FrontUrl) ?? '' const front = control.branding?.front ?? getMetadata(serverCore.metadata.FrontUrl) ?? ''
const path = `${workbenchId}/${control.workspace.workspaceUrl}/${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) const link = concatLink(front, path)
return `<a href="${link}">${lead.title}</a>` return `<a href="${link}">${lead.title}</a>`

View File

@ -82,7 +82,7 @@ async function createUserInfo (acc: Ref<Account>, control: TriggerControl): Prom
const room = (await control.findAll(love.class.Office, { person: personId }))[0] const room = (await control.findAll(love.class.Office, { person: personId }))[0]
const tx = control.txFactory.createTxCreateDoc(love.class.ParticipantInfo, love.space.Rooms, { const tx = control.txFactory.createTxCreateDoc(love.class.ParticipantInfo, love.space.Rooms, {
person: personId, person: personId,
name: person !== undefined ? getName(control.hierarchy, person) : account.email, name: person !== undefined ? getName(control.hierarchy, person, control.branding?.lastNameFirst) : account.email,
room: room?._id ?? love.ids.Reception, room: room?._id ?? love.ids.Reception,
x: 0, x: 0,
y: 0 y: 0
@ -266,7 +266,9 @@ export async function OnKnock (tx: Tx, control: TriggerControl): Promise<Tx[]> {
) { ) {
const path = [workbenchId, control.workspace.workspaceUrl, loveId] const path = [workbenchId, control.workspace.workspaceUrl, loveId]
const title = await translate(love.string.KnockingLabel, {}) const title = await translate(love.string.KnockingLabel, {})
const body = await translate(love.string.IsKnocking, { name: formatName(from.name) }) const body = await translate(love.string.IsKnocking, {
name: formatName(from.name, control.branding?.lastNameFirst)
})
await createPushNotification(control, userAcc._id, title, body, request._id, from, path) await createPushNotification(control, userAcc._id, title, body, request._id, from, path)
} }
} }
@ -294,7 +296,9 @@ export async function OnInvite (tx: Tx, control: TriggerControl): Promise<Tx[]>
const title = await translate(love.string.InivitingLabel, {}) const title = await translate(love.string.InivitingLabel, {})
const body = const body =
from !== undefined from !== undefined
? await translate(love.string.InvitingYou, { name: formatName(from.name) }) ? await translate(love.string.InvitingYou, {
name: formatName(from.name, control.branding?.lastNameFirst)
})
: await translate(love.string.InivitingLabel, {}) : await translate(love.string.InivitingLabel, {})
await createPushNotification(control, userAcc._id, title, body, invite._id, from, path) await createPushNotification(control, userAcc._id, title, body, invite._id, from, path)
} }

View File

@ -229,7 +229,7 @@ async function notifyByEmail (
if (sender !== undefined) { if (sender !== undefined) {
const senderPerson = (await control.findAll(contact.class.Person, { _id: sender.person }))[0] const senderPerson = (await control.findAll(contact.class.Person, { _id: sender.person }))[0]
senderName = senderPerson !== undefined ? formatName(senderPerson.name) : '' senderName = senderPerson !== undefined ? formatName(senderPerson.name, control.branding?.lastNameFirst) : ''
} }
const content = await getContent(doc, senderName, type, control, data) const content = await getContent(doc, senderName, type, control, data)
@ -611,7 +611,7 @@ export async function createPushNotification (
if (_id !== undefined) { if (_id !== undefined) {
data.tag = _id data.tag = _id
} }
const front = getMetadata(serverCore.metadata.FrontUrl) ?? '' const front = control.branding?.front ?? getMetadata(serverCore.metadata.FrontUrl) ?? ''
const uploadUrl = getMetadata(serverCore.metadata.UploadURL) ?? '' const uploadUrl = getMetadata(serverCore.metadata.UploadURL) ?? ''
const domainPath = `${workbenchId}/${control.workspace.workspaceUrl}` const domainPath = `${workbenchId}/${control.workspace.workspaceUrl}`
const domain = concatLink(front, domainPath) const domain = concatLink(front, domainPath)

View File

@ -327,7 +327,7 @@ async function getFallbackNotificationFullfillment (
if (account !== undefined) { if (account !== undefined) {
const senderPerson = (cache.get(account.person) as Person) ?? (await findPersonForAccount(control, account.person)) const senderPerson = (cache.get(account.person) as Person) ?? (await findPersonForAccount(control, account.person))
if (senderPerson !== undefined) { if (senderPerson !== undefined) {
intlParams.senderName = formatName(senderPerson.name) intlParams.senderName = formatName(senderPerson.name, control.branding?.lastNameFirst)
cache.set(senderPerson._id, senderPerson) cache.set(senderPerson._id, senderPerson)
} }
} }

View File

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

View File

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

View File

@ -26,7 +26,7 @@ export const TrainingRequestHTMLPresenter: Presenter<TrainingRequest> = async (
request: TrainingRequest, request: TrainingRequest,
control: TriggerControl control: TriggerControl
) => { ) => {
const front = getMetadata(serverCore.metadata.FrontUrl) ?? '' const front = control.branding?.front ?? getMetadata(serverCore.metadata.FrontUrl) ?? ''
// TODO: Don't hardcode URLs, find a way to share routes info between front and server resources, and DRY // TODO: Don't hardcode URLs, find a way to share routes info between front and server resources, and DRY
const path = `${workbenchId}/${control.workspace.workspaceUrl}/${trainingId}/requests/${request._id}` const path = `${workbenchId}/${control.workspace.workspaceUrl}/${trainingId}/requests/${request._id}`
const link = concatLink(front, path) const link = concatLink(front, path)

View File

@ -7,14 +7,13 @@ import account, {
UpgradeWorker, UpgradeWorker,
accountId, accountId,
cleanInProgressWorkspaces, cleanInProgressWorkspaces,
getMethods, getMethods
type BrandingMap
} from '@hcengineering/account' } from '@hcengineering/account'
import accountEn from '@hcengineering/account/lang/en.json' import accountEn from '@hcengineering/account/lang/en.json'
import accountRu from '@hcengineering/account/lang/ru.json' import accountRu from '@hcengineering/account/lang/ru.json'
import { Analytics } from '@hcengineering/analytics' import { Analytics } from '@hcengineering/analytics'
import { registerProviders } from '@hcengineering/auth-providers' import { registerProviders } from '@hcengineering/auth-providers'
import { type Data, type MeasureContext, type Tx, type Version } from '@hcengineering/core' import { type Data, type MeasureContext, type Tx, type Version, type BrandingMap } from '@hcengineering/core'
import { type MigrateOperation } from '@hcengineering/model' import { type MigrateOperation } from '@hcengineering/model'
import platform, { Severity, Status, addStringsLoader, setMetadata } from '@hcengineering/platform' import platform, { Severity, Status, addStringsLoader, setMetadata } from '@hcengineering/platform'
import serverToken from '@hcengineering/server-token' import serverToken from '@hcengineering/server-token'
@ -101,7 +100,7 @@ export function serveAccount (
void client.then(async (p: MongoClient) => { void client.then(async (p: MongoClient) => {
const db = p.db(ACCOUNT_DB) const db = p.db(ACCOUNT_DB)
registerProviders(measureCtx, app, router, db, productId, serverSecret, frontURL) registerProviders(measureCtx, app, router, db, productId, serverSecret, frontURL, brandings)
// We need to clean workspace with creating === true, since server is restarted. // We need to clean workspace with creating === true, since server is restarted.
void cleanInProgressWorkspaces(db, productId) void cleanInProgressWorkspaces(db, productId)

View File

@ -42,7 +42,8 @@ import core, {
TxOperations, TxOperations,
Version, Version,
versionToString, versionToString,
WorkspaceId WorkspaceId,
type Branding
} from '@hcengineering/core' } from '@hcengineering/core'
import { consoleModelLogger, MigrateOperation, ModelLogger } from '@hcengineering/model' import { consoleModelLogger, MigrateOperation, ModelLogger } from '@hcengineering/model'
import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform' import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform'
@ -513,7 +514,7 @@ async function sendConfirmation (productId: string, branding: Branding | null, a
console.info('Please provide email service url to enable email confirmations.') console.info('Please provide email service url to enable email confirmations.')
return return
} }
const front = getMetadata(accountPlugin.metadata.FrontURL) const front = branding?.front ?? getMetadata(accountPlugin.metadata.FrontURL)
if (front === undefined || front === '') { if (front === undefined || front === '') {
throw new Error('Please provide front url') throw new Error('Please provide front url')
} }
@ -806,10 +807,12 @@ async function generateWorkspaceRecord (
email: string, email: string,
productId: string, productId: string,
version: Data<Version>, version: Data<Version>,
branding: Branding | null,
workspaceName: string, workspaceName: string,
fixedWorkspace?: string fixedWorkspace?: string
): Promise<Workspace> { ): Promise<Workspace> {
const coll = db.collection<Omit<Workspace, '_id'>>(WORKSPACE_COLLECTION) const coll = db.collection<Omit<Workspace, '_id'>>(WORKSPACE_COLLECTION)
const brandingKey = branding?.key ?? 'huly'
if (fixedWorkspace !== undefined) { if (fixedWorkspace !== undefined) {
const ws = await coll.find<Workspace>({ workspaceUrl: fixedWorkspace }).toArray() const ws = await coll.find<Workspace>({ workspaceUrl: fixedWorkspace }).toArray()
if ((await getWorkspaceById(db, productId, fixedWorkspace)) !== null || ws.length > 0) { if ((await getWorkspaceById(db, productId, fixedWorkspace)) !== null || ws.length > 0) {
@ -822,6 +825,7 @@ async function generateWorkspaceRecord (
workspaceUrl: fixedWorkspace, workspaceUrl: fixedWorkspace,
productId, productId,
version, version,
branding: brandingKey,
workspaceName, workspaceName,
accounts: [], accounts: [],
disabled: true, disabled: true,
@ -854,6 +858,7 @@ async function generateWorkspaceRecord (
workspaceUrl, workspaceUrl,
productId, productId,
version, version,
branding: brandingKey,
workspaceName, workspaceName,
accounts: [], accounts: [],
disabled: true, disabled: true,
@ -909,7 +914,7 @@ export async function createWorkspace (
await searchPromise await searchPromise
// Safe generate workspace record. // Safe generate workspace record.
searchPromise = generateWorkspaceRecord(db, email, productId, version, workspaceName, workspace) searchPromise = generateWorkspaceRecord(db, email, productId, version, branding, workspaceName, workspace)
const workspaceInfo = await searchPromise const workspaceInfo = await searchPromise
@ -1670,7 +1675,7 @@ export async function requestPassword (
if (sesURL === undefined || sesURL === '') { if (sesURL === undefined || sesURL === '') {
throw new Error('Please provide email service url') throw new Error('Please provide email service url')
} }
const front = getMetadata(accountPlugin.metadata.FrontURL) const front = branding?.front ?? getMetadata(accountPlugin.metadata.FrontURL)
if (front === undefined || front === '') { if (front === undefined || front === '') {
throw new Error('Please provide front url') throw new Error('Please provide front url')
} }
@ -1936,7 +1941,7 @@ export async function sendInvite (
if (sesURL === undefined || sesURL === '') { if (sesURL === undefined || sesURL === '') {
throw new Error('Please provide email service url') throw new Error('Please provide email service url')
} }
const front = getMetadata(accountPlugin.metadata.FrontURL) const front = branding?.front ?? getMetadata(accountPlugin.metadata.FrontURL)
if (front === undefined || front === '') { if (front === undefined || front === '') {
throw new Error('Please provide front url') throw new Error('Please provide front url')
} }
@ -1995,14 +2000,6 @@ async function deactivatePersonAccount (
} }
} }
export interface Branding {
title?: string
language?: string
initWorkspace?: string
}
export type BrandingMap = Record<string, Branding>
/** /**
* @public * @public
*/ */

View File

@ -3,6 +3,7 @@ import core, {
ModelDb, ModelDb,
TxProcessor, TxProcessor,
toFindResult, toFindResult,
type Branding,
type Blob, type Blob,
type BlobLookup, type BlobLookup,
type Class, type Class,
@ -157,7 +158,12 @@ export class MemStorageAdapter implements StorageAdapter {
throw new Error('NoSuchKey') throw new Error('NoSuchKey')
} }
async lookup (ctx: MeasureContext, workspaceId: WorkspaceIdWithUrl, docs: Blob[]): Promise<BlobLookupResult> { async lookup (
ctx: MeasureContext,
workspaceId: WorkspaceIdWithUrl,
branding: Branding | null,
docs: Blob[]
): Promise<BlobLookupResult> {
return { lookups: docs as unknown as BlobLookup[] } return { lookups: docs as unknown as BlobLookup[] }
} }
} }

View File

@ -26,7 +26,8 @@ import core, {
type MeasureContext, type MeasureContext,
type Ref, type Ref,
type WorkspaceId, type WorkspaceId,
getFullTextContext getFullTextContext,
type Branding
} from '@hcengineering/core' } from '@hcengineering/core'
import { jsonToText, markupToJSON } from '@hcengineering/text' import { jsonToText, markupToJSON } from '@hcengineering/text'
import { type DbAdapter } from '../adapter' import { type DbAdapter } from '../adapter'
@ -66,7 +67,8 @@ export class FullTextPushStage implements FullTextPipelineStage {
constructor ( constructor (
private readonly dbStorage: ServerStorage, private readonly dbStorage: ServerStorage,
readonly fulltextAdapter: FullTextAdapter, readonly fulltextAdapter: FullTextAdapter,
readonly workspace: WorkspaceId readonly workspace: WorkspaceId,
readonly branding: Branding | null
) {} ) {}
async initialize (ctx: MeasureContext, storage: DbAdapter, pipeline: FullTextPipeline): Promise<void> { async initialize (ctx: MeasureContext, storage: DbAdapter, pipeline: FullTextPipeline): Promise<void> {
@ -190,7 +192,7 @@ export class FullTextPushStage implements FullTextPipelineStage {
}) })
) )
await updateDocWithPresenter(pipeline.hierarchy, doc, elasticDoc, { parentDoc, spaceDoc }) await updateDocWithPresenter(pipeline.hierarchy, doc, elasticDoc, { parentDoc, spaceDoc }, this.branding)
this.checkIntegrity(elasticDoc) this.checkIntegrity(elasticDoc)
bulk.push(elasticDoc) bulk.push(elasticDoc)

View File

@ -1,5 +1,6 @@
import { import {
docKey, docKey,
type Branding,
type Class, type Class,
type Doc, type Doc,
type DocIndexState, type DocIndexState,
@ -110,7 +111,8 @@ export async function updateDocWithPresenter (
refDocs: { refDocs: {
parentDoc: DocIndexState | undefined parentDoc: DocIndexState | undefined
spaceDoc: DocIndexState | undefined spaceDoc: DocIndexState | undefined
} },
branding: Branding | null
): Promise<void> { ): Promise<void> {
const searchPresenter = findSearchPresenter(hierarchy, doc.objectClass) const searchPresenter = findSearchPresenter(hierarchy, doc.objectClass)
if (searchPresenter === undefined) { if (searchPresenter === undefined) {
@ -134,7 +136,8 @@ export async function updateDocWithPresenter (
props.push({ props.push({
name: 'searchShortTitle', name: 'searchShortTitle',
config: searchPresenter.searchConfig.shortTitle, config: searchPresenter.searchConfig.shortTitle,
provider: searchPresenter.getSearchShortTitle provider: searchPresenter.getSearchShortTitle,
lastNameFirst: branding?.lastNameFirst
}) })
} }

View File

@ -28,7 +28,8 @@ import {
type SearchResult, type SearchResult,
type StorageIterator, type StorageIterator,
type Tx, type Tx,
type TxResult type TxResult,
type Branding
} from '@hcengineering/core' } from '@hcengineering/core'
import { type DbConfiguration } from './configuration' import { type DbConfiguration } from './configuration'
import { createServerStorage } from './server' import { createServerStorage } from './server'
@ -49,7 +50,8 @@ export async function createPipeline (
conf: DbConfiguration, conf: DbConfiguration,
constructors: MiddlewareCreator[], constructors: MiddlewareCreator[],
upgrade: boolean, upgrade: boolean,
broadcast: BroadcastFunc broadcast: BroadcastFunc,
branding: Branding | null
): Promise<Pipeline> { ): Promise<Pipeline> {
const broadcastHandlers: BroadcastFunc[] = [broadcast] const broadcastHandlers: BroadcastFunc[] = [broadcast]
const _broadcast: BroadcastFunc = ( const _broadcast: BroadcastFunc = (
@ -65,7 +67,8 @@ export async function createPipeline (
async (ctx) => async (ctx) =>
await createServerStorage(ctx, conf, { await createServerStorage(ctx, conf, {
upgrade, upgrade,
broadcast: _broadcast broadcast: _broadcast,
branding
}) })
) )
const pipelineResult = await PipelineImpl.create(ctx.newChild('pipeline-operations', {}), storage, constructors) const pipelineResult = await PipelineImpl.create(ctx.newChild('pipeline-operations', {}), storage, constructors)

View File

@ -6,7 +6,8 @@ import core, {
type MeasureContext, type MeasureContext,
type Ref, type Ref,
type WorkspaceId, type WorkspaceId,
type WorkspaceIdWithUrl type WorkspaceIdWithUrl,
type Branding
} from '@hcengineering/core' } from '@hcengineering/core'
import { type Readable } from 'stream' import { type Readable } from 'stream'
import { type RawDBAdapter } from '../adapter' import { type RawDBAdapter } from '../adapter'
@ -292,14 +293,19 @@ export class AggregatorStorageAdapter implements StorageAdapter, StorageAdapterE
return result return result
} }
async lookup (ctx: MeasureContext, workspaceId: WorkspaceIdWithUrl, docs: Blob[]): Promise<BlobLookupResult> { async lookup (
ctx: MeasureContext,
workspaceId: WorkspaceIdWithUrl,
branding: Branding | null,
docs: Blob[]
): Promise<BlobLookupResult> {
const result: BlobLookup[] = [] const result: BlobLookup[] = []
const byProvider = groupByArray(docs, (it) => it.provider) const byProvider = groupByArray(docs, (it) => it.provider)
for (const [k, v] of byProvider.entries()) { for (const [k, v] of byProvider.entries()) {
const provider = this.adapters.get(k) const provider = this.adapters.get(k)
if (provider?.lookup !== undefined) { if (provider?.lookup !== undefined) {
const upd = await provider.lookup(ctx, workspaceId, v) const upd = await provider.lookup(ctx, workspaceId, branding, v)
if (upd.updates !== undefined) { if (upd.updates !== undefined) {
await this.dbAdapter.update(ctx, workspaceId, DOMAIN_BLOB, upd.updates) await this.dbAdapter.update(ctx, workspaceId, DOMAIN_BLOB, upd.updates)
} }

View File

@ -709,6 +709,7 @@ export class TServerStorage implements ServerStorage {
operationContext: ctx, operationContext: ctx,
removedMap, removedMap,
workspace: this.workspaceId, workspace: this.workspaceId,
branding: this.options.branding,
storageAdapter: this.storageAdapter, storageAdapter: this.storageAdapter,
serviceAdaptersManager: this.serviceAdaptersManager, serviceAdaptersManager: this.serviceAdaptersManager,
findAll: fAll(ctx.ctx), findAll: fAll(ctx.ctx),
@ -746,7 +747,8 @@ export class TServerStorage implements ServerStorage {
sctx.sessionId, sctx.sessionId,
sctx.admin, sctx.admin,
[], [],
this.workspaceId this.workspaceId,
this.options.branding
) )
const result = await performAsync(applyCtx) const result = await performAsync(applyCtx)
// We need to broadcast changes // We need to broadcast changes

View File

@ -40,7 +40,8 @@ import {
type TxFactory, type TxFactory,
type TxResult, type TxResult,
type WorkspaceId, type WorkspaceId,
type WorkspaceIdWithUrl type WorkspaceIdWithUrl,
type Branding
} from '@hcengineering/core' } from '@hcengineering/core'
import type { Asset, Resource } from '@hcengineering/platform' import type { Asset, Resource } from '@hcengineering/platform'
import { type Readable } from 'stream' import { type Readable } from 'stream'
@ -68,7 +69,7 @@ export interface ServerStorage extends LowLevelStorage {
close: () => Promise<void> close: () => Promise<void>
loadModel: (last: Timestamp, hash?: string) => Promise<Tx[] | LoadModelResponse> loadModel: (last: Timestamp, hash?: string) => Promise<Tx[] | LoadModelResponse>
workspaceId: WorkspaceIdWithUrl workspaceId: WorkspaceIdWithUrl
branding?: string
storageAdapter: StorageAdapter storageAdapter: StorageAdapter
} }
@ -81,6 +82,7 @@ export interface SessionContext extends SessionOperationContext {
admin?: boolean admin?: boolean
workspace: WorkspaceIdWithUrl workspace: WorkspaceIdWithUrl
branding: Branding | null
} }
/** /**
@ -142,6 +144,7 @@ export interface TriggerControl {
operationContext: SessionOperationContext operationContext: SessionOperationContext
ctx: MeasureContext ctx: MeasureContext
workspace: WorkspaceIdWithUrl workspace: WorkspaceIdWithUrl
branding: Branding | null
txFactory: TxFactory txFactory: TxFactory
findAll: Storage['findAll'] findAll: Storage['findAll']
findAllCtx: <T extends Doc>( findAllCtx: <T extends Doc>(
@ -438,6 +441,7 @@ export interface ServerStorageOptions {
upgrade: boolean upgrade: boolean
broadcast: BroadcastFunc broadcast: BroadcastFunc
branding: Branding | null
} }
export interface ServiceAdapter { export interface ServiceAdapter {

View File

@ -10,9 +10,12 @@ import core, {
type ParamsType, type ParamsType,
type Ref, type Ref,
type TxWorkspaceEvent, type TxWorkspaceEvent,
type WorkspaceIdWithUrl type WorkspaceIdWithUrl,
type Branding,
type BrandingMap
} from '@hcengineering/core' } from '@hcengineering/core'
import { type Hash } from 'crypto' import { type Hash } from 'crypto'
import fs from 'fs'
import type { SessionContext } from './types' import type { SessionContext } from './types'
/** /**
@ -138,7 +141,8 @@ export class SessionContextImpl implements SessionContext {
readonly sessionId: string, readonly sessionId: string,
readonly admin: boolean | undefined, readonly admin: boolean | undefined,
readonly derived: SessionContext['derived'], readonly derived: SessionContext['derived'],
readonly workspace: WorkspaceIdWithUrl readonly workspace: WorkspaceIdWithUrl,
readonly branding: Branding | null
) {} ) {}
with<T>( with<T>(
@ -151,7 +155,17 @@ export class SessionContextImpl implements SessionContext {
name, name,
params, params,
async (ctx) => async (ctx) =>
await op(new SessionContextImpl(ctx, this.userEmail, this.sessionId, this.admin, this.derived, this.workspace)), await op(
new SessionContextImpl(
ctx,
this.userEmail,
this.sessionId,
this.admin,
this.derived,
this.workspace,
this.branding
)
),
fullParams fullParams
) )
} }
@ -171,3 +185,16 @@ export function createBroadcastEvent (classes: Ref<Class<Doc>>[]): TxWorkspaceEv
space: core.space.DerivedTx space: core.space.DerivedTx
} }
} }
export function loadBrandingMap (brandingPath?: string): BrandingMap {
let brandings: BrandingMap = {}
if (brandingPath !== undefined && brandingPath !== '') {
brandings = JSON.parse(fs.readFileSync(brandingPath, 'utf8'))
for (const [host, value] of Object.entries(brandings)) {
value.front = `https://${host}/`
}
}
return brandings
}

View File

@ -26,7 +26,8 @@ import core, {
toIdMap, toIdMap,
type Blob, type Blob,
type BlobLookup, type BlobLookup,
type WorkspaceIdWithUrl type WorkspaceIdWithUrl,
type Branding
} from '@hcengineering/core' } from '@hcengineering/core'
import { Middleware, SessionContext, TxMiddlewareResult, type ServerStorage } from '@hcengineering/server-core' import { Middleware, SessionContext, TxMiddlewareResult, type ServerStorage } from '@hcengineering/server-core'
import { BaseMiddleware } from './base' import { BaseMiddleware } from './base'
@ -50,12 +51,13 @@ export class BlobLookupMiddleware extends BaseMiddleware implements Middleware {
async fetchBlobInfo ( async fetchBlobInfo (
ctx: MeasureContext, ctx: MeasureContext,
workspace: WorkspaceIdWithUrl, workspace: WorkspaceIdWithUrl,
branding: Branding | null,
toUpdate: [Doc, Blob, string][] toUpdate: [Doc, Blob, string][]
): Promise<void> { ): Promise<void> {
if (this.storage.storageAdapter.lookup !== undefined) { if (this.storage.storageAdapter.lookup !== undefined) {
const docsToUpdate = toUpdate.map((it) => it[1]) const docsToUpdate = toUpdate.map((it) => it[1])
const updatedBlobs = toIdMap<Blob>( const updatedBlobs = toIdMap<Blob>(
(await this.storage.storageAdapter.lookup(ctx, workspace, docsToUpdate)).lookups (await this.storage.storageAdapter.lookup(ctx, workspace, branding, docsToUpdate)).lookups
) )
for (const [doc, blob, key] of toUpdate) { for (const [doc, blob, key] of toUpdate) {
const ublob = updatedBlobs.get(blob._id) const ublob = updatedBlobs.get(blob._id)
@ -78,7 +80,8 @@ export class BlobLookupMiddleware extends BaseMiddleware implements Middleware {
if (_class === core.class.Blob) { if (_class === core.class.Blob) {
// Bulk update of info // Bulk update of info
const updatedBlobs = toIdMap<Blob>( const updatedBlobs = toIdMap<Blob>(
(await this.storage.storageAdapter.lookup(ctx.ctx, ctx.workspace, result as unknown as Blob[])).lookups (await this.storage.storageAdapter.lookup(ctx.ctx, ctx.workspace, ctx.branding, result as unknown as Blob[]))
.lookups
) )
const res: T[] = [] const res: T[] = []
for (const d of result) { for (const d of result) {
@ -102,13 +105,13 @@ export class BlobLookupMiddleware extends BaseMiddleware implements Middleware {
} }
if (toUpdate.length > 50) { if (toUpdate.length > 50) {
// Bulk update of info // Bulk update of info
await this.fetchBlobInfo(ctx.ctx, ctx.workspace, toUpdate) await this.fetchBlobInfo(ctx.ctx, ctx.workspace, ctx.branding, toUpdate)
toUpdate = [] toUpdate = []
} }
} }
if (toUpdate.length > 0) { if (toUpdate.length > 0) {
// Bulk update of info // Bulk update of info
await this.fetchBlobInfo(ctx.ctx, ctx.workspace, toUpdate) await this.fetchBlobInfo(ctx.ctx, ctx.workspace, ctx.branding, toUpdate)
toUpdate = [] toUpdate = []
} }
} }

View File

@ -24,7 +24,8 @@ import core, {
type MeasureContext, type MeasureContext,
type Ref, type Ref,
type WorkspaceId, type WorkspaceId,
type WorkspaceIdWithUrl type WorkspaceIdWithUrl,
type Branding
} from '@hcengineering/core' } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform' import { getMetadata } from '@hcengineering/platform'
@ -76,8 +77,13 @@ export class MinioService implements StorageAdapter {
async initialize (ctx: MeasureContext, workspaceId: WorkspaceId): Promise<void> {} async initialize (ctx: MeasureContext, workspaceId: WorkspaceId): Promise<void> {}
async lookup (ctx: MeasureContext, workspaceId: WorkspaceIdWithUrl, docs: Blob[]): Promise<BlobLookupResult> { async lookup (
const frontUrl = getMetadata(serverCore.metadata.FrontUrl) ?? '' ctx: MeasureContext,
workspaceId: WorkspaceIdWithUrl,
branding: Branding | null,
docs: Blob[]
): Promise<BlobLookupResult> {
const frontUrl = branding?.front ?? getMetadata(serverCore.metadata.FrontUrl) ?? ''
for (const d of docs) { for (const d of docs) {
// Let's add current from URI for previews. // Let's add current from URI for previews.
const bl = d as BlobLookup const bl = d as BlobLookup

View File

@ -177,7 +177,8 @@ describe('mongo operations', () => {
const ctx = new MeasureMetricsContext('client', {}) const ctx = new MeasureMetricsContext('client', {})
serverStorage = await createServerStorage(ctx, conf, { serverStorage = await createServerStorage(ctx, conf, {
upgrade: false, upgrade: false,
broadcast: () => {} broadcast: () => {},
branding: null
}) })
const soCtx: SessionOperationContext = { const soCtx: SessionOperationContext = {
ctx, ctx,

View File

@ -25,7 +25,8 @@ import core, {
type MeasureContext, type MeasureContext,
type Ref, type Ref,
type WorkspaceId, type WorkspaceId,
type WorkspaceIdWithUrl type WorkspaceIdWithUrl,
type Branding
} from '@hcengineering/core' } from '@hcengineering/core'
import { import {
@ -83,7 +84,12 @@ export class S3Service implements StorageAdapter {
async initialize (ctx: MeasureContext, workspaceId: WorkspaceId): Promise<void> {} async initialize (ctx: MeasureContext, workspaceId: WorkspaceId): Promise<void> {}
async lookup (ctx: MeasureContext, workspaceId: WorkspaceIdWithUrl, docs: Blob[]): Promise<BlobLookupResult> { async lookup (
ctx: MeasureContext,
workspaceId: WorkspaceIdWithUrl,
branding: Branding | null,
docs: Blob[]
): Promise<BlobLookupResult> {
const result: BlobLookupResult = { const result: BlobLookupResult = {
lookups: [], lookups: [],
updates: new Map() updates: new Map()

View File

@ -1,4 +1,4 @@
import { Doc, DocInfo, Domain, Ref, StorageIterator } from '@hcengineering/core' import { type BrandingMap, Doc, DocInfo, Domain, Ref, StorageIterator } from '@hcengineering/core'
import { Pipeline, estimateDocSize } from '@hcengineering/server-core' import { Pipeline, estimateDocSize } from '@hcengineering/server-core'
import { Token } from '@hcengineering/server-token' import { Token } from '@hcengineering/server-token'
import { ClientSession, Session, type ClientSessionCtx } from '@hcengineering/server-ws' import { ClientSession, Session, type ClientSessionCtx } from '@hcengineering/server-ws'
@ -30,9 +30,10 @@ export interface BackupSession extends Session {
export class BackupClientSession extends ClientSession implements BackupSession { export class BackupClientSession extends ClientSession implements BackupSession {
constructor ( constructor (
protected readonly token: Token, protected readonly token: Token,
protected readonly _pipeline: Pipeline protected readonly _pipeline: Pipeline,
protected readonly brandingMap: BrandingMap
) { ) {
super(token, _pipeline) super(token, _pipeline, brandingMap)
} }
idIndex = 0 idIndex = 0

View File

@ -13,6 +13,7 @@ export interface ServerEnv {
pushPublicKey: string | undefined pushPublicKey: string | undefined
pushPrivateKey: string | undefined pushPrivateKey: string | undefined
pushSubject: string | undefined pushSubject: string | undefined
brandingPath: string | undefined
} }
export function serverConfigFromEnv (): ServerEnv { export function serverConfigFromEnv (): ServerEnv {
@ -71,6 +72,8 @@ export function serverConfigFromEnv (): ServerEnv {
const pushPublicKey = process.env.PUSH_PUBLIC_KEY const pushPublicKey = process.env.PUSH_PUBLIC_KEY
const pushPrivateKey = process.env.PUSH_PRIVATE_KEY const pushPrivateKey = process.env.PUSH_PRIVATE_KEY
const pushSubject = process.env.PUSH_SUBJECT const pushSubject = process.env.PUSH_SUBJECT
const brandingPath = process.env.BRANDING_PATH
return { return {
url, url,
elasticUrl, elasticUrl,
@ -85,6 +88,7 @@ export function serverConfigFromEnv (): ServerEnv {
enableCompression, enableCompression,
pushPublicKey, pushPublicKey,
pushPrivateKey, pushPrivateKey,
pushSubject pushSubject,
brandingPath
} }
} }

View File

@ -82,9 +82,10 @@ describe('server', () => {
return { docs: [] } return { docs: [] }
} }
}), }),
sessionFactory: (token, pipeline) => new ClientSession(token, pipeline), sessionFactory: (token, pipeline) => new ClientSession(token, pipeline, {}),
port: 3335, port: 3335,
productId: '', productId: '',
brandingMap: {},
serverFactory: startHttpServer, serverFactory: startHttpServer,
accountsUrl: '', accountsUrl: '',
externalStorage: createDummyStorageAdapter() externalStorage: createDummyStorageAdapter()
@ -182,9 +183,10 @@ describe('server', () => {
return { docs: [] } return { docs: [] }
} }
}), }),
sessionFactory: (token, pipeline) => new ClientSession(token, pipeline), sessionFactory: (token, pipeline) => new ClientSession(token, pipeline, {}),
port: 3336, port: 3336,
productId: '', productId: '',
brandingMap: {},
serverFactory: startHttpServer, serverFactory: startHttpServer,
accountsUrl: '', accountsUrl: '',
externalStorage: createDummyStorageAdapter() externalStorage: createDummyStorageAdapter()

View File

@ -32,7 +32,9 @@ import core, {
type Tx, type Tx,
type TxApplyIf, type TxApplyIf,
type TxApplyResult, type TxApplyResult,
type TxCUD type TxCUD,
type Branding,
type BrandingMap
} from '@hcengineering/core' } from '@hcengineering/core'
import { SessionContextImpl, createBroadcastEvent, type Pipeline } from '@hcengineering/server-core' import { SessionContextImpl, createBroadcastEvent, type Pipeline } from '@hcengineering/server-core'
import { type Token } from '@hcengineering/server-token' import { type Token } from '@hcengineering/server-token'
@ -56,7 +58,8 @@ export class ClientSession implements Session {
constructor ( constructor (
protected readonly token: Token, protected readonly token: Token,
protected readonly _pipeline: Pipeline protected readonly _pipeline: Pipeline,
protected readonly brandingMap: BrandingMap
) {} ) {}
getUser (): string { getUser (): string {
@ -75,6 +78,14 @@ export class ClientSession implements Session {
return this._pipeline return this._pipeline
} }
getBranding (brandingKey?: string): Branding | null {
if (brandingKey === undefined) {
return null
}
return this.brandingMap[brandingKey] ?? null
}
async ping (ctx: ClientSessionCtx): Promise<void> { async ping (ctx: ClientSessionCtx): Promise<void> {
// console.log('ping') // console.log('ping')
this.lastRequest = Date.now() this.lastRequest = Date.now()
@ -115,7 +126,8 @@ export class ClientSession implements Session {
this.sessionId, this.sessionId,
this.token.extra?.admin === 'true', this.token.extra?.admin === 'true',
[], [],
this._pipeline.storage.workspaceId this._pipeline.storage.workspaceId,
this.getBranding(this._pipeline.storage.branding)
) )
await this._pipeline.tx(context, createTx) await this._pipeline.tx(context, createTx)
const acc = TxProcessor.createDoc2Doc(createTx) const acc = TxProcessor.createDoc2Doc(createTx)
@ -144,7 +156,8 @@ export class ClientSession implements Session {
this.sessionId, this.sessionId,
this.token.extra?.admin === 'true', this.token.extra?.admin === 'true',
[], [],
this._pipeline.storage.workspaceId this._pipeline.storage.workspaceId,
this.getBranding(this._pipeline.storage.branding)
) )
return await this._pipeline.findAll(context, _class, query, options) return await this._pipeline.findAll(context, _class, query, options)
} }
@ -166,7 +179,8 @@ export class ClientSession implements Session {
this.sessionId, this.sessionId,
this.token.extra?.admin === 'true', this.token.extra?.admin === 'true',
[], [],
this._pipeline.storage.workspaceId this._pipeline.storage.workspaceId,
this.getBranding(this._pipeline.storage.branding)
) )
await ctx.sendResponse(await this._pipeline.searchFulltext(context, query, options)) await ctx.sendResponse(await this._pipeline.searchFulltext(context, query, options))
} }
@ -181,7 +195,8 @@ export class ClientSession implements Session {
this.sessionId, this.sessionId,
this.token.extra?.admin === 'true', this.token.extra?.admin === 'true',
[], [],
this._pipeline.storage.workspaceId this._pipeline.storage.workspaceId,
this.getBranding(this._pipeline.storage.branding)
) )
const result = await this._pipeline.tx(context, tx) const result = await this._pipeline.tx(context, tx)

View File

@ -26,7 +26,9 @@ import core, {
type MeasureContext, type MeasureContext,
type Tx, type Tx,
type TxWorkspaceEvent, type TxWorkspaceEvent,
type WorkspaceId type WorkspaceId,
type Branding,
type BrandingMap
} from '@hcengineering/core' } from '@hcengineering/core'
import { unknownError, type Status } from '@hcengineering/platform' import { unknownError, type Status } from '@hcengineering/platform'
import { type HelloRequest, type HelloResponse, type Request, type Response } from '@hcengineering/rpc' import { type HelloRequest, type HelloResponse, type Request, type Response } from '@hcengineering/rpc'
@ -92,7 +94,8 @@ class TSessionManager implements SessionManager {
constructor ( constructor (
readonly ctx: MeasureContext, readonly ctx: MeasureContext,
readonly sessionFactory: (token: Token, pipeline: Pipeline) => Session, readonly sessionFactory: (token: Token, pipeline: Pipeline) => Session,
readonly timeouts: Timeouts readonly timeouts: Timeouts,
readonly brandingMap: BrandingMap
) { ) {
this.checkInterval = setInterval(() => { this.checkInterval = setInterval(() => {
this.handleInterval() this.handleInterval()
@ -297,6 +300,10 @@ class TSessionManager implements SessionManager {
await this.close(ctx, oldSession.socket, wsString) await this.close(ctx, oldSession.socket, wsString)
} }
const workspaceName = workspaceInfo.workspaceName ?? workspaceInfo.workspaceUrl ?? workspaceInfo.workspaceId const workspaceName = workspaceInfo.workspaceName ?? workspaceInfo.workspaceUrl ?? workspaceInfo.workspaceId
const branding =
(workspaceInfo.branding !== undefined
? Object.values(this.brandingMap).find((b) => b.key === workspaceInfo.branding)
: null) ?? null
if (workspace === undefined) { if (workspace === undefined) {
ctx.warn('open workspace', { ctx.warn('open workspace', {
@ -310,7 +317,8 @@ class TSessionManager implements SessionManager {
pipelineFactory, pipelineFactory,
token, token,
workspaceInfo.workspaceUrl ?? workspaceInfo.workspaceId, workspaceInfo.workspaceUrl ?? workspaceInfo.workspaceId,
workspaceName workspaceName,
branding
) )
} }
@ -424,7 +432,8 @@ class TSessionManager implements SessionManager {
true, true,
(tx, targets, exclude) => { (tx, targets, exclude) => {
this.broadcastAll(workspace, tx, targets, exclude) this.broadcastAll(workspace, tx, targets, exclude)
} },
workspace.branding
) )
return await workspace.pipeline return await workspace.pipeline
} }
@ -513,7 +522,8 @@ class TSessionManager implements SessionManager {
pipelineFactory: PipelineFactory, pipelineFactory: PipelineFactory,
token: Token, token: Token,
workspaceUrl: string, workspaceUrl: string,
workspaceName: string workspaceName: string,
branding: Branding | null
): Workspace { ): Workspace {
const upgrade = token.extra?.model === 'upgrade' const upgrade = token.extra?.model === 'upgrade'
const backup = token.extra?.mode === 'backup' const backup = token.extra?.mode === 'backup'
@ -528,14 +538,16 @@ class TSessionManager implements SessionManager {
upgrade, upgrade,
(tx, targets) => { (tx, targets) => {
this.broadcastAll(workspace, tx, targets) this.broadcastAll(workspace, tx, targets)
} },
branding
), ),
sessions: new Map(), sessions: new Map(),
softShutdown: 3, softShutdown: 3,
upgrade, upgrade,
backup, backup,
workspaceId: token.workspace, workspaceId: token.workspace,
workspaceName workspaceName,
branding
} }
this.workspaces.set(toWorkspaceString(token.workspace), workspace) this.workspaces.set(toWorkspaceString(token.workspace), workspace)
return workspace return workspace
@ -970,16 +982,22 @@ export function start (
pipelineFactory: PipelineFactory pipelineFactory: PipelineFactory
sessionFactory: (token: Token, pipeline: Pipeline) => Session sessionFactory: (token: Token, pipeline: Pipeline) => Session
productId: string productId: string
brandingMap: BrandingMap
serverFactory: ServerFactory serverFactory: ServerFactory
enableCompression?: boolean enableCompression?: boolean
accountsUrl: string accountsUrl: string
externalStorage: StorageAdapter externalStorage: StorageAdapter
} & Partial<Timeouts> } & Partial<Timeouts>
): () => Promise<void> { ): () => Promise<void> {
const sessions = new TSessionManager(ctx, opt.sessionFactory, { const sessions = new TSessionManager(
pingTimeout: opt.pingTimeout ?? 10000, ctx,
reconnectTimeout: 500 opt.sessionFactory,
}) {
pingTimeout: opt.pingTimeout ?? 10000,
reconnectTimeout: 500
},
opt.brandingMap
)
return opt.serverFactory( return opt.serverFactory(
sessions, sessions,
(rctx, service, ws, msg, workspace) => { (rctx, service, ws, msg, workspace) => {

View File

@ -8,7 +8,8 @@ import {
type Ref, type Ref,
type Tx, type Tx,
type WorkspaceId, type WorkspaceId,
type WorkspaceIdWithUrl type WorkspaceIdWithUrl,
type Branding
} from '@hcengineering/core' } from '@hcengineering/core'
import { type Request, type Response } from '@hcengineering/rpc' import { type Request, type Response } from '@hcengineering/rpc'
import { type BroadcastFunc, type Pipeline, type StorageAdapter } from '@hcengineering/server-core' import { type BroadcastFunc, type Pipeline, type StorageAdapter } from '@hcengineering/server-core'
@ -92,7 +93,8 @@ export type PipelineFactory = (
ctx: MeasureContext, ctx: MeasureContext,
ws: WorkspaceIdWithUrl, ws: WorkspaceIdWithUrl,
upgrade: boolean, upgrade: boolean,
broadcast: BroadcastFunc broadcast: BroadcastFunc,
branding: Branding | null
) => Promise<Pipeline> ) => Promise<Pipeline>
/** /**
@ -134,6 +136,7 @@ export interface Workspace {
workspaceId: WorkspaceId workspaceId: WorkspaceId
workspaceName: string workspaceName: string
branding: Branding | null
} }
export interface AddSessionActive { export interface AddSessionActive {

View File

@ -39,6 +39,8 @@ services:
- minio - minio
ports: ports:
- 3003:3003 - 3003:3003
volumes:
- ./branding-test.json:/var/cfg/branding.json
environment: environment:
- ACCOUNT_PORT=3003 - ACCOUNT_PORT=3003
- SERVER_SECRET=secret - SERVER_SECRET=secret
@ -47,6 +49,7 @@ services:
- ENDPOINT_URL=ws://localhost:3334 - ENDPOINT_URL=ws://localhost:3334
- STORAGE_CONFIG=${STORAGE_CONFIG} - STORAGE_CONFIG=${STORAGE_CONFIG}
- MODEL_ENABLED=* - MODEL_ENABLED=*
- BRANDING_PATH=/var/cfg/branding.json
front: front:
image: hardcoreeng/front image: hardcoreeng/front
pull_policy: never pull_policy: never
@ -87,6 +90,8 @@ services:
- account - account
ports: ports:
- 3334:3334 - 3334:3334
volumes:
- ./branding-test.json:/var/cfg/branding.json
environment: environment:
- SERVER_PROVIDER=${SERVER_PROVIDER} - SERVER_PROVIDER=${SERVER_PROVIDER}
- SERVER_PORT=3334 - SERVER_PORT=3334
@ -102,6 +107,7 @@ services:
- ACCOUNTS_URL=http://account:3003 - ACCOUNTS_URL=http://account:3003
- LAST_NAME_FIRST=true - LAST_NAME_FIRST=true
- ELASTIC_INDEX_NAME=local_storage_index - ELASTIC_INDEX_NAME=local_storage_index
- BRANDING_PATH=/var/cfg/branding.json
collaborator: collaborator:
image: hardcoreeng/collaborator image: hardcoreeng/collaborator
links: links: