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')
.option('-e, --email <email>', 'Author email', 'platform@email.com')
.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()
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

View File

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

View File

@ -67,3 +67,14 @@ export interface LowLevelStorage {
// Remove a list of documents.
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 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<Readable>
// 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 {
@ -174,7 +180,12 @@ export class DummyStorageAdapter implements StorageAdapter, StorageAdapterEx {
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: [] }
}
}

View File

@ -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<Class<Doc>>): boolean
/**
* @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)) {
return formatName(name)
return formatName(name, lastNameFirst)
}
return name
}

View File

@ -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"
}
}

View File

@ -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))

View File

@ -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 })

View File

@ -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 })
}

View File

@ -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)
}

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 { 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
})

View File

@ -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,

View File

@ -54,7 +54,7 @@ import { NOTIFICATION_BODY_SIZE } from '@hcengineering/server-notification'
*/
export async function channelHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> {
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 `<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> {
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 `<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 {
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> {
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 `<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 {
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> {
@ -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<string, Doc>): Promise<string> {
@ -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
}

View File

@ -20,7 +20,7 @@ function getDocumentId (doc: Document): string {
*/
export async function documentHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> {
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 `<a href="${link}">${document.name}</a>`

View File

@ -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<PublicLink>, workspace: WorkspaceIdWithUrl): string {
function generateUrl (linkId: Ref<PublicLink>, 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<string> {
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<PublicLink>()
const url = generateUrl(id, workspace)
const url = generateUrl(id, workspace, branding?.front)
const fragment = getDocFragment(doc, client)
await client.createDoc(
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 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<string> {
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<string> {
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<Employee>, 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<Employee>, 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()}`

View File

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

View File

@ -26,7 +26,7 @@ import { workbenchId } from '@hcengineering/workbench'
*/
export async function leadHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> {
const lead = doc as Lead
const front = getMetadata(serverCore.metadata.FrontUrl) ?? ''
const 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 `<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 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<Tx[]> {
) {
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<Tx[]>
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)
}

View File

@ -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)

View File

@ -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)
}
}

View File

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

View File

@ -59,7 +59,7 @@ async function updateSubIssues (
*/
export async function issueHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> {
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 `<a href="${link}">${issue.identifier}</a> ${issue.title}`

View File

@ -26,7 +26,7 @@ export const TrainingRequestHTMLPresenter: Presenter<TrainingRequest> = 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)

View File

@ -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)

View File

@ -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<Version>,
branding: Branding | null,
workspaceName: string,
fixedWorkspace?: string
): Promise<Workspace> {
const coll = db.collection<Omit<Workspace, '_id'>>(WORKSPACE_COLLECTION)
const brandingKey = branding?.key ?? 'huly'
if (fixedWorkspace !== undefined) {
const ws = await coll.find<Workspace>({ 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<string, Branding>
/**
* @public
*/

View File

@ -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<BlobLookupResult> {
async lookup (
ctx: MeasureContext,
workspaceId: WorkspaceIdWithUrl,
branding: Branding | null,
docs: Blob[]
): Promise<BlobLookupResult> {
return { lookups: docs as unknown as BlobLookup[] }
}
}

View File

@ -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<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)
bulk.push(elasticDoc)

View File

@ -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<void> {
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
})
}

View File

@ -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<Pipeline> {
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)

View File

@ -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<BlobLookupResult> {
async lookup (
ctx: MeasureContext,
workspaceId: WorkspaceIdWithUrl,
branding: Branding | null,
docs: Blob[]
): Promise<BlobLookupResult> {
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)
}

View File

@ -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

View File

@ -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<void>
loadModel: (last: Timestamp, hash?: string) => Promise<Tx[] | LoadModelResponse>
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: <T extends Doc>(
@ -438,6 +441,7 @@ export interface ServerStorageOptions {
upgrade: boolean
broadcast: BroadcastFunc
branding: Branding | null
}
export interface ServiceAdapter {

View File

@ -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<T>(
@ -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<Class<Doc>>[]): 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
}

View File

@ -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<void> {
if (this.storage.storageAdapter.lookup !== undefined) {
const docsToUpdate = toUpdate.map((it) => it[1])
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) {
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<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[] = []
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 = []
}
}

View File

@ -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<void> {}
async lookup (ctx: MeasureContext, workspaceId: WorkspaceIdWithUrl, docs: Blob[]): Promise<BlobLookupResult> {
const frontUrl = getMetadata(serverCore.metadata.FrontUrl) ?? ''
async lookup (
ctx: MeasureContext,
workspaceId: WorkspaceIdWithUrl,
branding: Branding | null,
docs: Blob[]
): Promise<BlobLookupResult> {
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

View File

@ -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,

View File

@ -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<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 = {
lookups: [],
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 { 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

View File

@ -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
}
}

View File

@ -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()

View File

@ -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<void> {
// 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)

View File

@ -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<Timeouts>
): () => Promise<void> {
const sessions = new TSessionManager(ctx, opt.sessionFactory, {
const sessions = new TSessionManager(
ctx,
opt.sessionFactory,
{
pingTimeout: opt.pingTimeout ?? 10000,
reconnectTimeout: 500
})
},
opt.brandingMap
)
return opt.serverFactory(
sessions,
(rctx, service, ws, msg, workspace) => {

View File

@ -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<Pipeline>
/**
@ -134,6 +136,7 @@ export interface Workspace {
workspaceId: WorkspaceId
workspaceName: string
branding: Branding | null
}
export interface AddSessionActive {

View File

@ -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: