import { getRandStr , getStoreItem , sleep , TCCSModuleShortInfo , TCCSVersion , TCromwellBlockData , TPageConfig , TPageInfo , TPalette , TThemeConfig , TThemeEntity , TThemeEntityInput , } from '@cromwell/core' ; import { configFileName , GenericTheme , getCmsEntity , getCmsModuleInfo , getCmsSettings , getLogger , getModulePackage , getModuleStaticDir , getNodeModuleDir , getPluginSettings , getPublicThemesDir , getThemeConfigs , runShellCommand , TAllThemeConfigs , } from '@cromwell/core-backend' ; import { getCentralServerClient } from '@cromwell/core-frontend' ; import { HttpException , HttpStatus , Injectable } from '@nestjs/common' ; import decache from 'decache' ; import fs from 'fs-extra' ; import { resolve } from 'path' ; import { Container , Service } from 'typedi' ; import { getConnection , getCustomRepository } from 'typeorm' ; import { resetAllPagesCache } from '../helpers/reset-page' ; import { serverFireAction } from '../helpers/server-fire-action' ; import { childSendMessage } from '../helpers/server-manager' ; import { endTransaction , restartService , setPendingKill , startTransaction } from '../helpers/state-manager' ; import { CmsService } from './cms.service' ; import { PluginService } from './plugin.service' ; const logger = getLogger ( ) ; @ Injectable ( ) @ Service ( ) export class ThemeService { private get cmsService ( ) { return Container . get ( CmsService ) ; } private get pluginService ( ) { return Container . get ( PluginService ) ; } constructor ( ) { this . init ( ) ; } private async init ( ) { if ( ! getConnection ( ) ?. isConnected ) return ; } async findOne ( themeName ) { const themeRepo = getCustomRepository ( GenericTheme . repository ) ; return themeRepo . findOne ( { where : { name : themeName } } ) ; } public async saveEntity ( theme ) { const themeRepo = getCustomRepository ( GenericTheme . repository ) ; return themeRepo . save ( theme ) ; } public async createEntity ( theme ) { const themeRepo = getCustomRepository ( GenericTheme . repository ) ; return themeRepo . createEntity ( theme ) ; } public getAll ( ) { const themeRepo = getCustomRepository ( GenericTheme . repository ) ; return themeRepo . find ( ) ; } param cmsConfig param cb public async saveThemeUserConfig ( themeConfig ) { const cmsSettings = await getCmsSettings ( ) ; if ( cmsSettings ?. themeName ) { const theme = await this . findOne ( cmsSettings . themeName ) ; if ( theme ) { theme . settings = JSON . stringify ( themeConfig , null , 4 ) ; await this . saveEntity ( theme ) ; return true ; } } return false ; } param cmsConfig param cb public async saveThemeOriginalConfig ( themeConfig ) { const cmsSettings = await getCmsSettings ( ) ; if ( cmsSettings ?. themeName ) { const theme = await this . findOne ( cmsSettings . themeName ) ; if ( theme ) { theme . defaultSettings = JSON . stringify ( themeConfig , null , 4 ) ; await this . saveEntity ( theme ) ; return true ; } } return false ; } param themeMods param userMods public mergeMods ( themeMods ? , userMods ? ) { const mods = Array . isArray ( themeMods ) ? [ ... themeMods ] : [ ] ; if ( Array . isArray ( userMods ) ) { userMods . forEach ( userMod => { let hasOriginally = false ; mods . forEach ( ( themeMod , i ) => { if ( themeMod . id === userMod . id ) { mods [ i ] = userMod ; hasOriginally = true ; } } ) ; if ( ! hasOriginally ) { mods . push ( userMod ) ; } } ) ; } return mods ; } param themeConfig param userConfig param globalThemeMods param globalUserMods public mergePages ( themeConfig ? , userConfig ? , globalThemeMods ? , globalUserMods ? ) { const globalModifications = this . mergeMods ( globalThemeMods , globalUserMods ) ; let mods = this . mergeMods ( themeConfig ?. modifications , userConfig ?. modifications ) ; mods = this . mergeMods ( globalModifications , mods ) ; const config = Object . assign ( { } , themeConfig , userConfig ) ; config . modifications = mods ; return config ; } public getPageConfigFromThemeConfig ( themeConfig , pageRoute , pageId ? ) { if ( themeConfig ?. pages && Array . isArray ( themeConfig . pages ) ) { for ( const p of themeConfig . pages ) { if ( p . id === pageId ) return p ; if ( p . route === pageRoute ) return p ; } } } param pageRoute param cb public async getPageConfig ( pageRoute , allConfigs ? ) { if ( ! allConfigs ) allConfigs = await getThemeConfigs ( ) ; const { themeConfig , userConfig } = allConfigs ; const userPageConfig = this . getPageConfigFromThemeConfig ( userConfig , pageRoute ) ; const themePageConfig = this . getPageConfigFromThemeConfig ( themeConfig , pageRoute , userPageConfig ?. id ) ; const pageConfig = this . mergePages ( themePageConfig , userPageConfig , themeConfig ?. globalModifications , userConfig ?. globalModifications ) ; return pageConfig ; } param userConfig public async saveUserPageConfig ( userPageConfig ) { if ( ! userPageConfig ) { logger . error ( 'Server::saveUserPageConfig: Invalid userPageConfig' ) ; return false ; } if ( ! userPageConfig . route ) { logger . error ( 'Server::saveUserPageConfig: Invalid userPageConfig, no route' , JSON . stringify ( userPageConfig ) ) ; throw new HttpException ( "Invalid page config, no route" , HttpStatus . NOT_ACCEPTABLE ) ; } if ( ! userPageConfig . name ) { logger . error ( 'Server::saveUserPageConfig: Invalid userPageConfig, no name' , JSON . stringify ( userPageConfig ) ) ; throw new HttpException ( "Invalid page config, no name" , HttpStatus . NOT_ACCEPTABLE ) ; } if ( ! userPageConfig . modifications ) { logger . error ( 'Server::saveUserPageConfig: Invalid userPageConfig, no modifications' , JSON . stringify ( userPageConfig ) ) ; throw new HttpException ( "Invalid page config, no modifications" , HttpStatus . NOT_ACCEPTABLE ) ; } const config = await getThemeConfigs ( ) ; let userConfig = config . userConfig ; const themeConfig = config . themeConfig ; const allPages = [ ... ( userConfig ?. pages ?? [ ] ) , ... ( themeConfig ?. pages ?? [ ] ) ] ; allPages . forEach ( page => { if ( page . route === userPageConfig . route && page . id !== userPageConfig . id ) { logger . error ( 'Server::saveUserPageConfig: page with this route already exists' , JSON . stringify ( userPageConfig ) ) ; throw new HttpException ( "Page with this route already exists" , HttpStatus . NOT_ACCEPTABLE ) ; } } ) ; const cmsSettings = config . cmsSettings ; if ( ! userConfig ) { userConfig = { pages : [ ] } ; } if ( ! userConfig . pages ) userConfig . pages = [ ] ; const oldUserPageConfig = this . getPageConfigFromThemeConfig ( userConfig , userPageConfig . route , userPageConfig . id ) ; for ( const mod of userPageConfig . modifications ) { if ( ! mod . global && userConfig . globalModifications ) { for ( const globalMod of userConfig . globalModifications ) { if ( mod . id === globalMod . id ) { userConfig . globalModifications = userConfig . globalModifications ?. filter ( filteredMod => filteredMod . id !== mod . id ) ; } } } } const globalMods = [ ] ; userPageConfig . modifications = userPageConfig . modifications . filter ( mod => { if ( mod . global ) { globalMods . push ( mod ) ; return false ; } return true ; } ) ; userConfig . globalModifications = this . mergeMods ( userConfig ?. globalModifications , globalMods ) ; const filteredUserPageConfig = { ... userPageConfig , modifications : [ ... userPageConfig . modifications ] } ; userPageConfig . modifications . forEach ( mod => { if ( ! mod . isDeleted ) return ; filteredUserPageConfig . modifications = filteredUserPageConfig . modifications . map ( userMode => userMode . id === mod . id ? { id : mod . id , isDeleted : true , type : mod . type } : userMode ) ; } ) ; const pageConfig = this . mergePages ( oldUserPageConfig , filteredUserPageConfig ) ; let pageIndex ; userConfig . pages . forEach ( ( page , i ) => { if ( pageIndex !== undefined ) return ; if ( page . id === pageConfig . id ) pageIndex = i ; if ( page . route === pageConfig . route ) pageIndex = i ; } ) ; if ( pageIndex !== undefined ) { userConfig . pages [ pageIndex ] = pageConfig ; } else { userConfig . pages . push ( pageConfig ) ; } if ( cmsSettings ) { const success = await this . saveThemeUserConfig ( userConfig ) ; if ( success ) resetAllPagesCache ( ) ; return success ; } return false ; } public async getThemePalette ( ) { const configs = await getThemeConfigs ( ) ; return Object . assign ( { } , configs . themeConfig ?. palette , configs . userConfig ?. palette ) ; } public async saveThemePalette ( palette ) { if ( ! palette ) { logger . error ( 'Server::saveThemePalette: Invalid palette' ) ; return false ; } const userConfig = ( await getThemeConfigs ( ) ) . userConfig ?? { } ; if ( ! userConfig . palette ) userConfig . palette = { } ; userConfig . palette = Object . assign ( { } , userConfig . palette , palette ) ; return await this . saveThemeUserConfig ( userConfig ) ; } param cb public async readAllPageConfigs ( allConfigs ? ) { logger . log ( 'ThemeController::readAllPageConfigs' ) ; if ( ! allConfigs ) allConfigs = await getThemeConfigs ( ) ; const { themeConfig , userConfig } = allConfigs ; let pages = [ ] ; if ( themeConfig ?. pages && Array . isArray ( themeConfig . pages ) ) { pages = themeConfig . pages . filter ( p => p . id ) ; } const pageIds = [ ] ; pages . forEach ( p => pageIds . push ( p . id ) ) ; if ( userConfig && userConfig . pages && Array . isArray ( userConfig . pages ) ) { userConfig . pages . forEach ( p => { if ( p . id ) { if ( pageIds . includes ( p . id ) ) { const i = pageIds . indexOf ( p . id ) ; pages [ i ] = this . mergePages ( pages [ i ] , p ) ; } else { pages . push ( p ) ; } } } ) ; } const globalModifications = this . mergeMods ( themeConfig ?. globalModifications , userConfig ?. globalModifications ) ; pages . forEach ( p => p . modifications = this . mergeMods ( globalModifications , p . modifications ) ) ; return pages ; } public async getPagesInfo ( allConfigs ? ) { const out = [ ] ; const pages = await this . readAllPageConfigs ( allConfigs ) ; pages . forEach ( p => { const info = { id : p . id , route : p . route , name : p . name , title : p . title , isVirtual : p . isVirtual , } ; out . push ( info ) ; } ) ; return out ; } public async deletePage ( pageRoute ) { let page = null ; page = await this . getPageConfig ( pageRoute ) ; if ( ! page ) throw new HttpException ( "Page was not found by pageRoute" , HttpStatus . NOT_ACCEPTABLE ) ; if ( ! page . isVirtual ) throw new HttpException ( "Page cannot be deleted" , HttpStatus . NOT_ACCEPTABLE ) ; const configs = await getThemeConfigs ( ) ; if ( configs . themeConfig ?. pages ) { configs . themeConfig . pages = configs . themeConfig . pages . filter ( p => { if ( p . isVirtual && p . route === page ?. route && p . id === page ?. id ) return false ; return true ; } ) ; await this . saveThemeOriginalConfig ( configs . themeConfig ) ; } if ( configs . userConfig ?. pages ) { configs . userConfig . pages = configs . userConfig . pages . filter ( p => { if ( p . isVirtual && p . route === page ?. route && p . id === page ?. id ) return false ; return true ; } ) ; await this . saveThemeUserConfig ( configs . userConfig ) ; } return true ; } public async resetPage ( pageRoute ) { let page = null ; page = await this . getPageConfig ( pageRoute ) ; if ( ! page ) throw new HttpException ( "Page was not found by pageRoute" , HttpStatus . NOT_ACCEPTABLE ) ; const configs = await getThemeConfigs ( ) ; if ( configs . userConfig ?. pages ) { configs . userConfig . pages = configs . userConfig . pages . map ( userPage => { if ( page && userPage . route === page . route && userPage . id === page . id ) { userPage . modifications = [ ] ; } return userPage ; } ) ; await this . saveThemeUserConfig ( configs . userConfig ) ; } await resetAllPagesCache ( ) ; return true ; } public async getPluginsAtPage ( pageRoute , pageConfig ? ) { const out = { } ; if ( ! pageConfig ) pageConfig = await this . getPageConfig ( pageRoute ) ; if ( pageConfig && pageConfig . modifications && Array . isArray ( pageConfig . modifications ) ) { for ( const mod of pageConfig . modifications ) { const pluginName = mod ?. plugin ?. pluginName ; if ( ! pluginName ) continue ; try { if ( out [ pluginName ] ) { if ( mod ?. plugin ?. instanceSettings ) { if ( ! out [ pluginName ] . pluginInstances ) out [ pluginName ] . pluginInstances = { } ; out [ pluginName ] . pluginInstances ! [ mod . id ] = mod . plugin . instanceSettings ; } continue ; } const plugin = await this . pluginService . findOne ( pluginName ) ; if ( ! plugin ) continue ; out [ pluginName ] = { pluginName , version : plugin . version , pluginInstances : { [ mod . id ] : mod ?. plugin ?. instanceSettings } , globalSettings : await getPluginSettings ( pluginName ) , } ; } catch ( e ) { logger . error ( 'Failed to parse plugin settings of ' + pluginName + e ) ; } } } return out ; } public async setActive ( themeName ) { const entity = await getCmsEntity ( ) ; if ( ! entity ) throw new HttpException ( '!cms entity' , HttpStatus . INTERNAL_SERVER_ERROR ) ; entity . publicSettings = { ... ( entity . publicSettings ?? { } ) , themeName , } ; await entity . save ( ) ; await restartService ( 'renderer' ) ; const cmsSettings = getStoreItem ( 'cmsSettings' ) ; const timeout = ( cmsSettings ?. watchPoll ?? 2000 ) + 1000 ; await new Promise ( done => setTimeout ( done , timeout ) ) ; return true ; } public async activateTheme ( themeName ) { const themePath = await getNodeModuleDir ( themeName ) ; const themePckg = await getModulePackage ( themeName ) ; if ( ! themePckg ?. version || ! themePath ) throw new HttpException ( 'Failed to find package.json of the theme ' + themeName , HttpStatus . INTERNAL_SERVER_ERROR ) ; const moduleInfo = await getCmsModuleInfo ( themeName ) ; delete moduleInfo ?. frontendDependencies ; delete moduleInfo ?. bundledDependencies ; delete moduleInfo ?. firstLoadedDependencies ; let themeConfig ; const filePath = resolve ( themePath , configFileName ) ; if ( await fs . pathExists ( filePath ) ) { try { decache ( filePath ) ; } catch ( error ) { } try { themeConfig = require ( filePath ) ; } catch ( e ) { logger . error ( e ) ; } } const themeStaticDir = await getModuleStaticDir ( themeName ) ; if ( themeStaticDir && await fs . pathExists ( themeStaticDir ) ) { try { const publicThemesDir = getPublicThemesDir ( ) ; await fs . ensureDir ( publicThemesDir ) ; await fs . copy ( themeStaticDir , resolve ( publicThemesDir , themeName ) ) ; } catch ( e ) { logger . log ( e ) ; } } const input = { name : themeName , version : themePckg . version , slug : themeName , isInstalled : true , title : moduleInfo ?. title , pageTitle : moduleInfo ?. title } ; if ( themeConfig ) { try { input . defaultSettings = JSON . stringify ( themeConfig ) ; } catch ( e ) { logger . error ( e ) ; } } if ( moduleInfo ) { try { input . moduleInfo = JSON . stringify ( moduleInfo ) ; } catch ( e ) { logger . error ( e ) ; } } const themeRepo = getCustomRepository ( GenericTheme . repository ) ; let entity ; try { entity = await themeRepo . getBySlug ( themeName ) ; if ( entity ) { entity = Object . assign ( { } , entity , input ) ; await themeRepo . save ( entity ) ; await serverFireAction ( 'update_theme' , { themeName } ) ; } } catch ( error ) { } if ( ! entity ) { try { entity = await themeRepo . createEntity ( input ) ; await serverFireAction ( 'install_theme' , { themeName } ) ; } catch ( e ) { logger . error ( e ) ; } } if ( entity ) { return true ; } return false ; } async checkThemeUpdate ( name ) { const settings = await getCmsSettings ( ) ; const isBeta = ! ! settings ?. beta ; const pckg = await getModulePackage ( name ) ; try { return await getCentralServerClient ( ) . checkThemeUpdate ( name , pckg ?. version ?? '0' , isBeta ) ; } catch ( error ) { if ( error . statusCode === 404 ) return ; getLogger ( false ) . error ( error ) ; } } async getThemeLatest ( name ) { try { return await getCentralServerClient ( ) . getThemeInfo ( name ) ; } catch ( error ) { if ( error . statusCode === 404 ) return ; getLogger ( false ) . error ( error ) ; } } async handleThemeUpdate ( themeName ) { if ( await this . cmsService . getIsRunningNpm ( ) ) { throw new HttpException ( 'Only one install/update available at the time' , HttpStatus . METHOD_NOT_ALLOWED ) ; } await this . cmsService . setIsRunningNpm ( true ) ; await this . cmsService . checkYarn ( ) ; const transactionId = getRandStr ( 8 ) ; startTransaction ( transactionId ) ; let success = false ; let error ; try { success = await this . updateTheme ( themeName ) ; } catch ( e ) { error = e ; success = false ; } if ( success ) await this . cmsService . installModuleDependencies ( themeName ) ; endTransaction ( transactionId ) ; await this . cmsService . setIsRunningNpm ( false ) ; if ( ! success ) { throw new HttpException ( error ?. message , error ?. status ) ; } return true ; } async updateTheme ( themeName ) { const pckgOld = await getModulePackage ( themeName ) ; if ( ! themeName || ! pckgOld ?. version ) throw new HttpException ( 'Theme package not found' , HttpStatus . INTERNAL_SERVER_ERROR ) ; const oldVersion = pckgOld . version ; const updateInfo = await this . checkThemeUpdate ( themeName ) ; if ( ! updateInfo || ! updateInfo . packageVersion ) throw new HttpException ( 'No update available' , HttpStatus . METHOD_NOT_ALLOWED ) ; if ( updateInfo . onlyManualUpdate ) throw new HttpException ( `Update failed: Cannot launch automatic update. Please update using npm install command and restart CMS` , HttpStatus . FORBIDDEN ) ; await runShellCommand ( `yarn upgrade ${ themeName }@${ updateInfo . packageVersion } --exact --non-interactive` , process . cwd ( ) ) ; await sleep ( 1 ) ; const pckgNew = await getModulePackage ( themeName ) ; if ( ! pckgNew ?. version ) throw new HttpException ( 'Theme package not found' , HttpStatus . INTERNAL_SERVER_ERROR ) ; if ( ! /^\d/ . test ( pckgNew . version ) ) pckgNew . version = pckgNew . version . substr ( 1 ) ; if ( pckgNew . version !== updateInfo . packageVersion ) { throw new HttpException ( 'New version was not applied' , HttpStatus . INTERNAL_SERVER_ERROR ) ; } if ( ! updateInfo . restartServices ) updateInfo . restartServices = [ ] ; const settings = await getCmsSettings ( ) ; if ( settings ?. themeName === themeName ) updateInfo . restartServices . push ( 'renderer' ) ; for ( const service of updateInfo . restartServices ) { await restartService ( service ) ; } await sleep ( 1 ) ; if ( ( updateInfo ?. restartServices ?? [ ] ) . includes ( 'api-server' ) ) { const resp1 = await childSendMessage ( 'make-new' ) ; if ( resp1 . message !== 'success' ) { await runShellCommand ( `yarn upgrade ${ themeName }@${ oldVersion } --exact --non-interactive` ) ; await sleep ( 1 ) ; throw new HttpException ( 'Could not start server with new theme' , HttpStatus . INTERNAL_SERVER_ERROR ) ; } else { const resp2 = await childSendMessage ( 'apply-new' , resp1 . payload ) ; if ( resp2 . message !== 'success' ) throw new HttpException ( 'Could not apply new server with new plugin' , HttpStatus . INTERNAL_SERVER_ERROR ) ; setPendingKill ( 2000 ) ; } } await this . activateTheme ( themeName ) ; return true ; } async handleInstallTheme ( themeName ) { if ( await this . cmsService . getIsRunningNpm ( ) ) { throw new HttpException ( 'Only one install/update available at the time' , HttpStatus . METHOD_NOT_ALLOWED ) ; } await this . cmsService . setIsRunningNpm ( true ) ; await this . cmsService . checkYarn ( ) ; const transactionId = getRandStr ( 8 ) ; startTransaction ( transactionId ) ; let success = false ; let error ; try { success = await this . installTheme ( themeName ) ; } catch ( e ) { error = e ; success = false ; } if ( success ) await this . cmsService . installModuleDependencies ( themeName ) ; await this . cmsService . setIsRunningNpm ( false ) ; endTransaction ( transactionId ) ; if ( ! success ) { throw new HttpException ( error ?. message , error ?. status ) ; } return true ; } async installTheme ( themeName ) { const info = await this . getThemeLatest ( themeName ) ; if ( ! themeName || ! info || ! info . packageVersion || ! info . version ) throw new HttpException ( 'Theme was not found' , HttpStatus . METHOD_NOT_ALLOWED ) ; const settings = await getCmsSettings ( ) ; const isBeta = ! ! settings ?. beta ; const version = isBeta ? ( info . betaVersion ?? info . version ) : info . version ; await runShellCommand ( `yarn add ${ themeName }@${ version } --exact --non-interactive` ) ; await sleep ( 1 ) ; const pckgNew = await getModulePackage ( themeName ) ; if ( ! pckgNew ?. version ) throw new HttpException ( 'Theme package was not found' , HttpStatus . INTERNAL_SERVER_ERROR ) ; await this . activateTheme ( themeName ) ; return true ; } async handleDeleteTheme ( name ) { if ( await this . cmsService . getIsRunningNpm ( ) ) { throw new HttpException ( 'Only one install/update available at the time' , HttpStatus . METHOD_NOT_ALLOWED ) ; } await this . cmsService . setIsRunningNpm ( true ) ; await this . cmsService . checkYarn ( ) ; const transactionId = getRandStr ( 8 ) ; startTransaction ( transactionId ) ; let success = false ; let error ; try { success = await this . deleteTheme ( name ) ; } catch ( e ) { error = e ; success = false ; } await this . cmsService . setIsRunningNpm ( false ) ; endTransaction ( transactionId ) ; if ( ! success ) { throw new HttpException ( error ?. message , error ?. status ) ; } return true ; } private async deleteTheme ( themeName ) { const pckgOld = await getModulePackage ( themeName ) ; const oldVersion = pckgOld ?. version ; if ( ! themeName || ! oldVersion ) throw new HttpException ( 'Plugin package not found' , HttpStatus . INTERNAL_SERVER_ERROR ) ; await runShellCommand ( `yarn remove ${ themeName } --non-interactive` ) ; await sleep ( 1 ) ; const pckgNew = await getModulePackage ( themeName ) ; if ( pckgNew ) throw new HttpException ( `Failed to remove theme's package` , HttpStatus . INTERNAL_SERVER_ERROR ) ; const themeRepo = getCustomRepository ( GenericTheme . repository ) ; const entity = await this . findOne ( themeName ) ; if ( entity ?. id ) await themeRepo . deleteEntity ( entity . id ) ; await serverFireAction ( 'uninstall_theme' , { themeName } ) ; return true ; } }