diff --git a/src/database/schemas/management/documenttemplate.schema.js b/src/database/schemas/management/documenttemplate.schema.js index 48e75ea..28e4d5a 100644 --- a/src/database/schemas/management/documenttemplate.schema.js +++ b/src/database/schemas/management/documenttemplate.schema.js @@ -1,9 +1,13 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; +import { + normalizeTemplateResourceName, + RESOURCE_NAME_PATTERN, + validateTemplateResources, +} from '../../../templates/templateresources.js'; const { Schema } = mongoose; -const RENDER_DOCUMENT_TEMPLATE_CALL = - /fc\.renderDocumentTemplate\s*\(\s*(['"])([^'"]+)\1/g; +const RENDER_DOCUMENT_TEMPLATE_CALL = /fc\.renderDocumentTemplate\s*\(\s*(['"])([^'"]+)\1/g; function extractRenderDocumentTemplateReferences(content) { if (content == null || typeof content !== 'string' || content === '') { @@ -103,6 +107,27 @@ const documentTemplateSchema = new Schema( refPath: 'objectType', required: false, }, + resources: { + type: [ + new Schema( + { + name: { + type: String, + required: true, + set: normalizeTemplateResourceName, + match: RESOURCE_NAME_PATTERN, + }, + file: { type: Schema.Types.ObjectId, ref: 'file', required: true }, + }, + { _id: true } + ), + ], + default: [], + validate: { + validator: validateTemplateResources, + message: 'Template resource names must be unique.', + }, + }, }, { timestamps: true } ); diff --git a/src/database/schemas/management/emailtemplate.schema.js b/src/database/schemas/management/emailtemplate.schema.js index 612fe8f..ce50b99 100644 --- a/src/database/schemas/management/emailtemplate.schema.js +++ b/src/database/schemas/management/emailtemplate.schema.js @@ -1,5 +1,10 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; +import { + normalizeTemplateResourceName, + RESOURCE_NAME_PATTERN, + validateTemplateResources, +} from '../../../templates/templateresources.js'; const { Schema } = mongoose; const RENDER_EMAIL_TEMPLATE_CALL = /fc\.renderEmailTemplate\s*\(\s*(['"])([^'"]+)\1/g; @@ -36,13 +41,32 @@ const emailTemplateSchema = new Schema( active: { type: Boolean, required: true, default: true }, global: { type: Boolean, required: true, default: false }, parent: { type: Schema.Types.ObjectId, ref: 'emailTemplate', required: false }, - referencedTemplates: [ - { type: Schema.Types.ObjectId, ref: 'emailTemplate', required: false }, - ], + referencedTemplates: [{ type: Schema.Types.ObjectId, ref: 'emailTemplate', required: false }], subject: { type: String, required: false, default: '' }, content: { type: String, required: false, default: '' }, testObject: { type: Schema.Types.ObjectId, refPath: 'objectType', required: false }, attachments: { type: [emailTemplateAttachmentSchema], default: [] }, + resources: { + type: [ + new Schema( + { + name: { + type: String, + required: true, + set: normalizeTemplateResourceName, + match: RESOURCE_NAME_PATTERN, + }, + file: { type: Schema.Types.ObjectId, ref: 'file', required: true }, + }, + { _id: true } + ), + ], + default: [], + validate: { + validator: validateTemplateResources, + message: 'Template resource names must be unique.', + }, + }, }, { timestamps: true } ); diff --git a/src/index.js b/src/index.js index aebbea4..422419a 100644 --- a/src/index.js +++ b/src/index.js @@ -74,6 +74,7 @@ import { appUpdateRoutes, serverRoutes, slicerRoutes, + templateResourceRoutes, } from './routes/index.js'; import path from 'path'; import * as fs from 'fs'; @@ -247,6 +248,7 @@ app.use('/applaunch', appLaunchRoutes); app.use('/appupdate', appUpdateRoutes); app.use('/server', serverRoutes); app.use('/slicer', slicerRoutes); +app.use('/template-resources', templateResourceRoutes); // Start the application if (process.env.NODE_ENV !== 'test') { diff --git a/src/routes/index.js b/src/routes/index.js index 9468efc..875cbfb 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -67,6 +67,7 @@ import appLaunchRoutes from './misc/applaunch.js'; import appUpdateRoutes from './misc/appupdate.js'; import serverRoutes from './misc/server.js'; import slicerRoutes from './misc/slicer.js'; +import templateResourceRoutes from './misc/templateresources.js'; export { userRoutes, @@ -138,4 +139,5 @@ export { appUpdateRoutes, serverRoutes, slicerRoutes, + templateResourceRoutes, }; diff --git a/src/routes/misc/templateresources.js b/src/routes/misc/templateresources.js new file mode 100644 index 0000000..f92275f --- /dev/null +++ b/src/routes/misc/templateresources.js @@ -0,0 +1,8 @@ +import express from 'express'; +import { getTemplateResourceRouteHandler } from '../../services/misc/templateresources.js'; + +const router = express.Router(); + +router.get('/:token', getTemplateResourceRouteHandler); + +export default router; diff --git a/src/services/management/__tests__/documenttemplates.test.js b/src/services/management/__tests__/documenttemplates.test.js index a4e475b..35b465e 100644 --- a/src/services/management/__tests__/documenttemplates.test.js +++ b/src/services/management/__tests__/documenttemplates.test.js @@ -58,12 +58,10 @@ const { intellisenseDocumentTemplateRouteHandler, } = await import('../documenttemplates.js'); -const { listObjects, getObject, editObject, newObject } = await import( - '../../../database/database.js' -); -const { documentTemplateModel } = await import( - '../../../database/schemas/management/documenttemplate.schema.js' -); +const { listObjects, getObject, editObject, newObject } = + await import('../../../database/database.js'); +const { documentTemplateModel } = + await import('../../../database/schemas/management/documenttemplate.schema.js'); const { templateManager } = await import('../../../templates/templatemanager.js'); const { redisServer } = await import('../../../database/redis.js'); @@ -101,15 +99,40 @@ describe('Document Template Service Route Handlers', () => { describe('newDocumentTemplateRouteHandler', () => { it('should create a new document template', async () => { - req.body = { name: 'New Template', documentSize: 'size123' }; + req.body = { + name: 'New Template', + documentSize: 'size123', + resources: [{ name: ' Header Logo ', file: { _id: 'file-1' } }], + }; const mockTemplate = { _id: '456', ...req.body }; newObject.mockResolvedValue(mockTemplate); await newDocumentTemplateRouteHandler(req, res); - expect(newObject).toHaveBeenCalled(); + expect(newObject).toHaveBeenCalledWith( + expect.objectContaining({ + newData: expect.objectContaining({ + resources: [{ _id: undefined, name: 'header-logo', file: 'file-1' }], + }), + }) + ); expect(res.send).toHaveBeenCalledWith(mockTemplate); }); + + it('rejects resource names that collide after normalization', async () => { + req.body = { + name: 'New Template', + resources: [ + { name: 'Main Logo', file: 'file-1' }, + { name: 'main-logo', file: 'file-2' }, + ], + }; + + await newDocumentTemplateRouteHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(newObject).not.toHaveBeenCalled(); + }); }); describe('intellisenseDocumentTemplateRouteHandler', () => { @@ -294,4 +317,3 @@ describe('Document Template Service Route Handlers', () => { }); }); }); - diff --git a/src/services/management/__tests__/emailtemplates.test.js b/src/services/management/__tests__/emailtemplates.test.js index 3cda456..e63737b 100644 --- a/src/services/management/__tests__/emailtemplates.test.js +++ b/src/services/management/__tests__/emailtemplates.test.js @@ -18,7 +18,10 @@ jest.unstable_mockModule('../../../database/database.js', () => ({ searchObjects: jest.fn(), })); jest.unstable_mockModule('../../../templates/templatemanager.js', () => ({ - templateManager: { renderEmailTemplate: jest.fn() }, + templateManager: { + renderEmailTemplate: jest.fn(), + collectTemplateIntellisense: jest.fn(), + }, })); jest.unstable_mockModule('../../../templates/templateformatter.js', () => ({ formatTemplateContent: jest.fn((content) => ({ content })), @@ -41,11 +44,14 @@ const { getEmailTemplateStatsRouteHandler, listEmailTemplatesByPropertiesRouteHandler, newEmailTemplateRouteHandler, + intellisenseEmailTemplateRouteHandler, } = await import('../emailtemplates.js'); const { editObject, getModelStats, getObjectNeighbors, listObjectsByProperties, newObject } = await import('../../../database/database.js'); const ATTACHMENT_POPULATE = { path: 'attachments.file', strictPopulate: false }; +const RESOURCE_POPULATE = { path: 'resources.file', strictPopulate: false }; +const { templateManager } = await import('../../../templates/templatemanager.js'); describe('email template standard handlers', () => { let req; @@ -74,6 +80,7 @@ describe('email template standard handlers', () => { populate: [ { path: 'parent', strictPopulate: false }, ATTACHMENT_POPULATE, + RESOURCE_POPULATE, ], }) ); @@ -115,7 +122,11 @@ describe('email template standard handlers', () => { fileName: 'Terms', }, ]; - req.body = { name: 'Invoice', attachments }; + req.body = { + name: 'Invoice', + attachments, + resources: [{ name: ' Main Logo ', file: { _id: 'resource-file-1' } }], + }; newObject.mockResolvedValue({ _id: 'template-1' }); editObject.mockResolvedValue({ _id: 'template-1' }); @@ -140,6 +151,7 @@ describe('email template standard handlers', () => { fileType: undefined, }, ], + resources: [{ _id: undefined, name: 'main-logo', file: 'resource-file-1' }], }), }) ); @@ -168,4 +180,21 @@ describe('email template standard handlers', () => { }) ); }); + + it('returns resource suggestions with intellisense results', async () => { + req.params.id = 'template-1'; + templateManager.collectTemplateIntellisense.mockResolvedValue({ + intellisense: { $type: 'object', properties: {} }, + resources: [{ name: 'main-logo', file: { _id: 'file-1' } }], + }); + + await intellisenseEmailTemplateRouteHandler(req, res); + + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ + intellisense: expect.any(Object), + resources: [expect.objectContaining({ name: 'main-logo' })], + }) + ); + }); }); diff --git a/src/services/management/documenttemplates.js b/src/services/management/documenttemplates.js index 5aef997..5eb5da0 100644 --- a/src/services/management/documenttemplates.js +++ b/src/services/management/documenttemplates.js @@ -24,6 +24,10 @@ import { DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS, } from '../../templates/templatemanager.js'; import { formatTemplateContent } from '../../templates/templateformatter.js'; +import { + normalizeTemplateResources, + validateTemplateResources, +} from '../../templates/templateresources.js'; const logger = log4js.getLogger('Document Templates'); logger.level = config.server.logLevel; @@ -73,6 +77,7 @@ export const listDocumentTemplatesRouteHandler = async ( { path: 'parent' }, { path: 'documentPrinters', strictPopulate: false }, { path: 'referencedTemplates', strictPopulate: false }, + { path: 'resources.file', strictPopulate: false }, ], }); @@ -99,7 +104,7 @@ export const listDocumentTemplatesByPropertiesRouteHandler = async ( model: documentTemplateModel, properties, filter, - populate: ['documentSize'], + populate: ['documentSize', { path: 'resources.file', strictPopulate: false }], masterFilter, }); @@ -147,6 +152,7 @@ export const getDocumentTemplateRouteHandler = async (req, res) => { { path: 'documentPrinters', strictPopulate: false }, { path: 'referencedTemplates', strictPopulate: false }, { path: 'testObject', strictPopulate: false }, + { path: 'resources.file', strictPopulate: false }, ], }); if (result?.error) { @@ -172,6 +178,13 @@ export const editDocumentTemplateRouteHandler = async (req, res) => { } formattedContent = formatResult.content; } + const resources = normalizeTemplateResources(req.body.resources); + if (resources !== undefined && !validateTemplateResources(resources)) { + return res.status(400).send({ + error: 'Template resources require unique normalized names and files.', + code: 400, + }); + } const updateData = { updatedAt: new Date(), @@ -185,6 +198,7 @@ export const editDocumentTemplateRouteHandler = async (req, res) => { documentPrinters: req.body.documentPrinters, content: formattedContent, testObject: req.body.testObject?._id || req.body.testObject, + resources, }; // Create audit log before updating const result = await editObject({ @@ -198,12 +212,13 @@ export const editDocumentTemplateRouteHandler = async (req, res) => { { path: 'documentPrinters', strictPopulate: false }, { path: 'referencedTemplates', strictPopulate: false }, { path: 'testObject', strictPopulate: false }, + { path: 'resources.file', strictPopulate: false }, ], }); if (result.error) { logger.error('Error editing document template:', result.error); - res.status(result).send(result); + res.status(result.code || 500).send(result); return; } @@ -213,6 +228,13 @@ export const editDocumentTemplateRouteHandler = async (req, res) => { }; export const newDocumentTemplateRouteHandler = async (req, res) => { + const resources = normalizeTemplateResources(req.body.resources); + if (resources !== undefined && !validateTemplateResources(resources)) { + return res.status(400).send({ + error: 'Template resources require unique normalized names and files.', + code: 400, + }); + } const newData = { updatedAt: new Date(), name: req.body.name, @@ -224,6 +246,7 @@ export const newDocumentTemplateRouteHandler = async (req, res) => { documentSize: req.body.documentSize, documentPrinters: req.body.documentPrinters, content: req.body.content, + resources, }; const result = await newObject({ model: documentTemplateModel, @@ -237,7 +260,17 @@ export const newDocumentTemplateRouteHandler = async (req, res) => { logger.debug(`New document template with ID: ${result._id}`); - res.send(result); + const populated = await getObject({ + model: documentTemplateModel, + id: result._id, + populate: [ + { path: 'documentSize' }, + { path: 'parent', strictPopulate: false }, + { path: 'documentPrinters', strictPopulate: false }, + { path: 'resources.file', strictPopulate: false }, + ], + }); + res.send(populated && !populated.error ? populated : result); }; export const deleteDocumentTemplateByFilterRouteHandler = async ( @@ -407,10 +440,7 @@ function sendRenderedDownload(res, result, filename) { if (buffers.length === 1) { const buffer = buffers[0]; res.set('Content-Type', result.mime); - res.set( - 'Content-Disposition', - `attachment; filename="${filename}.${result.extension}"` - ); + res.set('Content-Disposition', `attachment; filename="${filename}.${result.extension}"`); res.set('Content-Length', String(buffer.length)); return res.send(buffer); } @@ -498,4 +528,3 @@ export const downloadDocumentTemplateRouteHandler = async (req, res) => { return sendRenderedDownload(res, result, filename); }; - diff --git a/src/services/management/emailtemplates.js b/src/services/management/emailtemplates.js index b0c056d..1b7d44f 100644 --- a/src/services/management/emailtemplates.js +++ b/src/services/management/emailtemplates.js @@ -18,6 +18,10 @@ import { } from '../../database/database.js'; import { templateManager } from '../../templates/templatemanager.js'; import { formatTemplateContent } from '../../templates/templateformatter.js'; +import { + normalizeTemplateResources, + validateTemplateResources, +} from '../../templates/templateresources.js'; const logger = log4js.getLogger('Email Templates'); logger.level = config.server.logLevel; @@ -25,6 +29,7 @@ const populate = [ { path: 'parent', strictPopulate: false }, { path: 'referencedTemplates', strictPopulate: false }, { path: 'attachments.file', strictPopulate: false }, + { path: 'resources.file', strictPopulate: false }, ]; const normalizeAttachments = (attachments) => @@ -84,6 +89,7 @@ export const listEmailTemplatesByPropertiesRouteHandler = async ( populate: [ { path: 'parent', strictPopulate: false }, { path: 'attachments.file', strictPopulate: false }, + { path: 'resources.file', strictPopulate: false }, ], }); @@ -149,6 +155,13 @@ export const editEmailTemplateRouteHandler = async (req, res) => { } content = formatted.content; } + const resources = normalizeTemplateResources(req.body.resources); + if (resources !== undefined && !validateTemplateResources(resources)) { + return res.status(400).send({ + error: 'Template resources require unique normalized names and files.', + code: 400, + }); + } const updateData = { updatedAt: new Date(), @@ -162,6 +175,7 @@ export const editEmailTemplateRouteHandler = async (req, res) => { content, testObject: req.body.testObject?._id ?? req.body.testObject, attachments: normalizeAttachments(req.body.attachments), + resources, }; const result = await editObject({ model: emailTemplateModel, @@ -192,6 +206,13 @@ export const newEmailTemplateRouteHandler = async (req, res) => { } content = formatted.content; } + const resources = normalizeTemplateResources(req.body.resources); + if (resources !== undefined && !validateTemplateResources(resources)) { + return res.status(400).send({ + error: 'Template resources require unique normalized names and files.', + code: 400, + }); + } const newData = { createdAt: new Date(), @@ -206,6 +227,7 @@ export const newEmailTemplateRouteHandler = async (req, res) => { content, testObject: req.body.testObject?._id ?? req.body.testObject, attachments: normalizeAttachments(req.body.attachments), + resources, }; const result = await newObject({ @@ -220,7 +242,12 @@ export const newEmailTemplateRouteHandler = async (req, res) => { logger.debug(`New email template with ID: ${result._id}`); - res.send(result); + const populated = await getObject({ + model: emailTemplateModel, + id: result._id, + populate, + }); + res.send(populated && !populated.error ? populated : result); }; export const deleteEmailTemplateByFilterRouteHandler = async ( diff --git a/src/services/misc/__tests__/templateresources.test.js b/src/services/misc/__tests__/templateresources.test.js new file mode 100644 index 0000000..f261949 --- /dev/null +++ b/src/services/misc/__tests__/templateresources.test.js @@ -0,0 +1,87 @@ +import { EventEmitter } from 'events'; +import { jest } from '@jest/globals'; + +const getAndDeleteKey = jest.fn(); +const findById = jest.fn(); +const downloadFile = jest.fn(); + +jest.unstable_mockModule('../../../database/redis.js', () => ({ + redisServer: { getAndDeleteKey }, +})); +jest.unstable_mockModule('../../../database/ceph.js', () => ({ + BUCKETS: { FILES: 'files' }, + downloadFile, +})); +jest.unstable_mockModule('../../../database/schemas/management/file.schema.js', () => ({ + fileModel: { findById }, +})); +jest.unstable_mockModule('../../../templates/templateresources.js', () => ({ + TEMPLATE_RESOURCE_KEY_PREFIX: 'templateresources:', +})); + +const { getTemplateResourceRouteHandler } = await import('../templateresources.js'); + +function response() { + const res = new EventEmitter(); + res.status = jest.fn().mockReturnValue(res); + res.send = jest.fn().mockReturnValue(res); + res.set = jest.fn().mockReturnValue(res); + res.destroy = jest.fn(); + res.headersSent = false; + return res; +} + +describe('template resource route', () => { + beforeEach(() => jest.clearAllMocks()); + + it.each(['bad', '', undefined])('returns 404 for malformed token %s', async (token) => { + const res = response(); + await getTemplateResourceRouteHandler({ params: { token } }, res); + expect(res.status).toHaveBeenCalledWith(404); + expect(getAndDeleteKey).not.toHaveBeenCalled(); + }); + + it('atomically consumes the token and streams the stored file inline', async () => { + const token = 'a'.repeat(64); + const fileId = '507f1f77bcf86cd799439011'; + const body = Buffer.from('image'); + getAndDeleteKey.mockResolvedValue({ fileId }); + findById.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: fileId, + name: 'logo', + extension: '.png', + type: 'image/png', + size: body.length, + }), + }); + downloadFile.mockResolvedValue(body); + const res = response(); + + await getTemplateResourceRouteHandler({ params: { token } }, res); + + expect(getAndDeleteKey).toHaveBeenCalledWith(`templateresources:${token}`); + expect(downloadFile).toHaveBeenCalledWith('files', `files/${fileId}.png`); + expect(res.set).toHaveBeenCalledWith( + 'Content-Disposition', + 'inline; filename="logo.png"' + ); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store, no-transform'); + expect(res.set).toHaveBeenCalledWith('Content-Type', 'image/png'); + expect(res.set).toHaveBeenCalledWith('Content-Length', String(body.length)); + expect(res.send).toHaveBeenCalledWith(body); + }); + + it('returns the same 404 for missing, used, expired, or deleted resources', async () => { + getAndDeleteKey.mockResolvedValue(null); + const res = response(); + await getTemplateResourceRouteHandler({ params: { token: 'b'.repeat(64) } }, res); + expect(res.status).toHaveBeenCalledWith(404); + + getAndDeleteKey.mockResolvedValue({ fileId: '507f1f77bcf86cd799439011' }); + findById.mockReturnValue({ lean: jest.fn().mockResolvedValue(null) }); + const deletedRes = response(); + await getTemplateResourceRouteHandler({ params: { token: 'c'.repeat(64) } }, deletedRes); + expect(deletedRes.status).toHaveBeenCalledWith(404); + }); +}); diff --git a/src/services/misc/templateresources.js b/src/services/misc/templateresources.js new file mode 100644 index 0000000..9c281d4 --- /dev/null +++ b/src/services/misc/templateresources.js @@ -0,0 +1,53 @@ +import mongoose from 'mongoose'; +import { redisServer } from '../../database/redis.js'; +import { downloadFile, BUCKETS } from '../../database/ceph.js'; +import { fileModel } from '../../database/schemas/management/file.schema.js'; +import { TEMPLATE_RESOURCE_KEY_PREFIX } from '../../templates/templateresources.js'; + +const TOKEN_PATTERN = /^[a-f0-9]{64}$/; + +function notFound(res) { + return res.status(404).send({ error: 'Template resource not found.', code: 404 }); +} + +export async function getTemplateResourceRouteHandler(req, res) { + const token = req.params?.token; + if (!TOKEN_PATTERN.test(token || '')) return notFound(res); + + try { + const payload = await redisServer.getAndDeleteKey(`${TEMPLATE_RESOURCE_KEY_PREFIX}${token}`); + if (!payload?.fileId || !mongoose.Types.ObjectId.isValid(payload.fileId)) { + return notFound(res); + } + + const file = await fileModel.findById(payload.fileId).lean(); + if (!file?._id || typeof file.extension !== 'string') return notFound(res); + + const body = await downloadFile(BUCKETS.FILES, `files/${file._id}${file.extension}`); + if (!body || (typeof body.pipe !== 'function' && !Buffer.isBuffer(body))) { + return notFound(res); + } + + res.set('Content-Type', file.type); + const filename = `${file.name || 'resource'}${file.extension}`.replace(/["\r\n]/g, '_'); + res.set('Content-Disposition', `inline; filename="${filename}"`); + res.set('Cache-Control', 'no-store, no-transform'); + if (Number.isFinite(file.size) && file.size >= 0) { + res.set('Content-Length', String(file.size)); + } + + if (Buffer.isBuffer(body)) return res.send(body); + body.on('error', () => { + if (!res.headersSent) notFound(res); + else res.destroy(); + }); + res.on('close', () => { + if (typeof body.destroy === 'function') body.destroy(); + }); + body.pipe(res); + } catch { + return notFound(res); + } +} + +export { TOKEN_PATTERN }; diff --git a/src/templates/__tests__/templatemanager.test.js b/src/templates/__tests__/templatemanager.test.js index 40a0db2..63d32c3 100644 --- a/src/templates/__tests__/templatemanager.test.js +++ b/src/templates/__tests__/templatemanager.test.js @@ -106,6 +106,9 @@ jest.unstable_mockModule('log4js', () => ({ jest.unstable_mockModule('../../config.js', () => ({ default: { + app: { + urlApi: 'https://api.example.test', + }, server: { logLevel: 'info', }, @@ -244,6 +247,58 @@ describe('TemplateManager', () => { }); describe('renderTemplate', () => { + it('replaces each valid resource occurrence before rendering', async () => { + getObject.mockResolvedValue({ + documentSize: { width: 100, height: 100, infiniteHeight: false }, + global: true, + resources: [{ name: 'logo', file: { _id: 'file-1' } }], + }); + + await templateManager.renderTemplate( + 'temp-id', + '' + ); + + const renderedSource = ejs.render.mock.calls[0][0]; + const tokens = [...renderedSource.matchAll(/template-resources\/([a-f0-9]{64})/g)]; + expect(tokens).toHaveLength(2); + expect(tokens[0][1]).not.toBe(tokens[1][1]); + expect(redisServer.setKey).toHaveBeenCalledTimes(2); + expect(redisServer.setKey).toHaveBeenCalledWith( + expect.stringMatching(/^templateresources:[a-f0-9]{64}$/), + { fileId: 'file-1' }, + 3 + ); + }); + + it('resolves parent resources from the parent owner only', async () => { + getObject.mockResolvedValue({ + _reference: 'CHILD', + documentSize: { width: 100, height: 100, infiniteHeight: false }, + global: true, + content: '', + resources: [{ name: 'logo', file: 'child-file' }], + parent: { + _reference: 'PARENT', + content: '<%- content %>', + resources: [{ name: 'logo', file: 'parent-file' }], + }, + }); + + await templateManager.renderTemplate('temp-id'); + + expect(redisServer.setKey).toHaveBeenCalledWith( + expect.any(String), + { fileId: 'child-file' }, + 3 + ); + expect(redisServer.setKey).toHaveBeenCalledWith( + expect.any(String), + { fileId: 'parent-file' }, + 3 + ); + }); + it('should render a template successfully', async () => { const mockTemplate = { documentSize: { width: 100, height: 100, infiniteHeight: false }, @@ -281,12 +336,7 @@ describe('TemplateManager', () => { objectType: 'printer', }); - await templateManager.renderTemplate( - 'temp-id', - 'some content', - { name: 'Test' }, - 2.5 - ); + await templateManager.renderTemplate('temp-id', 'some content', { name: 'Test' }, 2.5); expect(ejs.render).toHaveBeenCalledWith( '<%- content %>', @@ -626,6 +676,7 @@ describe('TemplateManager', () => { getObject.mockResolvedValue({ global: true, content: '<% const broken = %>', + resources: [{ name: 'main-logo', file: { _id: 'file-1', type: 'image/png' } }], }); ejs.compile.mockImplementationOnce(() => { throw new SyntaxError('Unexpected token'); @@ -639,7 +690,9 @@ describe('TemplateManager', () => { 'documentTemplate' ); - expect(result).toEqual({}); + expect(result).toEqual({ + resources: ['main-logo'], + }); expect(result).not.toHaveProperty('intellisense'); }); }); diff --git a/src/templates/__tests__/templateresources.test.js b/src/templates/__tests__/templateresources.test.js new file mode 100644 index 0000000..786e774 --- /dev/null +++ b/src/templates/__tests__/templateresources.test.js @@ -0,0 +1,81 @@ +import { jest } from '@jest/globals'; + +const setKey = jest.fn(); + +jest.unstable_mockModule('../../database/redis.js', () => ({ + redisServer: { setKey }, +})); +jest.unstable_mockModule('../../config.js', () => ({ + default: { app: { urlApi: 'https://api.example.test' } }, +})); + +const { + createTemplateResourceUrl, + normalizeTemplateResourceName, + normalizeTemplateResources, + preprocessTemplateResources, + validateTemplateResources, + TEMPLATE_RESOURCE_KEY_PREFIX, + TEMPLATE_RESOURCE_TTL_SECONDS, +} = await import('../templateresources.js'); + +describe('template resources', () => { + beforeEach(() => jest.clearAllMocks()); + + it('normalizes names and populated file values', () => { + expect(normalizeTemplateResourceName(' Main Logo (Dark) ')).toBe('main-logo-dark'); + expect(normalizeTemplateResources([{ name: ' Main Logo ', file: { _id: 'file-1' } }])).toEqual([ + { _id: undefined, name: 'main-logo', file: 'file-1' }, + ]); + expect( + validateTemplateResources([ + { name: 'main-logo', file: 'file-1' }, + { name: 'main-logo', file: 'file-2' }, + ]) + ).toBe(false); + expect(validateTemplateResources([{ name: 'Main Logo', file: 'file-1' }])).toBe(false); + expect(validateTemplateResources([{ name: 'main-logo' }])).toBe(false); + }); + + it('only replaces references in XML attributes and EJS JavaScript strings', async () => { + const source = [ + '', + '@[logo]', + "<% const image = '@[logo]'; const outside = value@[logo] %>", + '<%= `@[logo]` %>', + '<%= `${value@[logo]}-${"@[logo]"}` %>', + '', + '', + ].join(''); + let occurrence = 0; + const result = await preprocessTemplateResources( + source, + [{ name: 'logo', file: 'file-1' }], + async () => `temp-${++occurrence}` + ); + + expect(result).toContain(''); + expect(result).toContain('@[logo]'); + expect(result).toContain("const image = 'temp-2'"); + expect(result).toContain('value@[logo]'); + expect(result).toContain('<%= `temp-3` %>'); + expect(result).toContain('`${value@[logo]}-${"temp-4"}`'); + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('creates a crypto-random one-time token with an exact three-second TTL', async () => { + const first = await createTemplateResourceUrl('507f1f77bcf86cd799439011'); + const second = await createTemplateResourceUrl('507f1f77bcf86cd799439011'); + + expect(first).toMatch(/^https:\/\/api\.example\.test\/template-resources\/[a-f0-9]{64}$/); + expect(second).not.toBe(first); + expect(setKey).toHaveBeenNthCalledWith( + 1, + `${TEMPLATE_RESOURCE_KEY_PREFIX}${first.split('/').at(-1)}`, + { fileId: '507f1f77bcf86cd799439011' }, + TEMPLATE_RESOURCE_TTL_SECONDS + ); + expect(TEMPLATE_RESOURCE_TTL_SECONDS).toBe(3); + }); +}); diff --git a/src/templates/templatemanager.js b/src/templates/templatemanager.js index fa4c56c..84d29a0 100644 --- a/src/templates/templatemanager.js +++ b/src/templates/templatemanager.js @@ -24,6 +24,7 @@ import { } from './templateintellisense.js'; import { eventManager } from '../events/eventmanager.js'; import { redisServer } from '../database/redis.js'; +import { preprocessTemplateResources } from './templateresources.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -467,6 +468,12 @@ function omitTemplateFc(data) { return rest; } +function getTemplateResourceSuggestions(resources) { + return (resources || []) + .map((resource) => resource?.name) + .filter((name) => typeof name === 'string' && name.length > 0); +} + function extractObjectId(value) { if (value == null || value === '') { return null; @@ -579,7 +586,11 @@ export class TemplateManager { const results = await listObjects({ model: documentTemplateModel, filter: { _reference: strippedReference }, - populate: [{ path: 'parent', strictPopulate: false }, { path: 'documentSize' }], + populate: [ + { path: 'parent', strictPopulate: false }, + { path: 'documentSize' }, + { path: 'resources.file', strictPopulate: false }, + ], pagination: false, }); @@ -626,7 +637,11 @@ export class TemplateManager { // Return a content fragment only. Page shells and previewPaginationScript // already exist on the host document. - let nestedContent = await ejs.render(nestedTemplate.content, nestedData, renderOptions); + const nestedSource = await preprocessTemplateResources( + nestedTemplate.content, + nestedTemplate.resources + ); + let nestedContent = await ejs.render(nestedSource, nestedData, renderOptions); let parentTemplate = nestedTemplate.parent; if (parentTemplate != undefined) { @@ -638,7 +653,11 @@ export class TemplateManager { parentTemplate = await getObject({ model: documentTemplateModel, id: parentTemplate._id || parentTemplate.id || parentTemplate, - populate: [{ path: 'documentSize' }, { path: 'parent', strictPopulate: false }], + populate: [ + { path: 'documentSize' }, + { path: 'parent', strictPopulate: false }, + { path: 'resources.file', strictPopulate: false }, + ], }); } if ( @@ -653,7 +672,11 @@ export class TemplateManager { } const parentData = { content: nestedContent }; parentData.fc = this.createTemplateFc(() => parentData, renderOptions, nextVisited); - nestedContent = await ejs.render(parentTemplate.content, parentData, renderOptions); + const parentSource = await preprocessTemplateResources( + parentTemplate.content, + parentTemplate.resources + ); + nestedContent = await ejs.render(parentSource, parentData, renderOptions); } return nestedContent; @@ -682,7 +705,10 @@ export class TemplateManager { const results = await listObjects({ model: emailTemplateModel, filter: { _reference: strippedReference }, - populate: [{ path: 'parent', strictPopulate: false }], + populate: [ + { path: 'parent', strictPopulate: false }, + { path: 'resources.file', strictPopulate: false }, + ], pagination: false, }); if (!Array.isArray(results) || results.length === 0) { @@ -708,7 +734,8 @@ export class TemplateManager { let data; const fc = this.createEmailTemplateFc(() => data, renderOptions, nextVisited); data = buildTemplateData(template, object, fc); - let rendered = await ejs.render(template.content || '', data, renderOptions); + const source = await preprocessTemplateResources(template.content || '', template.resources); + let rendered = await ejs.render(source, data, renderOptions); let parent = template.parent; if (parent) { @@ -716,7 +743,10 @@ export class TemplateManager { parent = await getObject({ model: emailTemplateModel, id: parent._id || parent.id || parent, - populate: [{ path: 'parent', strictPopulate: false }], + populate: [ + { path: 'parent', strictPopulate: false }, + { path: 'resources.file', strictPopulate: false }, + ], }); } if (parent?.error || typeof parent.content !== 'string') { @@ -724,7 +754,8 @@ export class TemplateManager { } const parentData = { content: rendered }; parentData.fc = this.createEmailTemplateFc(() => parentData, renderOptions, nextVisited); - rendered = await ejs.render(parent.content, parentData, renderOptions); + const parentSource = await preprocessTemplateResources(parent.content, parent.resources); + rendered = await ejs.render(parentSource, parentData, renderOptions); } return rendered; } @@ -742,7 +773,10 @@ export class TemplateManager { const template = await getObject({ model: emailTemplateModel, id, - populate: [{ path: 'parent', strictPopulate: false }], + populate: [ + { path: 'parent', strictPopulate: false }, + { path: 'resources.file', strictPopulate: false }, + ], }); if (template?.error) return template; if (template?.active === false && preview !== true) { @@ -757,7 +791,10 @@ export class TemplateManager { const fc = this.createEmailTemplateFc(() => templateData, renderOptions, visited); templateData = buildTemplateData(template, resolvedData, fc); const subjectSource = typeof subject === 'string' ? subject : template.subject || ''; - const contentSource = typeof content === 'string' ? content : template.content || ''; + const contentSource = await preprocessTemplateResources( + typeof content === 'string' ? content : template.content || '', + template.resources + ); const renderedSubject = await ejs.render(subjectSource, templateData, renderOptions); let renderedContent = await ejs.render(contentSource, templateData, renderOptions); @@ -767,6 +804,7 @@ export class TemplateManager { parent = await getObject({ model: emailTemplateModel, id: parent._id || parent.id || parent, + populate: [{ path: 'resources.file', strictPopulate: false }], }); } if (parent?.error || typeof parent.content !== 'string') { @@ -777,7 +815,8 @@ export class TemplateManager { } const parentData = { content: renderedContent }; parentData.fc = this.createEmailTemplateFc(() => parentData, renderOptions, visited); - renderedContent = await ejs.render(parent.content, parentData, renderOptions); + const parentSource = await preprocessTemplateResources(parent.content, parent.resources); + renderedContent = await ejs.render(parentSource, parentData, renderOptions); } const transformed = await transformCustomElements(renderedContent); @@ -870,7 +909,11 @@ export class TemplateManager { const documentTemplate = await getObject({ model: documentTemplateModel, id, - populate: [{ path: 'documentSize' }, { path: 'parent', strictPopulate: false }], + populate: [ + { path: 'documentSize' }, + { path: 'parent', strictPopulate: false }, + { path: 'resources.file', strictPopulate: false }, + ], }); if (documentTemplate == null || documentTemplate.error) { @@ -882,8 +925,10 @@ export class TemplateManager { return { error: 'Document template size not found.', code: 400 }; } - const templateContentSource = - content != null && typeof content === 'string' ? content : documentTemplate.content; + const templateContentSource = await preprocessTemplateResources( + content != null && typeof content === 'string' ? content : documentTemplate.content, + documentTemplate.resources + ); if (templateContentSource == null || typeof templateContentSource !== 'string') { return { error: 'Template content is required and must be a string.', code: 400 }; @@ -920,11 +965,7 @@ export class TemplateManager { templateData.fc = this.createTemplateFc(() => templateData, defaultOptions, visited); await report(0.16, 'Rendering template content...'); - const templateContent = await ejs.render( - templateContentSource, - templateData, - defaultOptions - ); + const templateContent = await ejs.render(templateContentSource, templateData, defaultOptions); var templateWithParentContent; var parentTemplate = documentTemplate.parent; @@ -934,7 +975,11 @@ export class TemplateManager { parentTemplate = await getObject({ model: documentTemplateModel, id: parentTemplate, - populate: [{ path: 'documentSize' }, { path: 'parent', strictPopulate: false }], + populate: [ + { path: 'documentSize' }, + { path: 'parent', strictPopulate: false }, + { path: 'resources.file', strictPopulate: false }, + ], }); } if ( @@ -959,11 +1004,11 @@ export class TemplateManager { const parentData = { content: templateContent }; parentData.fc = this.createTemplateFc(() => parentData, defaultOptions, visited); await report(0.22, 'Rendering parent template...'); - templateWithParentContent = await ejs.render( + const parentSource = await preprocessTemplateResources( parentTemplate.content, - parentData, - defaultOptions + parentTemplate.resources ); + templateWithParentContent = await ejs.render(parentSource, parentData, defaultOptions); } else { templateWithParentContent = templateContent; } @@ -1265,8 +1310,15 @@ export class TemplateManager { model: isEmail ? emailTemplateModel : documentTemplateModel, id, populate: isEmail - ? [{ path: 'parent', strictPopulate: false }] - : [{ path: 'documentSize' }, { path: 'parent', strictPopulate: false }], + ? [ + { path: 'parent', strictPopulate: false }, + { path: 'resources.file', strictPopulate: false }, + ] + : [ + { path: 'documentSize' }, + { path: 'parent', strictPopulate: false }, + { path: 'resources.file', strictPopulate: false }, + ], }); if (template == null || template.error) { return { error: template?.error || 'Template not found.', code: 404 }; @@ -1298,7 +1350,7 @@ export class TemplateManager { compiledTemplate = ejs.compile(session.content, renderOptions); } catch (error) { logger.debug('Skipping intellisense update due to compile error:', error.message); - return {}; + return { resources: getTemplateResourceSuggestions(template.resources) }; } try { await compiledTemplate(session.data); @@ -1310,12 +1362,8 @@ export class TemplateManager { (key) => key !== 'fc' && key !== '__fcCapture' ); return { - intellisense: filterIntellisenseToCursor( - session.collect(), - source, - cursor, - alwaysNames - ), + intellisense: filterIntellisenseToCursor(session.collect(), source, cursor, alwaysNames), + resources: getTemplateResourceSuggestions(template.resources), }; } catch (error) { logger.warn('Error collecting template intellisense:', error.message); diff --git a/src/templates/templateresources.js b/src/templates/templateresources.js new file mode 100644 index 0000000..cea5ffc --- /dev/null +++ b/src/templates/templateresources.js @@ -0,0 +1,211 @@ +import { randomBytes } from 'crypto'; +import config from '../config.js'; +import { redisServer } from '../database/redis.js'; + +const RESOURCE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const TEMPLATE_RESOURCE_KEY_PREFIX = 'templateresources:'; +const TEMPLATE_RESOURCE_TTL_SECONDS = 3; +const RESOURCE_REFERENCE_PATTERN = /@\[([a-z0-9]+(?:-[a-z0-9]+)*)\]/g; + +export function normalizeTemplateResourceName(value) { + return String(value ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export function normalizeTemplateResources(resources) { + if (resources === undefined) return undefined; + if (!Array.isArray(resources)) return []; + return resources.map((resource) => ({ + _id: resource?._id, + name: normalizeTemplateResourceName(resource?.name), + file: resource?.file?._id ?? resource?.file, + })); +} + +export function validateTemplateResources(resources) { + const names = new Set(); + for (const resource of resources || []) { + if ( + !RESOURCE_NAME_PATTERN.test(resource?.name || '') || + resource?.file == null || + resource.file === '' + ) { + return false; + } + if (names.has(resource.name)) { + return false; + } + names.add(resource.name); + } + return true; +} + +export async function createTemplateResourceUrl(file) { + const fileId = file?._id ?? file; + if (!fileId) return null; + const token = randomBytes(32).toString('hex'); + await redisServer.setKey( + `${TEMPLATE_RESOURCE_KEY_PREFIX}${token}`, + { fileId: String(fileId) }, + TEMPLATE_RESOURCE_TTL_SECONDS + ); + return `${config.app.urlApi}/template-resources/${token}`; +} + +function collectResourceReferences(source) { + const references = []; + let xmlTag = false; + let xmlQuote = null; + + const collectAt = (index) => { + if (source[index] !== '@' || source[index + 1] !== '[') return null; + RESOURCE_REFERENCE_PATTERN.lastIndex = index; + const match = RESOURCE_REFERENCE_PATTERN.exec(source); + if (match?.index === index) { + references.push({ start: index, end: RESOURCE_REFERENCE_PATTERN.lastIndex, name: match[1] }); + return RESOURCE_REFERENCE_PATTERN.lastIndex; + } + return null; + }; + + const skipComment = (index) => { + if (source[index] === '/' && source[index + 1] === '/') { + const newline = source.indexOf('\n', index + 2); + return newline === -1 ? source.length : newline; + } + if (source[index] === '/' && source[index + 1] === '*') { + const close = source.indexOf('*/', index + 2); + return close === -1 ? source.length : close + 2; + } + return null; + }; + + const scanQuotedString = (start, quote) => { + let index = start + 1; + while (index < source.length) { + if (source[index] === '\\') { + index += 2; + continue; + } + if (source[index] === quote) return index + 1; + const referenceEnd = collectAt(index); + index = referenceEnd ?? index + 1; + } + return source.length; + }; + + const scanCode = (start, stopAtInterpolationEnd = false) => { + let index = start; + let braceDepth = stopAtInterpolationEnd ? 1 : 0; + + while (index < source.length) { + if (!stopAtInterpolationEnd && source[index] === '%' && source[index + 1] === '>') { + return index + 2; + } + + const commentEnd = skipComment(index); + if (commentEnd != null) { + index = commentEnd; + continue; + } + + const char = source[index]; + if (char === '"' || char === "'") { + index = scanQuotedString(index, char); + continue; + } + if (char === '`') { + index = scanTemplateString(index); + continue; + } + if (stopAtInterpolationEnd && char === '{') { + braceDepth += 1; + } else if (stopAtInterpolationEnd && char === '}') { + braceDepth -= 1; + if (braceDepth === 0) return index + 1; + } + index += 1; + } + return source.length; + }; + + const scanTemplateString = (start) => { + let index = start + 1; + while (index < source.length) { + if (source[index] === '\\') { + index += 2; + continue; + } + if (source[index] === '`') return index + 1; + if (source[index] === '$' && source[index + 1] === '{') { + index = scanCode(index + 2, true); + continue; + } + const referenceEnd = collectAt(index); + index = referenceEnd ?? index + 1; + } + return source.length; + }; + + let index = 0; + while (index < source.length) { + const char = source[index]; + + if (char === '<' && source[index + 1] === '%') { + index = scanCode(index + 2); + continue; + } + if (!xmlQuote && char === '<') { + xmlTag = true; + index += 1; + continue; + } + if (!xmlQuote && char === '>') { + xmlTag = false; + index += 1; + continue; + } + if (xmlTag && !xmlQuote && (char === '"' || char === "'")) { + xmlQuote = char; + index += 1; + continue; + } + if (xmlQuote && char === xmlQuote) { + xmlQuote = null; + index += 1; + continue; + } + + const referenceEnd = xmlQuote ? collectAt(index) : null; + index = referenceEnd ?? index + 1; + } + + return references; +} + +export async function preprocessTemplateResources( + source, + resources, + createUrl = createTemplateResourceUrl +) { + if (typeof source !== 'string' || !source.includes('@[')) return source; + const resourceMap = new Map((resources || []).map((resource) => [resource.name, resource.file])); + const references = collectResourceReferences(source); + let output = ''; + let cursor = 0; + + for (const reference of references) { + const file = resourceMap.get(reference.name); + if (!file) continue; + const url = await createUrl(file); + if (!url) continue; + output += source.slice(cursor, reference.start) + url; + cursor = reference.end; + } + return output + source.slice(cursor); +} + +export { RESOURCE_NAME_PATTERN, TEMPLATE_RESOURCE_KEY_PREFIX, TEMPLATE_RESOURCE_TTL_SECONDS };