From 171d3094a2354441621df7af77e2a84e09a9e43a Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 13 Sep 2026 19:26:13 +0100 Subject: [PATCH] Add template resource management features and routes This commit introduces a new schema for managing template resources, including validation and normalization functions. It adds routes for retrieving template resources and integrates resource handling into document and email template services. The changes enhance the application's template management capabilities by allowing for unique resource names and improved resource URL generation. Additionally, tests are added to ensure the correct functionality of the new features, including resource validation and rendering. --- .../management/documenttemplate.schema.js | 29 ++- .../management/emailtemplate.schema.js | 30 ++- src/index.js | 2 + src/routes/index.js | 2 + src/routes/misc/templateresources.js | 8 + .../__tests__/documenttemplates.test.js | 40 +++- .../__tests__/emailtemplates.test.js | 33 ++- src/services/management/documenttemplates.js | 45 +++- src/services/management/emailtemplates.js | 29 ++- .../misc/__tests__/templateresources.test.js | 87 ++++++++ src/services/misc/templateresources.js | 53 +++++ .../__tests__/templatemanager.test.js | 67 +++++- .../__tests__/templateresources.test.js | 81 +++++++ src/templates/templatemanager.js | 112 +++++++--- src/templates/templateresources.js | 211 ++++++++++++++++++ 15 files changed, 765 insertions(+), 64 deletions(-) create mode 100644 src/routes/misc/templateresources.js create mode 100644 src/services/misc/__tests__/templateresources.test.js create mode 100644 src/services/misc/templateresources.js create mode 100644 src/templates/__tests__/templateresources.test.js create mode 100644 src/templates/templateresources.js 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 };