Add template resource management features and routes
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
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.
This commit is contained in:
parent
622f4b20f2
commit
171d3094a2
@ -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 }
|
||||
);
|
||||
|
||||
@ -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: '<Container></Container>' },
|
||||
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 }
|
||||
);
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
8
src/routes/misc/templateresources.js
Normal file
8
src/routes/misc/templateresources.js
Normal file
@ -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;
|
||||
@ -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', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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' })],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
87
src/services/misc/__tests__/templateresources.test.js
Normal file
87
src/services/misc/__tests__/templateresources.test.js
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
53
src/services/misc/templateresources.js
Normal file
53
src/services/misc/templateresources.js
Normal file
@ -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 };
|
||||
@ -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',
|
||||
'<Image src="@[logo]" /><Image src="@[logo]" />'
|
||||
);
|
||||
|
||||
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: '<Image src="@[logo]" />',
|
||||
resources: [{ name: 'logo', file: 'child-file' }],
|
||||
parent: {
|
||||
_reference: 'PARENT',
|
||||
content: '<Image src="@[logo]" /><%- 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(
|
||||
'<html><%- content %></html>',
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
81
src/templates/__tests__/templateresources.test.js
Normal file
81
src/templates/__tests__/templateresources.test.js
Normal file
@ -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 = [
|
||||
'<Image src="@[logo]" />',
|
||||
'<Text>@[logo]</Text>',
|
||||
"<% const image = '@[logo]'; const outside = value@[logo] %>",
|
||||
'<%= `@[logo]` %>',
|
||||
'<%= `${value@[logo]}-${"@[logo]"}` %>',
|
||||
'<Image src="<%= path %>/@[logo]" />',
|
||||
'<Image src="@[unknown]" />',
|
||||
].join('');
|
||||
let occurrence = 0;
|
||||
const result = await preprocessTemplateResources(
|
||||
source,
|
||||
[{ name: 'logo', file: 'file-1' }],
|
||||
async () => `temp-${++occurrence}`
|
||||
);
|
||||
|
||||
expect(result).toContain('<Image src="temp-1" />');
|
||||
expect(result).toContain('<Text>@[logo]</Text>');
|
||||
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('<Image src="<%= path %>/temp-5" />');
|
||||
expect(result).toContain('<Image src="@[unknown]" />');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
|
||||
211
src/templates/templateresources.js
Normal file
211
src/templates/templateresources.js
Normal file
@ -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 };
|
||||
Loading…
x
Reference in New Issue
Block a user