UNPKG

jsreport-studio

Version:
656 lines (527 loc) 22.9 kB
const path = require('path') const Promise = require('bluebird') const url = require('url') const _ = require('lodash') const crypto = require('crypto') const fs = Promise.promisifyAll(require('fs')) const serveStatic = require('serve-static') const favicon = require('serve-favicon') const compression = require('compression') const requestLog = require('./requestLog') const ThemeManager = require('./themeManager') const distPath = path.join(__dirname, '../static/dist') module.exports = (reporter, definition) => { const mainCssFilename = findMainCssFilename() const extensionsJsChunkName = findExtensionsJsChunkName() const themeManager = ThemeManager(reporter.logger.warn) reporter.studio = { normalizeLogs: requestLog.normalizeLogs, getAllThemes: themeManager.getAllThemes, getAllEditorThemes: themeManager.getAllEditorThemes, getAvailableThemeVariables: themeManager.getAvailableThemeVariables, getCurrentThemeVars: themeManager.getCurrentThemeVars, registerTheme: themeManager.registerTheme, registerThemeVariables: themeManager.registerThemeVariables } reporter.studio.registerThemeVariables(path.join(__dirname, './themeVarsDefinition.json')) reporter.studio.registerTheme({ name: 'light', // the light theme is equal to the default values of variables, so no need to register a json file here variablesPath: null, previewColor: '#F6F6F6', editorTheme: 'chrome' }) reporter.addRequestContextMetaConfig('htmlTitle', { sandboxReadOnly: true }) if (definition.options.requestLogEnabled !== false) { reporter.logger.debug(`studio request logs are enabled (flush interval: ${definition.options.flushLogsInterval})`) requestLog(reporter, definition) } else { reporter.logger.debug('studio request logs are disabled') } const titleTemplateSettings = { variable: 'jsreport', // disabling escape escape: null, // making evaluate and interpolate the same, so basic javascript (like ternaty conditions) works in the interpolation evaluate: /\$\{([^\\}]*(?:\\.[^\\}]*)*)\}/g, // only match es6 string templates interpolate: /\$\{([^\\}]*(?:\\.[^\\}]*)*)\}/g, imports: { _: null } } const titleTemplate = _.template(definition.options.title, titleTemplateSettings) // eslint-disable-next-line no-template-curly-in-string const defaultTitleTemplate = _.template(definition.optionsSchema.extensions.studio.properties.title.default, titleTemplateSettings) let customLogoPathOrBuffer let customCssFile let customCssContent let compiler let clientStudioExtensionsJsContent let clientStudioExtensionsCssContent const serverStartupHash = crypto.randomBytes(10).toString('hex') reporter.on('after-authentication-express-routes', () => reporter.express.app.get('/', redirectOrSendIndex)) reporter.on('after-express-static-configure', () => { if (!reporter.authentication) { return reporter.express.app.get('/', redirectOrSendIndex) } }) reporter.on('before-express-configure', () => { reporter.express.app.use('/api/report', (req, res, next) => { res.cookie('render-complete', true) next() }) }) reporter.on('express-configure', () => { const extsInNormalMode = [] if (reporter.options.mode !== 'jsreport-development') { let webpackJsWrap if (fs.existsSync(path.join(distPath, 'extensions.client.js'))) { webpackJsWrap = fs.readFileSync(path.join(distPath, 'extensions.client.js'), 'utf8') } else { webpackJsWrap = fs.readFileSync(path.join(distPath, extensionsJsChunkName), 'utf8') } const webpackExtensionsJs = webpackJsWrap.replace('$extensionsHere', () => { return reporter.extensionsManager.extensions.map((e) => { try { return fs.readFileSync(path.join(e.directory, 'studio/main.js')) } catch (e) { return '' } }).join('\n') }) const webpackExtensionsCss = reporter.extensionsManager.extensions.map((e) => { try { return fs.readFileSync(path.join(e.directory, 'studio/main.css')) } catch (e) { return '' } }).join('\n') clientStudioExtensionsJsContent = webpackExtensionsJs clientStudioExtensionsCssContent = webpackExtensionsCss } else { const extsConfiguredInDevMode = process.env.JSREPORT_CURRENT_EXTENSION != null ? [process.env.JSREPORT_CURRENT_EXTENSION] : [] if (definition.options.extensionsInDevMode != null) { let extensionsDefined = [] if (Array.isArray(definition.options.extensionsInDevMode)) { extensionsDefined = [...definition.options.extensionsInDevMode] } else if (typeof definition.options.extensionsInDevMode === 'string') { extensionsDefined = [definition.options.extensionsInDevMode] } extensionsDefined.forEach((ext) => { if (ext !== '' && !extsConfiguredInDevMode.includes(ext)) { extsConfiguredInDevMode.push(ext) } }) } if (extsConfiguredInDevMode.length > 0) { reporter.logger.debug(`studio extensions configured in dev mode: ${extsConfiguredInDevMode.join(', ')}`) } else { reporter.logger.debug('all studio extensions are configured in dev mode') } fs.writeFileSync(path.join(__dirname, '../src/extensions_dev.js'), reporter.extensionsManager.extensions.map((e) => { const shouldUseMainEntry = extsConfiguredInDevMode.length > 0 && !extsConfiguredInDevMode.includes(e.name) try { fs.statSync(path.join(e.directory, '/studio/main_dev.js')) const extensionPath = shouldUseMainEntry ? ( path.join(e.directory, '/studio/main.js') ) : path.join(e.directory, '/studio/main_dev.js') if (shouldUseMainEntry) { extsInNormalMode.push(e) } return `import '${path.relative(path.join(__dirname, '../src'), extensionPath).replace(/\\/g, '/')}'` } catch (e) { return '' } }).join('\n')) fs.writeFileSync(path.join(__dirname, '../src/extensions_dev.css'), reporter.extensionsManager.extensions.map((e) => { const shouldUseMainEntry = extsConfiguredInDevMode.length > 0 && !extsConfiguredInDevMode.includes(e.name) try { if (!shouldUseMainEntry) { return '' } const extensionPath = path.join(e.directory, '/studio/main.css') fs.statSync(extensionPath) return `@import '${path.relative(path.join(__dirname, '../src'), extensionPath).replace(/\\/g, '/')}';` } catch (e) { return '' } }).join('\n')) } const app = reporter.express.app app.use(compression()) app.use(favicon(path.join(__dirname, '../static/favicon.ico'))) // we put the route before webpack dev middleware to get the chance to do our own handling app.get(`/studio/assets/${mainCssFilename}`, async (req, res, next) => { try { const themeCss = await themeManager.compileTheme( definition.options.theme.name, readMainCssContent, definition.options.theme.variables ) res.type('css').send(themeCss) } catch (e) { next(e) } }) if (reporter.options.mode === 'jsreport-development') { const webpack = require('jsreport-studio-dev').deps.webpack const webpackConfig = require('../webpack/dev.config')( reporter.extensionsManager.extensions, extsInNormalMode ) compiler = webpack(webpackConfig) let statsOpts = definition.options.webpackStatsInDevMode || {} if (statsOpts.colors == null) { statsOpts.colors = true } if (statsOpts.chunks == null) { statsOpts.chunks = false } if (statsOpts.modules == null) { statsOpts.modules = false } reporter.express.app.use(require('webpack-dev-middleware')(compiler, { publicPath: '/studio/assets/', lazy: false, stats: statsOpts })) reporter.express.app.use(require('webpack-hot-middleware')(compiler)) } app.get(`/studio/assets/${extensionsJsChunkName}`, (req, res) => { res.set('Content-Type', 'application/javascript') res.send(clientStudioExtensionsJsContent) }) app.get('/studio/assets/alternativeTheme.css', async (req, res, next) => { const themeName = req.query.name if (!themeName) { return res.status(400).end('Theme name was not specified in query string') } if (themeManager.getTheme(themeName) == null) { return res.status(404).end(`Theme "${themeName}" does not exists`) } try { const themeCss = await themeManager.compileTheme( themeName, readMainCssContent, definition.options.theme.variables ) res.type('css').send(themeCss) } catch (e) { next(e) } }) app.get('/studio/assets/customCss.css', async (req, res, next) => { const theme = req.query.theme if (customCssContent == null && customCssFile == null) { return res.status(404).end('Custom css was not configured, use "extensions.studio.theme.customCss.content" or "extensions.studio.theme.customCss.path" if you want to include custom css') } if (theme == null) { return res.status(400).end('Theme name was not specified') } if (themeManager.getTheme(theme) == null) { return res.status(404).end(`Theme "${theme}" does not exists`) } try { const cssContent = await themeManager.compileCustomCss(theme, async function readCustomCssContent () { let content if (customCssContent != null) { content = customCssContent } else { content = (await fs.readFileAsync(customCssFile)).toString() } return content }, definition.options.theme.variables) res.type('css').send(cssContent) } catch (e) { next(e) } }) app.get('/studio/assets/custom-logo', (req, res, next) => { if (!customLogoPathOrBuffer) { return res.status(404).end('Custom logo for studio was not configured, use "extensions.studio.theme.logo.base64" or "extensions.studio.theme.logo.path" option if you want to customize it') } if (typeof customLogoPathOrBuffer === 'string') { res.sendFile(customLogoPathOrBuffer, (err) => { if (err) { next(err) } }) } else { res.type(customLogoPathOrBuffer.type).send(customLogoPathOrBuffer.content) } }) app.use('/studio/assets', serveStatic(distPath)) app.use('/studio/hierarchyMove', async (req, res) => { const source = req.body.source const target = req.body.target const shouldCopy = req.body.copy === true const shouldReplace = req.body.replace === true try { if (!source || !target) { const missing = !source ? 'source' : 'target' throw new Error(`No "${missing}" specified in payload`) } await reporter.documentStore.beginTransaction(req) let updatedItems try { updatedItems = await reporter.folders.move({ source, target, shouldCopy, shouldReplace }, req) await reporter.documentStore.commitTransaction(req) } catch (e) { await reporter.documentStore.rollbackTransaction(req) throw e } for (const e of updatedItems) { const doc = await reporter.documentStore.collection(e.__entitySet).serializeProperties([e]) Object.assign(e, doc[0]) } res.status(200).json({ items: updatedItems }) } catch (e) { const errorRes = { message: e.message } if (e.code === 'DUPLICATED_ENTITY') { const publicKeyOfDuplicate = reporter.documentStore.model.entitySets[e.existingEntityEntitySet].entityTypePublicKey errorRes.code = e.code errorRes.existingEntity = { _id: e.existingEntity._id, name: e.existingEntity[publicKeyOfDuplicate] } errorRes.existingEntityEntitySet = e.existingEntityEntitySet if (e.existingEntityEntitySet === 'folders') { errorRes.message = `${errorRes.message} The existing entity is a folder and replacing a folder is not allowed, to be able to copy/move the entity you need to rename one of the two conflicting entities first.` } } res.status(400).json(errorRes) } }) app.post('/studio/validate-entity-name', async (req, res) => { const entitySet = req.body.entitySet const entityId = req.body._id const entityName = req.body.name const folderShortid = req.body.folderShortid try { if (!entitySet) { throw new Error('entitySet was not specified in request body') } const publicKey = reporter.documentStore.model.entitySets[entitySet].entityTypePublicKey if (publicKey) { await reporter.checkValidEntityName(entitySet, { _id: entityId, [publicKey]: entityName, folder: folderShortid != null ? { shortid: folderShortid } : null }, req) } res.status(200).end() } catch (e) { res.status(400).end(e.message) } }) app.get('/studio/*', sendIndex) }) reporter.documentStore.on('after-init', () => { const documentStore = reporter.documentStore for (let key in documentStore.collections) { const col = reporter.documentStore.collections[key] const entitySet = documentStore.model.entitySets[col.entitySet] const entityTypeName = entitySet.entityType.replace(documentStore.model.namespace + '.', '') const entityType = documentStore.model.entityTypes[entityTypeName] if ( entityType.modificationDate != null && entityType.modificationDate.type === 'Edm.DateTimeOffset' ) { col.beforeUpdateListeners.insert(0, 'studio-concurrent-save-check', async (q, u, o, req) => { if (!u.$set || !u.$set.modificationDate || !req) { return } if ( !req.context || !req.context.http || !req.context.http.headers || ( req.context.http.headers['x-validate-concurrent-update'] !== 'true' && req.context.http.headers['x-validate-concurrent-update'] !== true ) ) { return } let lastModificationDate = u.$set.modificationDate if (typeof lastModificationDate === 'string') { lastModificationDate = new Date(lastModificationDate) if (lastModificationDate.toString() === 'Invalid Date') { return } } if (typeof lastModificationDate.getTime !== 'function') { return } if (q._id == null) { return } const entity = await col.findOne(q, req) if (entity) { const currentModificationDate = entity.modificationDate if (currentModificationDate.getTime() !== lastModificationDate.getTime()) { throw reporter.createError(`Entity (_id: ${entity._id}) was modified previously by another source`, { status: 400, code: 'CONCURRENT_UPDATE_INVALID', weak: true }) } } }) } } initThemeOptions() }) reporter.initializeListeners.insert({ before: 'express' }, 'studio', async () => { if (reporter.express) { reporter.express.exposeOptionsToApi(definition.name, { customLogo: customLogoPathOrBuffer != null, theme: definition.options.theme.name, editorTheme: definition.options.theme.editorThemeName, availableThemes: themeManager.getAllThemes().reduce((acu, themeName) => { const themeValue = themeManager.getTheme(themeName) acu[themeName] = { previewColor: themeValue.previewColor, editorTheme: themeValue.editorTheme } return acu }, {}), availableEditorThemes: themeManager.getAllEditorThemes().reduce((acu, editorThemeName) => { const editorThemeValue = themeManager.getEditorTheme(editorThemeName) acu[editorThemeName] = editorThemeValue return acu }, {}), serverStartupHash, startupPage: definition.options.startupPage, requestLogEnabled: definition.options.requestLogEnabled, entityTreeOrder: definition.options.entityTreeOrder }) } }) function findMainCssFilename () { if (reporter.options.mode === 'jsreport-development') { return 'main.dev.css' } else { const staticFiles = fs.readdirSync(distPath) return staticFiles.find((fileName) => /main\.[^.]+.css/.test(fileName)) } } function findExtensionsJsChunkName () { if (reporter.options.mode === 'jsreport-development') { return 'studio-extensions.client.dev.js' } else { const staticFiles = fs.readdirSync(distPath) return staticFiles.find((fileName) => /studio-extensions\.client\.[^.]+.js/.test(fileName)) } } async function readMainCssContent () { const mainCssPath = path.join(__dirname, `../static/dist/${mainCssFilename}`) let readFile if (compiler) { readFile = compiler.outputFileSystem.readFile.bind(compiler.outputFileSystem) } else { readFile = fs.readFile.bind(fs) } let cssContent = await new Promise((resolve, reject) => { readFile(mainCssPath, 'utf8', (err, content) => { if (err) { return reject(err) } resolve(content) }) }) if (clientStudioExtensionsCssContent) { cssContent = `${cssContent}\n\n${clientStudioExtensionsCssContent}` } return cssContent } function sendIndex (req, res, next) { const indexHtml = path.join(distPath, 'index.html') function send (err, content) { if (err) { return next(err) } let title try { title = titleTemplate(reporter) } catch (e) { // in case of error, add a warn log and use default template reporter.logger.warn(`Error when trying to generate studio title with template "${ definition.options.title }". ${e.message}. the studio title is falling back to default template "${ definition.optionsSchema.extensions.studio.properties.title.default }"`) title = defaultTitleTemplate(reporter) } content = content.replace('$serverStartupHash', serverStartupHash) content = content.replace('$jsreportTitle', req.context.htmlTitle || title) content = content.replace('$defaultTheme', definition.options.theme.name) content = content.replace(/client\.[^.]+.js/, (match) => `${reporter.options.appPath}studio/assets/${match}`) content = content.replace(/main\.[^.]+.css/, (match) => `${reporter.options.appPath}studio/assets/${match}`) content = content.replace('$customCssFiles', (customCssFile != null || customCssContent != null) ? ( `<link href="${reporter.options.appPath}studio/assets/customCss.css?${serverStartupHash}&theme=${definition.options.theme.name}" data-jsreport-studio-custom-css="true" rel="stylesheet">` ) : '') res.send(content) } function tryRead () { compiler.outputFileSystem.readFile(indexHtml, 'utf8', (err, content) => { if (err) { return setTimeout(tryRead, 1000) } send(null, content) }) } if (reporter.options.mode === 'jsreport-development') { tryRead() } else { fs.readFile(indexHtml, 'utf8', send) } } function redirectOrSendIndex (req, res, next) { const reqUrl = url.parse(req.originalUrl) if (reqUrl.pathname[reqUrl.pathname.length - 1] !== '/') { return res.redirect(reqUrl.pathname + '/' + (reqUrl.search || '')) } sendIndex(req, res, next) } function initThemeOptions () { reporter.logger.debug(`studio default theme is: ${definition.options.theme.name}`) if (definition.options.theme.editorThemeName == null) { definition.options.theme.editorThemeName = themeManager.getTheme(definition.options.theme.name).editorTheme } else { reporter.logger.debug(`studio is using editor theme: ${definition.options.theme.editorThemeName}`) } if (definition.options.theme.logo.base64 != null) { if ( definition.options.theme.logo.base64.type == null || definition.options.theme.logo.base64.content == null ) { throw reporter.createError('"extensions.studio.theme.logo.base64.type" and "extensions.studio.theme.logo.base64.content" are required when specifying "extensions.studio.theme.logo.base64"') } customLogoPathOrBuffer = { type: definition.options.theme.logo.base64.type, content: Buffer.from(definition.options.theme.logo.base64.content, 'base64') } reporter.logger.debug('studio is using custom logo from base64 source') } else if (definition.options.theme.logo.path != null) { customLogoPathOrBuffer = path.resolve(reporter.options.rootDirectory, definition.options.theme.logo.path) reporter.logger.debug(`studio is using custom logo at: ${customLogoPathOrBuffer}`) } if (definition.options.theme.customCss.content != null) { customCssContent = definition.options.theme.customCss.content reporter.logger.debug('studio will include custom css style from raw string') } else if (definition.options.theme.customCss.path != null) { customCssFile = path.resolve(reporter.options.rootDirectory, definition.options.theme.customCss.path) reporter.logger.debug(`studio will include custom css style from: ${customCssFile}`) } } }