From dedff23b3164d21a2d1640f1c55dd9cf24d36d7f Mon Sep 17 00:00:00 2001 From: Alexey Zinoviev Date: Thu, 20 Jun 2024 17:13:57 +0400 Subject: [PATCH] EZQMS-951: Server branding (#5858) Signed-off-by: Alexey Zinoviev --- dev/tool/src/index.ts | 7 ++- packages/core/src/classes.ts | 1 + packages/core/src/server.ts | 11 +++++ packages/storage/src/index.ts | 17 +++++-- plugins/contact/src/utils.ts | 19 ++++--- pods/account/package.json | 1 + pods/account/src/__start.ts | 10 +--- pods/authProviders/src/github.ts | 34 ++++++++++--- pods/authProviders/src/google.ts | 34 ++++++++++--- pods/authProviders/src/index.ts | 10 ++-- pods/authProviders/src/utils.ts | 49 +++++++++++++++++++ pods/server/src/__start.ts | 3 +- pods/server/src/server.ts | 19 ++++--- server-plugins/chunter-resources/src/index.ts | 2 +- server-plugins/contact-resources/src/index.ts | 14 +++--- .../document-resources/src/index.ts | 2 +- server-plugins/guest-resources/src/index.ts | 16 +++--- server-plugins/hr-resources/src/index.ts | 10 ++-- .../inventory-resources/src/index.ts | 2 +- server-plugins/lead-resources/src/index.ts | 2 +- server-plugins/love-resources/src/index.ts | 10 ++-- .../notification-resources/src/index.ts | 4 +- .../notification-resources/src/utils.ts | 2 +- server-plugins/recruit-resources/src/index.ts | 4 +- server-plugins/tracker-resources/src/index.ts | 2 +- .../functions/TrainingRequestHTMLPresenter.ts | 2 +- server/account-service/src/index.ts | 7 ++- server/account/src/operations.ts | 23 ++++----- server/core/src/__tests__/memAdapters.ts | 8 ++- server/core/src/indexer/fulltextPush.ts | 8 +-- server/core/src/mapper.ts | 7 ++- server/core/src/pipeline.ts | 9 ++-- server/core/src/server/aggregator.ts | 12 +++-- server/core/src/server/storage.ts | 4 +- server/core/src/types.ts | 8 ++- server/core/src/utils.ts | 33 +++++++++++-- server/middleware/src/blobs.ts | 13 +++-- server/minio/src/index.ts | 12 +++-- server/mongo/src/__tests__/storage.test.ts | 3 +- server/s3/src/index.ts | 10 +++- server/server/src/backup.ts | 7 +-- server/server/src/starter.ts | 6 ++- server/ws/src/__tests__/server.test.ts | 6 ++- server/ws/src/client.ts | 27 +++++++--- server/ws/src/server.ts | 40 ++++++++++----- server/ws/src/types.ts | 7 ++- tests/docker-compose.yaml | 6 +++ 47 files changed, 395 insertions(+), 148 deletions(-) create mode 100644 pods/authProviders/src/utils.ts diff --git a/dev/tool/src/index.ts b/dev/tool/src/index.ts index a334cd1ded..8f9a527513 100644 --- a/dev/tool/src/index.ts +++ b/dev/tool/src/index.ts @@ -285,7 +285,8 @@ export function devTool ( .requiredOption('-w, --workspaceName ', 'Workspace name') .option('-e, --email ', 'Author email', 'platform@email.com') .option('-i, --init ', 'Init from workspace') - .action(async (workspace, cmd: { email: string, workspaceName: string, init?: string }) => { + .option('-b, --branding ', 'Branding key') + .action(async (workspace, cmd: { email: string, workspaceName: string, init?: string, branding?: string }) => { const { mongodbUri, txes, version, migrateOperations } = prepareTools() await withDatabase(mongodbUri, async (db) => { await createWorkspace( @@ -295,7 +296,9 @@ export function devTool ( migrateOperations, db, 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.workspaceName, workspace diff --git a/packages/core/src/classes.ts b/packages/core/src/classes.ts index 73c06fafe2..da3af8dd9a 100644 --- a/packages/core/src/classes.ts +++ b/packages/core/src/classes.ts @@ -661,6 +661,7 @@ export interface BaseWorkspaceInfo { productId: string disabled?: boolean version?: Data + branding?: string workspaceUrl?: string | null // An optional url to the workspace, if not set workspace will be used workspaceName?: string // An displayed workspace name diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 61e2b3bbf4..5e41bd7dc8 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -67,3 +67,14 @@ export interface LowLevelStorage { // Remove a list of documents. clean: (ctx: MeasureContext, domain: Domain, docs: Ref[]) => Promise } + +export interface Branding { + key?: string + front?: string + title?: string + language?: string + initWorkspace?: string + lastNameFirst?: string +} + +export type BrandingMap = Record diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 543a02afbb..0c0df1c115 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -19,7 +19,8 @@ import { type MeasureContext, type Ref, type WorkspaceId, - type WorkspaceIdWithUrl + type WorkspaceIdWithUrl, + type Branding } from '@hcengineering/core' import type { BlobLookup } from '@hcengineering/core/src/classes' import { type Readable } from 'stream' @@ -84,7 +85,12 @@ export interface StorageAdapter { ) => Promise // Lookup will extend Blob with lookup information. - lookup: (ctx: MeasureContext, workspaceId: WorkspaceIdWithUrl, docs: Blob[]) => Promise + lookup: ( + ctx: MeasureContext, + workspaceId: WorkspaceIdWithUrl, + branding: Branding | null, + docs: Blob[] + ) => Promise } export interface StorageAdapterEx extends StorageAdapter { @@ -174,7 +180,12 @@ export class DummyStorageAdapter implements StorageAdapter, StorageAdapterEx { throw new Error('not implemented') } - async lookup (ctx: MeasureContext, workspaceId: WorkspaceIdWithUrl, docs: Blob[]): Promise { + async lookup ( + ctx: MeasureContext, + workspaceId: WorkspaceIdWithUrl, + branding: Branding | null, + docs: Blob[] + ): Promise { return { lookups: [] } } } diff --git a/plugins/contact/src/utils.ts b/plugins/contact/src/utils.ts index b2b59b2455..7f17ba82a8 100644 --- a/plugins/contact/src/utils.ts +++ b/plugins/contact/src/utils.ts @@ -211,8 +211,10 @@ export function getLastName (name: string): string { /** * @public */ -export function formatName (name: string): string { - return getMetadata(contactPlugin.metadata.LastNameFirst) === true +export function formatName (name: string, lastNameFirst?: string): string { + const lastNameFirstCombined = + lastNameFirst !== undefined ? lastNameFirst === 'true' : getMetadata(contactPlugin.metadata.LastNameFirst) === true + return lastNameFirstCombined ? getLastName(name) + ' ' + getFirstName(name) : getFirstName(name) + ' ' + getLastName(name) } @@ -220,9 +222,9 @@ export function formatName (name: string): string { /** * @public */ -export function getName (hierarchy: Hierarchy, value: Contact): string { +export function getName (hierarchy: Hierarchy, value: Contact, lastNameFirst?: string): string { if (isPerson(hierarchy, value)) { - return formatName(value.name) + return formatName(value.name, lastNameFirst) } return value.name } @@ -238,9 +240,14 @@ function isPersonClass (hierarchy: Hierarchy, _class: Ref>): boolean /** * @public */ -export function formatContactName (hierarchy: Hierarchy, _class: Ref>, name: string): string { +export function formatContactName ( + hierarchy: Hierarchy, + _class: Ref>, + name: string, + lastNameFirst?: string +): string { if (isPersonClass(hierarchy, _class)) { - return formatName(name) + return formatName(name, lastNameFirst) } return name } diff --git a/pods/account/package.json b/pods/account/package.json index 1289df36bf..01d82c77bb 100644 --- a/pods/account/package.json +++ b/pods/account/package.json @@ -62,6 +62,7 @@ "@koa/cors": "^3.1.0", "@hcengineering/server-tool": "^0.6.0", "@hcengineering/server-token": "^0.6.11", + "@hcengineering/server-core": "^0.6.1", "@hcengineering/model-all": "^0.6.0" } } diff --git a/pods/account/src/__start.ts b/pods/account/src/__start.ts index 3cbf6c20ee..f891ce1c73 100644 --- a/pods/account/src/__start.ts +++ b/pods/account/src/__start.ts @@ -13,8 +13,7 @@ // limitations under the License. // -import fs from 'fs' -import { type BrandingMap } from '@hcengineering/account' +import { loadBrandingMap } from '@hcengineering/server-core' import { serveAccount } from '@hcengineering/account-service' import { MeasureMetricsContext, newMetrics, type Tx } from '@hcengineering/core' import builder, { getModelVersion, migrateOperations } from '@hcengineering/model-all' @@ -28,9 +27,4 @@ const metricsContext = new MeasureMetricsContext('account', {}, {}, newMetrics() const brandingPath = process.env.BRANDING_PATH -let brandings: BrandingMap = {} -if (brandingPath !== undefined && brandingPath !== '') { - brandings = JSON.parse(fs.readFileSync(brandingPath, 'utf8')) -} - -serveAccount(metricsContext, getModelVersion(), txes, migrateOperations, '', brandings) +serveAccount(metricsContext, getModelVersion(), txes, migrateOperations, '', loadBrandingMap(brandingPath)) diff --git a/pods/authProviders/src/github.ts b/pods/authProviders/src/github.ts index 3f06938752..bcae180a25 100644 --- a/pods/authProviders/src/github.ts +++ b/pods/authProviders/src/github.ts @@ -1,9 +1,10 @@ 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 { Db } from 'mongodb' import { Strategy as GitHubStrategy } from 'passport-github2' import { Passport } from '.' +import { getBranding, getHost, safeParseAuthState } from './utils' export function registerGithub ( measureCtx: MeasureContext, @@ -12,7 +13,8 @@ export function registerGithub ( accountsUrl: string, db: Db, productId: string, - frontUrl: string + frontUrl: string, + brandings: BrandingMap ): string | undefined { const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET @@ -34,21 +36,39 @@ export function registerGithub ( ) router.get('/auth/github', async (ctx, next) => { - const state = ctx.query?.inviteId 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) }) router.get( 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) => { try { 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, ''] measureCtx.info('Provider auth handler', { email, type: 'github' }) 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( measureCtx, db, @@ -57,7 +77,7 @@ export function registerGithub ( email, first, last, - ctx.query.state, + state.inviteId as any, { githubId: ctx.state.user.id } @@ -75,7 +95,7 @@ export function registerGithub ( } measureCtx.info('Success auth, redirect', { email, type: 'github' }) // Successful authentication, redirect to your application - ctx.redirect(concatLink(frontUrl, '/login/auth')) + ctx.redirect(concatLink(branding?.front ?? frontUrl, '/login/auth')) } } catch (err: any) { measureCtx.error('failed to auth', { err, type: 'github', user: ctx.state?.user }) diff --git a/pods/authProviders/src/google.ts b/pods/authProviders/src/google.ts index 2c445ed5a2..505f9b0cfd 100644 --- a/pods/authProviders/src/google.ts +++ b/pods/authProviders/src/google.ts @@ -1,9 +1,10 @@ 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 { Db } from 'mongodb' import { Strategy as GoogleStrategy } from 'passport-google-oauth20' import { Passport } from '.' +import { getBranding, getHost, safeParseAuthState } from './utils' export function registerGoogle ( measureCtx: MeasureContext, @@ -12,7 +13,8 @@ export function registerGoogle ( accountsUrl: string, db: Db, productId: string, - frontUrl: string + frontUrl: string, + brandings: BrandingMap ): string | undefined { const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET @@ -34,14 +36,30 @@ export function registerGoogle ( ) router.get('/auth/google', async (ctx, next) => { - const state = ctx.query?.inviteId 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) }) router.get( 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) => { const email = ctx.state.user.emails?.[0]?.value const first = ctx.state.user.name.givenName @@ -49,7 +67,9 @@ export function registerGoogle ( measureCtx.info('Provider auth handler', { email, type: 'google' }) if (email !== undefined) { 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( measureCtx, db, @@ -58,7 +78,7 @@ export function registerGoogle ( email, first, last, - ctx.query.state + state.inviteId as any ) if (ctx.session != null) { ctx.session.loginInfo = loginInfo @@ -72,7 +92,7 @@ export function registerGoogle ( // Successful authentication, redirect to your application 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) { measureCtx.error('failed to auth', { err, type: 'google', user: ctx.state?.user }) } diff --git a/pods/authProviders/src/index.ts b/pods/authProviders/src/index.ts index 91b5ef0a54..b83ee7fa99 100644 --- a/pods/authProviders/src/index.ts +++ b/pods/authProviders/src/index.ts @@ -5,7 +5,7 @@ import session from 'koa-session' import { Db } from 'mongodb' import { registerGithub } from './github' import { registerGoogle } from './google' -import { MeasureContext } from '@hcengineering/core' +import { BrandingMap, MeasureContext } from '@hcengineering/core' export type Passport = typeof passport @@ -16,7 +16,8 @@ export type AuthProvider = ( accountsUrl: string, db: Db, productId: string, - frontUrl: string + frontUrl: string, + brandings: BrandingMap ) => string | undefined export function registerProviders ( @@ -26,7 +27,8 @@ export function registerProviders ( db: Db, productId: string, serverSecret: string, - frontUrl: string | undefined + frontUrl: string | undefined, + brandings: BrandingMap ): void { const accountsUrl = process.env.ACCOUNTS_URL if (accountsUrl === undefined) { @@ -60,7 +62,7 @@ export function registerProviders ( const res: string[] = [] const providers: AuthProvider[] = [registerGoogle, registerGithub] 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) } diff --git a/pods/authProviders/src/utils.ts b/pods/authProviders/src/utils.ts new file mode 100644 index 0000000000..a68c8f989b --- /dev/null +++ b/pods/authProviders/src/utils.ts @@ -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 {} + } +} diff --git a/pods/server/src/__start.ts b/pods/server/src/__start.ts index 663271eeb8..5a54cb09d6 100644 --- a/pods/server/src/__start.ts +++ b/pods/server/src/__start.ts @@ -20,7 +20,7 @@ import notification from '@hcengineering/notification' import { setMetadata } from '@hcengineering/platform' import { serverConfigFromEnv } from '@hcengineering/server' 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 serverToken from '@hcengineering/server-token' import { start } from '.' @@ -61,6 +61,7 @@ const shutdown = start(config.url, { indexParallel: 2, indexProcessing: 50, productId: '', + brandingMap: loadBrandingMap(config.brandingPath), enableCompression: config.enableCompression, accountsUrl: config.accountsUrl }) diff --git a/pods/server/src/server.ts b/pods/server/src/server.ts index 0bc2cf245b..a22b7c5337 100644 --- a/pods/server/src/server.ts +++ b/pods/server/src/server.ts @@ -22,7 +22,9 @@ import { DOMAIN_TRANSIENT, DOMAIN_TX, type MeasureContext, - type WorkspaceId + type WorkspaceId, + type BrandingMap, + type Branding } from '@hcengineering/core' import { createElasticAdapter, createElasticBackupDataAdapter } from '@hcengineering/elastic' import { @@ -47,6 +49,7 @@ import { createYDocAdapter, getMetricsContext } from '@hcengineering/server' + import { serverActivityId } from '@hcengineering/server-activity' import { serverAttachmentId } from '@hcengineering/server-attachment' import { serverCalendarId } from '@hcengineering/server-calendar' @@ -212,6 +215,7 @@ export function start ( rekoniUrl: string port: number productId: string + brandingMap: BrandingMap serverFactory: ServerFactory indexProcessing: number // 1000 @@ -267,6 +271,7 @@ export function start ( function createIndexStages ( fullText: MeasureContext, workspace: WorkspaceId, + branding: Branding | null, adapter: FullTextAdapter, storage: ServerStorage, storageAdapter: StorageAdapter, @@ -309,7 +314,7 @@ export function start ( stages.push(summaryStage) // Push all content to elastic search - const pushStage = new FullTextPushStage(storage, adapter, workspace) + const pushStage = new FullTextPushStage(storage, adapter, workspace, branding) stages.push(pushStage) // OpenAI prepare stage @@ -324,7 +329,7 @@ export function start ( return stages } - const pipelineFactory: PipelineFactory = (ctx, workspace, upgrade, broadcast) => { + const pipelineFactory: PipelineFactory = (ctx, workspace, upgrade, broadcast, branding) => { const wsMetrics = metrics.newChild('🧲 session', {}) const conf: DbConfiguration = { domains: { @@ -369,6 +374,7 @@ export function start ( createIndexStages( wsMetrics.newChild('stages', {}), workspace, + branding, adapter, storage, storageAdapter, @@ -392,14 +398,14 @@ export function start ( storageFactory: externalStorage, workspace } - return createPipeline(ctx, conf, middlewares, upgrade, broadcast) + return createPipeline(ctx, conf, middlewares, upgrade, broadcast, branding) } const sessionFactory = (token: Token, pipeline: Pipeline): Session => { 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(), { @@ -407,6 +413,7 @@ export function start ( sessionFactory, port: opt.port, productId: opt.productId, + brandingMap: opt.brandingMap, serverFactory: opt.serverFactory, enableCompression: opt.enableCompression, accountsUrl: opt.accountsUrl, diff --git a/server-plugins/chunter-resources/src/index.ts b/server-plugins/chunter-resources/src/index.ts index f6a7268186..021638b481 100644 --- a/server-plugins/chunter-resources/src/index.ts +++ b/server-plugins/chunter-resources/src/index.ts @@ -54,7 +54,7 @@ import { NOTIFICATION_BODY_SIZE } from '@hcengineering/server-notification' */ export async function channelHTMLPresenter (doc: Doc, control: TriggerControl): Promise { 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 link = concatLink(front, path) return `${channel.name}` diff --git a/server-plugins/contact-resources/src/index.ts b/server-plugins/contact-resources/src/index.ts index c021bafb4c..d42140bf38 100644 --- a/server-plugins/contact-resources/src/index.ts +++ b/server-plugins/contact-resources/src/index.ts @@ -202,10 +202,10 @@ export async function OnChannelUpdate (tx: Tx, control: TriggerControl): Promise */ export async function personHTMLPresenter (doc: Doc, control: TriggerControl): Promise { 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 link = concatLink(front, path) - return `${getName(control.hierarchy, person)}` + return `${getName(control.hierarchy, person, control.branding?.lastNameFirst)}` } /** @@ -213,7 +213,7 @@ export async function personHTMLPresenter (doc: Doc, control: TriggerControl): P */ export function personTextPresenter (doc: Doc, control: TriggerControl): string { 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 { 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 link = concatLink(front, path) return `${organization.name}` @@ -240,7 +240,7 @@ export function organizationTextPresenter (doc: Doc): string { */ export function contactNameProvider (hierarchy: Hierarchy, props: Record): string { const _class = props._class !== undefined ? (props._class as Ref>) : 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): Promise { @@ -249,7 +249,7 @@ export async function getCurrentEmployeeName (control: TriggerControl, context: }) if (account === undefined) return '' 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): Promise { @@ -281,7 +281,7 @@ export async function getContactName ( const value = context[contact.class.Contact] as Contact if (value === undefined) return if (control.hierarchy.isDerived(value._class, contact.class.Person)) { - return getName(control.hierarchy, value) + return getName(control.hierarchy, value, control.branding?.lastNameFirst) } else { return value.name } diff --git a/server-plugins/document-resources/src/index.ts b/server-plugins/document-resources/src/index.ts index 6ce9554a78..80fb1d7807 100644 --- a/server-plugins/document-resources/src/index.ts +++ b/server-plugins/document-resources/src/index.ts @@ -20,7 +20,7 @@ function getDocumentId (doc: Document): string { */ export async function documentHTMLPresenter (doc: Doc, control: TriggerControl): Promise { 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 link = concatLink(front, path) return `${document.name}` diff --git a/server-plugins/guest-resources/src/index.ts b/server-plugins/guest-resources/src/index.ts index 55a6ccd48d..3c1ff831c8 100644 --- a/server-plugins/guest-resources/src/index.ts +++ b/server-plugins/guest-resources/src/index.ts @@ -14,6 +14,7 @@ // import { + Branding, Doc, Hierarchy, Ref, @@ -46,7 +47,7 @@ export async function OnPublicLinkCreate (tx: Tx, control: TriggerControl): Prom if (link.url !== '') return res 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) @@ -54,22 +55,23 @@ export async function OnPublicLinkCreate (tx: Tx, control: TriggerControl): Prom return res } -export function getPublicLinkUrl (workspace: WorkspaceIdWithUrl): string { - const front = getMetadata(serverCore.metadata.FrontUrl) ?? '' +export function getPublicLinkUrl (workspace: WorkspaceIdWithUrl, brandedFront?: string): string { + const front = brandedFront ?? getMetadata(serverCore.metadata.FrontUrl) ?? '' const path = `${guestId}/${workspace.workspaceUrl}` return concatLink(front, path) } -function generateUrl (linkId: Ref, workspace: WorkspaceIdWithUrl): string { +function generateUrl (linkId: Ref, workspace: WorkspaceIdWithUrl, brandedFront?: string): string { const token = generateToken(guestAccountEmail, workspace, { linkId, guest: 'true' }) - return `${getPublicLinkUrl(workspace)}?token=${token}` + return `${getPublicLinkUrl(workspace, brandedFront)}?token=${token}` } export async function getPublicLink ( doc: Doc, client: TxOperations, workspace: WorkspaceIdWithUrl, - revokable: boolean = true + revokable: boolean = true, + branding: Branding | null ): Promise { const current = await client.findOne(guest.class.PublicLink, { attachedTo: doc._id }) if (current !== undefined) { @@ -79,7 +81,7 @@ export async function getPublicLink ( return current.url } const id = generateId() - const url = generateUrl(id, workspace) + const url = generateUrl(id, workspace, branding?.front) const fragment = getDocFragment(doc, client) await client.createDoc( guest.class.PublicLink, diff --git a/server-plugins/hr-resources/src/index.ts b/server-plugins/hr-resources/src/index.ts index 98f0375eff..f08dd13917 100644 --- a/server-plugins/hr-resources/src/index.ts +++ b/server-plugins/hr-resources/src/index.ts @@ -278,7 +278,7 @@ async function sendEmailNotifications ( 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, '') 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 { const request = doc as Request 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 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 { const request = doc as Request 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 date = tzDateEqual(request.tzDate, request.tzDueDate) @@ -401,7 +401,7 @@ export async function PublicHolidayHTMLPresenter (doc: Doc, control: TriggerCont if (sender === undefined) return '' const employee = await getEmployee(sender.person as Ref, control) 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()}` @@ -417,7 +417,7 @@ export async function PublicHolidayTextPresenter (doc: Doc, control: TriggerCont if (sender === undefined) return '' const employee = await getEmployee(sender.person as Ref, control) 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()}` diff --git a/server-plugins/inventory-resources/src/index.ts b/server-plugins/inventory-resources/src/index.ts index 4cadfd9bbe..09780f1f35 100644 --- a/server-plugins/inventory-resources/src/index.ts +++ b/server-plugins/inventory-resources/src/index.ts @@ -25,7 +25,7 @@ import { workbenchId } from '@hcengineering/workbench' */ export async function productHTMLPresenter (doc: Doc, control: TriggerControl): Promise { 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 link = concatLink(front, path) return `${product.name}` diff --git a/server-plugins/lead-resources/src/index.ts b/server-plugins/lead-resources/src/index.ts index 8dc4cd84ed..fb87191800 100644 --- a/server-plugins/lead-resources/src/index.ts +++ b/server-plugins/lead-resources/src/index.ts @@ -26,7 +26,7 @@ import { workbenchId } from '@hcengineering/workbench' */ export async function leadHTMLPresenter (doc: Doc, control: TriggerControl): Promise { 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 link = concatLink(front, path) return `${lead.title}` diff --git a/server-plugins/love-resources/src/index.ts b/server-plugins/love-resources/src/index.ts index 41e2a834c4..3ee7fd4fdc 100644 --- a/server-plugins/love-resources/src/index.ts +++ b/server-plugins/love-resources/src/index.ts @@ -82,7 +82,7 @@ async function createUserInfo (acc: Ref, control: TriggerControl): Prom const room = (await control.findAll(love.class.Office, { person: personId }))[0] const tx = control.txFactory.createTxCreateDoc(love.class.ParticipantInfo, love.space.Rooms, { 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, x: 0, y: 0 @@ -266,7 +266,9 @@ export async function OnKnock (tx: Tx, control: TriggerControl): Promise { ) { const path = [workbenchId, control.workspace.workspaceUrl, loveId] 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) } } @@ -294,7 +296,9 @@ export async function OnInvite (tx: Tx, control: TriggerControl): Promise const title = await translate(love.string.InivitingLabel, {}) const body = 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 createPushNotification(control, userAcc._id, title, body, invite._id, from, path) } diff --git a/server-plugins/notification-resources/src/index.ts b/server-plugins/notification-resources/src/index.ts index 13024fd593..f652951595 100644 --- a/server-plugins/notification-resources/src/index.ts +++ b/server-plugins/notification-resources/src/index.ts @@ -229,7 +229,7 @@ async function notifyByEmail ( if (sender !== undefined) { 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) @@ -611,7 +611,7 @@ export async function createPushNotification ( if (_id !== undefined) { 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 domainPath = `${workbenchId}/${control.workspace.workspaceUrl}` const domain = concatLink(front, domainPath) diff --git a/server-plugins/notification-resources/src/utils.ts b/server-plugins/notification-resources/src/utils.ts index 4290a5aa50..ca1920f924 100644 --- a/server-plugins/notification-resources/src/utils.ts +++ b/server-plugins/notification-resources/src/utils.ts @@ -327,7 +327,7 @@ async function getFallbackNotificationFullfillment ( if (account !== undefined) { const senderPerson = (cache.get(account.person) as Person) ?? (await findPersonForAccount(control, account.person)) if (senderPerson !== undefined) { - intlParams.senderName = formatName(senderPerson.name) + intlParams.senderName = formatName(senderPerson.name, control.branding?.lastNameFirst) cache.set(senderPerson._id, senderPerson) } } diff --git a/server-plugins/recruit-resources/src/index.ts b/server-plugins/recruit-resources/src/index.ts index 222cd9688e..abb5e73c63 100644 --- a/server-plugins/recruit-resources/src/index.ts +++ b/server-plugins/recruit-resources/src/index.ts @@ -46,7 +46,7 @@ function getSequenceId (doc: Vacancy | Applicant, control: TriggerControl): stri */ export async function vacancyHTMLPresenter (doc: Doc, control: TriggerControl): Promise { 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 link = concatLink(front, path) return `${vacancy.name}` @@ -65,7 +65,7 @@ export async function vacancyTextPresenter (doc: Doc): Promise { */ export async function applicationHTMLPresenter (doc: Doc, control: TriggerControl): Promise { 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 path = `${workbenchId}/${control.workspace.workspaceUrl}/${recruitId}/${id}` const link = concatLink(front, path) diff --git a/server-plugins/tracker-resources/src/index.ts b/server-plugins/tracker-resources/src/index.ts index f472d07f2e..5d896be831 100644 --- a/server-plugins/tracker-resources/src/index.ts +++ b/server-plugins/tracker-resources/src/index.ts @@ -59,7 +59,7 @@ async function updateSubIssues ( */ export async function issueHTMLPresenter (doc: Doc, control: TriggerControl): Promise { 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 link = concatLink(front, path) return `${issue.identifier} ${issue.title}` diff --git a/server-plugins/training-resources/src/functions/TrainingRequestHTMLPresenter.ts b/server-plugins/training-resources/src/functions/TrainingRequestHTMLPresenter.ts index 5a7e5c2d7d..0e96b9bcbc 100644 --- a/server-plugins/training-resources/src/functions/TrainingRequestHTMLPresenter.ts +++ b/server-plugins/training-resources/src/functions/TrainingRequestHTMLPresenter.ts @@ -26,7 +26,7 @@ export const TrainingRequestHTMLPresenter: Presenter = async ( request: TrainingRequest, 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 const path = `${workbenchId}/${control.workspace.workspaceUrl}/${trainingId}/requests/${request._id}` const link = concatLink(front, path) diff --git a/server/account-service/src/index.ts b/server/account-service/src/index.ts index 8e28487777..9641da96c1 100644 --- a/server/account-service/src/index.ts +++ b/server/account-service/src/index.ts @@ -7,14 +7,13 @@ import account, { UpgradeWorker, accountId, cleanInProgressWorkspaces, - getMethods, - type BrandingMap + getMethods } from '@hcengineering/account' import accountEn from '@hcengineering/account/lang/en.json' import accountRu from '@hcengineering/account/lang/ru.json' import { Analytics } from '@hcengineering/analytics' 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 platform, { Severity, Status, addStringsLoader, setMetadata } from '@hcengineering/platform' import serverToken from '@hcengineering/server-token' @@ -101,7 +100,7 @@ export function serveAccount ( void client.then(async (p: MongoClient) => { 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. void cleanInProgressWorkspaces(db, productId) diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index b15dc4ef75..5998c0ab20 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -42,7 +42,8 @@ import core, { TxOperations, Version, versionToString, - WorkspaceId + WorkspaceId, + type Branding } from '@hcengineering/core' import { consoleModelLogger, MigrateOperation, ModelLogger } from '@hcengineering/model' 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.') return } - const front = getMetadata(accountPlugin.metadata.FrontURL) + const front = branding?.front ?? getMetadata(accountPlugin.metadata.FrontURL) if (front === undefined || front === '') { throw new Error('Please provide front url') } @@ -806,10 +807,12 @@ async function generateWorkspaceRecord ( email: string, productId: string, version: Data, + branding: Branding | null, workspaceName: string, fixedWorkspace?: string ): Promise { const coll = db.collection>(WORKSPACE_COLLECTION) + const brandingKey = branding?.key ?? 'huly' if (fixedWorkspace !== undefined) { const ws = await coll.find({ workspaceUrl: fixedWorkspace }).toArray() if ((await getWorkspaceById(db, productId, fixedWorkspace)) !== null || ws.length > 0) { @@ -822,6 +825,7 @@ async function generateWorkspaceRecord ( workspaceUrl: fixedWorkspace, productId, version, + branding: brandingKey, workspaceName, accounts: [], disabled: true, @@ -854,6 +858,7 @@ async function generateWorkspaceRecord ( workspaceUrl, productId, version, + branding: brandingKey, workspaceName, accounts: [], disabled: true, @@ -909,7 +914,7 @@ export async function createWorkspace ( await searchPromise // 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 @@ -1670,7 +1675,7 @@ export async function requestPassword ( if (sesURL === undefined || sesURL === '') { 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 === '') { throw new Error('Please provide front url') } @@ -1936,7 +1941,7 @@ export async function sendInvite ( if (sesURL === undefined || sesURL === '') { 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 === '') { 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 - /** * @public */ diff --git a/server/core/src/__tests__/memAdapters.ts b/server/core/src/__tests__/memAdapters.ts index b4abd91e8a..cc943fd5f0 100644 --- a/server/core/src/__tests__/memAdapters.ts +++ b/server/core/src/__tests__/memAdapters.ts @@ -3,6 +3,7 @@ import core, { ModelDb, TxProcessor, toFindResult, + type Branding, type Blob, type BlobLookup, type Class, @@ -157,7 +158,12 @@ export class MemStorageAdapter implements StorageAdapter { throw new Error('NoSuchKey') } - async lookup (ctx: MeasureContext, workspaceId: WorkspaceIdWithUrl, docs: Blob[]): Promise { + async lookup ( + ctx: MeasureContext, + workspaceId: WorkspaceIdWithUrl, + branding: Branding | null, + docs: Blob[] + ): Promise { return { lookups: docs as unknown as BlobLookup[] } } } diff --git a/server/core/src/indexer/fulltextPush.ts b/server/core/src/indexer/fulltextPush.ts index 1c0d70acfb..8fd60cdb38 100644 --- a/server/core/src/indexer/fulltextPush.ts +++ b/server/core/src/indexer/fulltextPush.ts @@ -26,7 +26,8 @@ import core, { type MeasureContext, type Ref, type WorkspaceId, - getFullTextContext + getFullTextContext, + type Branding } from '@hcengineering/core' import { jsonToText, markupToJSON } from '@hcengineering/text' import { type DbAdapter } from '../adapter' @@ -66,7 +67,8 @@ export class FullTextPushStage implements FullTextPipelineStage { constructor ( private readonly dbStorage: ServerStorage, readonly fulltextAdapter: FullTextAdapter, - readonly workspace: WorkspaceId + readonly workspace: WorkspaceId, + readonly branding: Branding | null ) {} async initialize (ctx: MeasureContext, storage: DbAdapter, pipeline: FullTextPipeline): Promise { @@ -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) bulk.push(elasticDoc) diff --git a/server/core/src/mapper.ts b/server/core/src/mapper.ts index 3853c764ad..271a06a2a0 100644 --- a/server/core/src/mapper.ts +++ b/server/core/src/mapper.ts @@ -1,5 +1,6 @@ import { docKey, + type Branding, type Class, type Doc, type DocIndexState, @@ -110,7 +111,8 @@ export async function updateDocWithPresenter ( refDocs: { parentDoc: DocIndexState | undefined spaceDoc: DocIndexState | undefined - } + }, + branding: Branding | null ): Promise { const searchPresenter = findSearchPresenter(hierarchy, doc.objectClass) if (searchPresenter === undefined) { @@ -134,7 +136,8 @@ export async function updateDocWithPresenter ( props.push({ name: 'searchShortTitle', config: searchPresenter.searchConfig.shortTitle, - provider: searchPresenter.getSearchShortTitle + provider: searchPresenter.getSearchShortTitle, + lastNameFirst: branding?.lastNameFirst }) } diff --git a/server/core/src/pipeline.ts b/server/core/src/pipeline.ts index 523da06b49..f2553c861c 100644 --- a/server/core/src/pipeline.ts +++ b/server/core/src/pipeline.ts @@ -28,7 +28,8 @@ import { type SearchResult, type StorageIterator, type Tx, - type TxResult + type TxResult, + type Branding } from '@hcengineering/core' import { type DbConfiguration } from './configuration' import { createServerStorage } from './server' @@ -49,7 +50,8 @@ export async function createPipeline ( conf: DbConfiguration, constructors: MiddlewareCreator[], upgrade: boolean, - broadcast: BroadcastFunc + broadcast: BroadcastFunc, + branding: Branding | null ): Promise { const broadcastHandlers: BroadcastFunc[] = [broadcast] const _broadcast: BroadcastFunc = ( @@ -65,7 +67,8 @@ export async function createPipeline ( async (ctx) => await createServerStorage(ctx, conf, { upgrade, - broadcast: _broadcast + broadcast: _broadcast, + branding }) ) const pipelineResult = await PipelineImpl.create(ctx.newChild('pipeline-operations', {}), storage, constructors) diff --git a/server/core/src/server/aggregator.ts b/server/core/src/server/aggregator.ts index 0cc631d917..d87cc45d74 100644 --- a/server/core/src/server/aggregator.ts +++ b/server/core/src/server/aggregator.ts @@ -6,7 +6,8 @@ import core, { type MeasureContext, type Ref, type WorkspaceId, - type WorkspaceIdWithUrl + type WorkspaceIdWithUrl, + type Branding } from '@hcengineering/core' import { type Readable } from 'stream' import { type RawDBAdapter } from '../adapter' @@ -292,14 +293,19 @@ export class AggregatorStorageAdapter implements StorageAdapter, StorageAdapterE return result } - async lookup (ctx: MeasureContext, workspaceId: WorkspaceIdWithUrl, docs: Blob[]): Promise { + async lookup ( + ctx: MeasureContext, + workspaceId: WorkspaceIdWithUrl, + branding: Branding | null, + docs: Blob[] + ): Promise { const result: BlobLookup[] = [] const byProvider = groupByArray(docs, (it) => it.provider) for (const [k, v] of byProvider.entries()) { const provider = this.adapters.get(k) 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) { await this.dbAdapter.update(ctx, workspaceId, DOMAIN_BLOB, upd.updates) } diff --git a/server/core/src/server/storage.ts b/server/core/src/server/storage.ts index df9e42c633..85b837305a 100644 --- a/server/core/src/server/storage.ts +++ b/server/core/src/server/storage.ts @@ -709,6 +709,7 @@ export class TServerStorage implements ServerStorage { operationContext: ctx, removedMap, workspace: this.workspaceId, + branding: this.options.branding, storageAdapter: this.storageAdapter, serviceAdaptersManager: this.serviceAdaptersManager, findAll: fAll(ctx.ctx), @@ -746,7 +747,8 @@ export class TServerStorage implements ServerStorage { sctx.sessionId, sctx.admin, [], - this.workspaceId + this.workspaceId, + this.options.branding ) const result = await performAsync(applyCtx) // We need to broadcast changes diff --git a/server/core/src/types.ts b/server/core/src/types.ts index 0b039560f5..fb42dc9817 100644 --- a/server/core/src/types.ts +++ b/server/core/src/types.ts @@ -40,7 +40,8 @@ import { type TxFactory, type TxResult, type WorkspaceId, - type WorkspaceIdWithUrl + type WorkspaceIdWithUrl, + type Branding } from '@hcengineering/core' import type { Asset, Resource } from '@hcengineering/platform' import { type Readable } from 'stream' @@ -68,7 +69,7 @@ export interface ServerStorage extends LowLevelStorage { close: () => Promise loadModel: (last: Timestamp, hash?: string) => Promise workspaceId: WorkspaceIdWithUrl - + branding?: string storageAdapter: StorageAdapter } @@ -81,6 +82,7 @@ export interface SessionContext extends SessionOperationContext { admin?: boolean workspace: WorkspaceIdWithUrl + branding: Branding | null } /** @@ -142,6 +144,7 @@ export interface TriggerControl { operationContext: SessionOperationContext ctx: MeasureContext workspace: WorkspaceIdWithUrl + branding: Branding | null txFactory: TxFactory findAll: Storage['findAll'] findAllCtx: ( @@ -438,6 +441,7 @@ export interface ServerStorageOptions { upgrade: boolean broadcast: BroadcastFunc + branding: Branding | null } export interface ServiceAdapter { diff --git a/server/core/src/utils.ts b/server/core/src/utils.ts index 0f2b6db4f2..a7c788c00d 100644 --- a/server/core/src/utils.ts +++ b/server/core/src/utils.ts @@ -10,9 +10,12 @@ import core, { type ParamsType, type Ref, type TxWorkspaceEvent, - type WorkspaceIdWithUrl + type WorkspaceIdWithUrl, + type Branding, + type BrandingMap } from '@hcengineering/core' import { type Hash } from 'crypto' +import fs from 'fs' import type { SessionContext } from './types' /** @@ -138,7 +141,8 @@ export class SessionContextImpl implements SessionContext { readonly sessionId: string, readonly admin: boolean | undefined, readonly derived: SessionContext['derived'], - readonly workspace: WorkspaceIdWithUrl + readonly workspace: WorkspaceIdWithUrl, + readonly branding: Branding | null ) {} with( @@ -151,7 +155,17 @@ export class SessionContextImpl implements SessionContext { name, params, 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 ) } @@ -171,3 +185,16 @@ export function createBroadcastEvent (classes: Ref>[]): TxWorkspaceEv 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 +} diff --git a/server/middleware/src/blobs.ts b/server/middleware/src/blobs.ts index 9233161d9a..374a6e0dc3 100644 --- a/server/middleware/src/blobs.ts +++ b/server/middleware/src/blobs.ts @@ -26,7 +26,8 @@ import core, { toIdMap, type Blob, type BlobLookup, - type WorkspaceIdWithUrl + type WorkspaceIdWithUrl, + type Branding } from '@hcengineering/core' import { Middleware, SessionContext, TxMiddlewareResult, type ServerStorage } from '@hcengineering/server-core' import { BaseMiddleware } from './base' @@ -50,12 +51,13 @@ export class BlobLookupMiddleware extends BaseMiddleware implements Middleware { async fetchBlobInfo ( ctx: MeasureContext, workspace: WorkspaceIdWithUrl, + branding: Branding | null, toUpdate: [Doc, Blob, string][] ): Promise { if (this.storage.storageAdapter.lookup !== undefined) { const docsToUpdate = toUpdate.map((it) => it[1]) const updatedBlobs = toIdMap( - (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) { const ublob = updatedBlobs.get(blob._id) @@ -78,7 +80,8 @@ export class BlobLookupMiddleware extends BaseMiddleware implements Middleware { if (_class === core.class.Blob) { // Bulk update of info const updatedBlobs = toIdMap( - (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[] = [] for (const d of result) { @@ -102,13 +105,13 @@ export class BlobLookupMiddleware extends BaseMiddleware implements Middleware { } if (toUpdate.length > 50) { // Bulk update of info - await this.fetchBlobInfo(ctx.ctx, ctx.workspace, toUpdate) + await this.fetchBlobInfo(ctx.ctx, ctx.workspace, ctx.branding, toUpdate) toUpdate = [] } } if (toUpdate.length > 0) { // Bulk update of info - await this.fetchBlobInfo(ctx.ctx, ctx.workspace, toUpdate) + await this.fetchBlobInfo(ctx.ctx, ctx.workspace, ctx.branding, toUpdate) toUpdate = [] } } diff --git a/server/minio/src/index.ts b/server/minio/src/index.ts index 9c5f17cd00..0de45c9742 100644 --- a/server/minio/src/index.ts +++ b/server/minio/src/index.ts @@ -24,7 +24,8 @@ import core, { type MeasureContext, type Ref, type WorkspaceId, - type WorkspaceIdWithUrl + type WorkspaceIdWithUrl, + type Branding } from '@hcengineering/core' import { getMetadata } from '@hcengineering/platform' @@ -76,8 +77,13 @@ export class MinioService implements StorageAdapter { async initialize (ctx: MeasureContext, workspaceId: WorkspaceId): Promise {} - async lookup (ctx: MeasureContext, workspaceId: WorkspaceIdWithUrl, docs: Blob[]): Promise { - const frontUrl = getMetadata(serverCore.metadata.FrontUrl) ?? '' + async lookup ( + ctx: MeasureContext, + workspaceId: WorkspaceIdWithUrl, + branding: Branding | null, + docs: Blob[] + ): Promise { + const frontUrl = branding?.front ?? getMetadata(serverCore.metadata.FrontUrl) ?? '' for (const d of docs) { // Let's add current from URI for previews. const bl = d as BlobLookup diff --git a/server/mongo/src/__tests__/storage.test.ts b/server/mongo/src/__tests__/storage.test.ts index 1b79c465da..e190fbd5fd 100644 --- a/server/mongo/src/__tests__/storage.test.ts +++ b/server/mongo/src/__tests__/storage.test.ts @@ -177,7 +177,8 @@ describe('mongo operations', () => { const ctx = new MeasureMetricsContext('client', {}) serverStorage = await createServerStorage(ctx, conf, { upgrade: false, - broadcast: () => {} + broadcast: () => {}, + branding: null }) const soCtx: SessionOperationContext = { ctx, diff --git a/server/s3/src/index.ts b/server/s3/src/index.ts index 999d0ee628..f711f0a09a 100644 --- a/server/s3/src/index.ts +++ b/server/s3/src/index.ts @@ -25,7 +25,8 @@ import core, { type MeasureContext, type Ref, type WorkspaceId, - type WorkspaceIdWithUrl + type WorkspaceIdWithUrl, + type Branding } from '@hcengineering/core' import { @@ -83,7 +84,12 @@ export class S3Service implements StorageAdapter { async initialize (ctx: MeasureContext, workspaceId: WorkspaceId): Promise {} - async lookup (ctx: MeasureContext, workspaceId: WorkspaceIdWithUrl, docs: Blob[]): Promise { + async lookup ( + ctx: MeasureContext, + workspaceId: WorkspaceIdWithUrl, + branding: Branding | null, + docs: Blob[] + ): Promise { const result: BlobLookupResult = { lookups: [], updates: new Map() diff --git a/server/server/src/backup.ts b/server/server/src/backup.ts index 1d2d539f8a..74cc1c3e92 100644 --- a/server/server/src/backup.ts +++ b/server/server/src/backup.ts @@ -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 { Token } from '@hcengineering/server-token' 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 { constructor ( 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 diff --git a/server/server/src/starter.ts b/server/server/src/starter.ts index cd7bdfb6ea..8d87611c7f 100644 --- a/server/server/src/starter.ts +++ b/server/server/src/starter.ts @@ -13,6 +13,7 @@ export interface ServerEnv { pushPublicKey: string | undefined pushPrivateKey: string | undefined pushSubject: string | undefined + brandingPath: string | undefined } export function serverConfigFromEnv (): ServerEnv { @@ -71,6 +72,8 @@ export function serverConfigFromEnv (): ServerEnv { const pushPublicKey = process.env.PUSH_PUBLIC_KEY const pushPrivateKey = process.env.PUSH_PRIVATE_KEY const pushSubject = process.env.PUSH_SUBJECT + const brandingPath = process.env.BRANDING_PATH + return { url, elasticUrl, @@ -85,6 +88,7 @@ export function serverConfigFromEnv (): ServerEnv { enableCompression, pushPublicKey, pushPrivateKey, - pushSubject + pushSubject, + brandingPath } } diff --git a/server/ws/src/__tests__/server.test.ts b/server/ws/src/__tests__/server.test.ts index efa106755a..ea68546417 100644 --- a/server/ws/src/__tests__/server.test.ts +++ b/server/ws/src/__tests__/server.test.ts @@ -82,9 +82,10 @@ describe('server', () => { return { docs: [] } } }), - sessionFactory: (token, pipeline) => new ClientSession(token, pipeline), + sessionFactory: (token, pipeline) => new ClientSession(token, pipeline, {}), port: 3335, productId: '', + brandingMap: {}, serverFactory: startHttpServer, accountsUrl: '', externalStorage: createDummyStorageAdapter() @@ -182,9 +183,10 @@ describe('server', () => { return { docs: [] } } }), - sessionFactory: (token, pipeline) => new ClientSession(token, pipeline), + sessionFactory: (token, pipeline) => new ClientSession(token, pipeline, {}), port: 3336, productId: '', + brandingMap: {}, serverFactory: startHttpServer, accountsUrl: '', externalStorage: createDummyStorageAdapter() diff --git a/server/ws/src/client.ts b/server/ws/src/client.ts index 784d835ae0..b9aeb8fd00 100644 --- a/server/ws/src/client.ts +++ b/server/ws/src/client.ts @@ -32,7 +32,9 @@ import core, { type Tx, type TxApplyIf, type TxApplyResult, - type TxCUD + type TxCUD, + type Branding, + type BrandingMap } from '@hcengineering/core' import { SessionContextImpl, createBroadcastEvent, type Pipeline } from '@hcengineering/server-core' import { type Token } from '@hcengineering/server-token' @@ -56,7 +58,8 @@ export class ClientSession implements Session { constructor ( protected readonly token: Token, - protected readonly _pipeline: Pipeline + protected readonly _pipeline: Pipeline, + protected readonly brandingMap: BrandingMap ) {} getUser (): string { @@ -75,6 +78,14 @@ export class ClientSession implements Session { return this._pipeline } + getBranding (brandingKey?: string): Branding | null { + if (brandingKey === undefined) { + return null + } + + return this.brandingMap[brandingKey] ?? null + } + async ping (ctx: ClientSessionCtx): Promise { // console.log('ping') this.lastRequest = Date.now() @@ -115,7 +126,8 @@ export class ClientSession implements Session { this.sessionId, 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) const acc = TxProcessor.createDoc2Doc(createTx) @@ -144,7 +156,8 @@ export class ClientSession implements Session { this.sessionId, 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) } @@ -166,7 +179,8 @@ export class ClientSession implements Session { this.sessionId, 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)) } @@ -181,7 +195,8 @@ export class ClientSession implements Session { this.sessionId, 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) diff --git a/server/ws/src/server.ts b/server/ws/src/server.ts index 13fb910240..6811fcc33a 100644 --- a/server/ws/src/server.ts +++ b/server/ws/src/server.ts @@ -26,7 +26,9 @@ import core, { type MeasureContext, type Tx, type TxWorkspaceEvent, - type WorkspaceId + type WorkspaceId, + type Branding, + type BrandingMap } from '@hcengineering/core' import { unknownError, type Status } from '@hcengineering/platform' import { type HelloRequest, type HelloResponse, type Request, type Response } from '@hcengineering/rpc' @@ -92,7 +94,8 @@ class TSessionManager implements SessionManager { constructor ( readonly ctx: MeasureContext, readonly sessionFactory: (token: Token, pipeline: Pipeline) => Session, - readonly timeouts: Timeouts + readonly timeouts: Timeouts, + readonly brandingMap: BrandingMap ) { this.checkInterval = setInterval(() => { this.handleInterval() @@ -297,6 +300,10 @@ class TSessionManager implements SessionManager { await this.close(ctx, oldSession.socket, wsString) } 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) { ctx.warn('open workspace', { @@ -310,7 +317,8 @@ class TSessionManager implements SessionManager { pipelineFactory, token, workspaceInfo.workspaceUrl ?? workspaceInfo.workspaceId, - workspaceName + workspaceName, + branding ) } @@ -424,7 +432,8 @@ class TSessionManager implements SessionManager { true, (tx, targets, exclude) => { this.broadcastAll(workspace, tx, targets, exclude) - } + }, + workspace.branding ) return await workspace.pipeline } @@ -513,7 +522,8 @@ class TSessionManager implements SessionManager { pipelineFactory: PipelineFactory, token: Token, workspaceUrl: string, - workspaceName: string + workspaceName: string, + branding: Branding | null ): Workspace { const upgrade = token.extra?.model === 'upgrade' const backup = token.extra?.mode === 'backup' @@ -528,14 +538,16 @@ class TSessionManager implements SessionManager { upgrade, (tx, targets) => { this.broadcastAll(workspace, tx, targets) - } + }, + branding ), sessions: new Map(), softShutdown: 3, upgrade, backup, workspaceId: token.workspace, - workspaceName + workspaceName, + branding } this.workspaces.set(toWorkspaceString(token.workspace), workspace) return workspace @@ -970,16 +982,22 @@ export function start ( pipelineFactory: PipelineFactory sessionFactory: (token: Token, pipeline: Pipeline) => Session productId: string + brandingMap: BrandingMap serverFactory: ServerFactory enableCompression?: boolean accountsUrl: string externalStorage: StorageAdapter } & Partial ): () => Promise { - const sessions = new TSessionManager(ctx, opt.sessionFactory, { - pingTimeout: opt.pingTimeout ?? 10000, - reconnectTimeout: 500 - }) + const sessions = new TSessionManager( + ctx, + opt.sessionFactory, + { + pingTimeout: opt.pingTimeout ?? 10000, + reconnectTimeout: 500 + }, + opt.brandingMap + ) return opt.serverFactory( sessions, (rctx, service, ws, msg, workspace) => { diff --git a/server/ws/src/types.ts b/server/ws/src/types.ts index 129929b04c..ac2cc37c40 100644 --- a/server/ws/src/types.ts +++ b/server/ws/src/types.ts @@ -8,7 +8,8 @@ import { type Ref, type Tx, type WorkspaceId, - type WorkspaceIdWithUrl + type WorkspaceIdWithUrl, + type Branding } from '@hcengineering/core' import { type Request, type Response } from '@hcengineering/rpc' import { type BroadcastFunc, type Pipeline, type StorageAdapter } from '@hcengineering/server-core' @@ -92,7 +93,8 @@ export type PipelineFactory = ( ctx: MeasureContext, ws: WorkspaceIdWithUrl, upgrade: boolean, - broadcast: BroadcastFunc + broadcast: BroadcastFunc, + branding: Branding | null ) => Promise /** @@ -134,6 +136,7 @@ export interface Workspace { workspaceId: WorkspaceId workspaceName: string + branding: Branding | null } export interface AddSessionActive { diff --git a/tests/docker-compose.yaml b/tests/docker-compose.yaml index e02d1e6c59..af42520640 100644 --- a/tests/docker-compose.yaml +++ b/tests/docker-compose.yaml @@ -39,6 +39,8 @@ services: - minio ports: - 3003:3003 + volumes: + - ./branding-test.json:/var/cfg/branding.json environment: - ACCOUNT_PORT=3003 - SERVER_SECRET=secret @@ -47,6 +49,7 @@ services: - ENDPOINT_URL=ws://localhost:3334 - STORAGE_CONFIG=${STORAGE_CONFIG} - MODEL_ENABLED=* + - BRANDING_PATH=/var/cfg/branding.json front: image: hardcoreeng/front pull_policy: never @@ -87,6 +90,8 @@ services: - account ports: - 3334:3334 + volumes: + - ./branding-test.json:/var/cfg/branding.json environment: - SERVER_PROVIDER=${SERVER_PROVIDER} - SERVER_PORT=3334 @@ -102,6 +107,7 @@ services: - ACCOUNTS_URL=http://account:3003 - LAST_NAME_FIRST=true - ELASTIC_INDEX_NAME=local_storage_index + - BRANDING_PATH=/var/cfg/branding.json collaborator: image: hardcoreeng/collaborator links: