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