diff --git a/src/database/permissions.js b/src/database/permissions.js
index 4a5b60d..47aac4a 100644
--- a/src/database/permissions.js
+++ b/src/database/permissions.js
@@ -51,6 +51,14 @@ export const hasPermission = async (user, objectType, action) => {
export const checkPermissions = (objectType, action) => async (req, res, next) => {
try {
+ if (req.user?._objectType == 'host') {
+ if (
+ (objectType === 'documentTemplate' && action === 'design') ||
+ (objectType === 'file' && action === 'download')
+ ) {
+ return next();
+ }
+ }
const allowed = await hasPermission(req.user, objectType, action);
if (!allowed) {
return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
diff --git a/src/database/schemas/management/documentsize.schema.js b/src/database/schemas/management/documentsize.schema.js
index b1caeb5..68df07e 100644
--- a/src/database/schemas/management/documentsize.schema.js
+++ b/src/database/schemas/management/documentsize.schema.js
@@ -25,6 +25,31 @@ const documentSizeSchema = new Schema(
required: true,
default: false,
},
+ printPadding: {
+ type: Boolean,
+ required: true,
+ default: false,
+ },
+ paddingLeft: {
+ type: Number,
+ required: true,
+ default: 0,
+ },
+ paddingRight: {
+ type: Number,
+ required: true,
+ default: 0,
+ },
+ paddingTop: {
+ type: Number,
+ required: true,
+ default: 0,
+ },
+ paddingBottom: {
+ type: Number,
+ required: true,
+ default: 0,
+ },
},
{ timestamps: true }
);
diff --git a/src/keycloak.js b/src/keycloak.js
index 9dc5112..ee395d6 100644
--- a/src/keycloak.js
+++ b/src/keycloak.js
@@ -63,7 +63,7 @@ const isAuthenticated = async (req, res, next) => {
try {
const session = await getSession(token);
if (session && session.expiresAt > Date.now()) {
- req.user = session.user;
+ req.user = { ...session.user, _objectType: 'user' };
req.session = session;
return next();
}
@@ -71,7 +71,7 @@ const isAuthenticated = async (req, res, next) => {
// Try email-render JWT (short-lived token for Puppeteer email notifications)
const user = await lookupUserByToken(token);
if (user) {
- req.user = user;
+ req.user = { ...user, _objectType: 'user' };
req.session = { user };
return next();
}
@@ -85,6 +85,7 @@ const isAuthenticated = async (req, res, next) => {
if (hostId && authCode) {
const host = await getObject({ model: hostModel, id: hostId });
if (host && host.authCode === authCode) {
+ req.user = { ...host, _objectType: 'host' };
return next();
}
}
diff --git a/src/routes/management/documentsizes.js b/src/routes/management/documentsizes.js
index bba42ce..d29948d 100644
--- a/src/routes/management/documentsizes.js
+++ b/src/routes/management/documentsizes.js
@@ -5,8 +5,32 @@ import { getFilter, convertPropertiesString, getSort } from '../../utils.js';
const router = express.Router();
-const listAllowedFilters = ['name', 'width', 'height', 'createdAt', 'updatedAt', '_reference'];
-const listAllowedSorters = ['name', 'width', 'height', 'infiniteHeight', 'createdAt', 'updatedAt'];
+const listAllowedFilters = [
+ 'name',
+ 'width',
+ 'height',
+ 'printPadding',
+ 'paddingLeft',
+ 'paddingRight',
+ 'paddingTop',
+ 'paddingBottom',
+ 'createdAt',
+ 'updatedAt',
+ '_reference',
+];
+const listAllowedSorters = [
+ 'name',
+ 'width',
+ 'height',
+ 'infiniteHeight',
+ 'printPadding',
+ 'paddingLeft',
+ 'paddingRight',
+ 'paddingTop',
+ 'paddingBottom',
+ 'createdAt',
+ 'updatedAt',
+];
const propertiesAllowedFilters = [];
import {
listDocumentSizesRouteHandler,
diff --git a/src/services/management/__tests__/documentsizes.test.js b/src/services/management/__tests__/documentsizes.test.js
index bb549a5..4fecc78 100644
--- a/src/services/management/__tests__/documentsizes.test.js
+++ b/src/services/management/__tests__/documentsizes.test.js
@@ -77,13 +77,34 @@ describe('Document Size Service Route Handlers', () => {
describe('newDocumentSizeRouteHandler', () => {
it('should create a new document size', async () => {
- req.body = { name: 'Letter', width: 216, height: 279 };
+ req.body = {
+ name: 'Letter',
+ width: 216,
+ height: 279,
+ infiniteHeight: false,
+ printPadding: false,
+ paddingLeft: 0,
+ paddingRight: 0,
+ paddingTop: 0,
+ paddingBottom: 0,
+ };
const mockSize = { _id: '456', ...req.body };
newObject.mockResolvedValue(mockSize);
await newDocumentSizeRouteHandler(req, res);
- expect(newObject).toHaveBeenCalled();
+ expect(newObject).toHaveBeenCalledWith(
+ expect.objectContaining({
+ newData: expect.objectContaining({
+ name: 'Letter',
+ printPadding: false,
+ paddingLeft: 0,
+ paddingRight: 0,
+ paddingTop: 0,
+ paddingBottom: 0,
+ }),
+ })
+ );
expect(res.send).toHaveBeenCalledWith(mockSize);
});
});
diff --git a/src/services/management/__tests__/documenttemplates.test.js b/src/services/management/__tests__/documenttemplates.test.js
index 597a4d1..1e7b567 100644
--- a/src/services/management/__tests__/documenttemplates.test.js
+++ b/src/services/management/__tests__/documenttemplates.test.js
@@ -113,7 +113,7 @@ describe('Document Template Service Route Handlers', () => {
'Hello',
{ name: 'Test' },
1,
- {},
+ { padding: true },
true
);
expect(res.send).toHaveBeenCalledWith(mockPreview);
@@ -155,12 +155,34 @@ describe('Document Template Service Route Handlers', () => {
'Hello',
{ name: 'Test' },
'pdf',
- {}
+ { padding: true }
);
expect(res.set).toHaveBeenCalledWith('Content-Type', 'application/pdf');
expect(res.send).toHaveBeenCalledWith(pdf);
});
+ it('passes padding false when requested by the caller', async () => {
+ req.params.id = 'template-1';
+ req.query = { type: 'pdf', padding: 'false' };
+ req.body = { object: { name: 'Test' } };
+ templateManager.renderDownload.mockResolvedValue({
+ type: 'pdf',
+ mime: 'application/pdf',
+ extension: 'pdf',
+ buffers: [Buffer.from('pdf-data')],
+ });
+
+ await downloadDocumentTemplateRouteHandler(req, res);
+
+ expect(templateManager.renderDownload).toHaveBeenCalledWith(
+ 'template-1',
+ undefined,
+ { name: 'Test' },
+ 'pdf',
+ { padding: false }
+ );
+ });
+
it('should return base64 images when multiple pages are rendered', async () => {
req.params.id = 'template-1';
req.query = { type: 'png' };
diff --git a/src/services/management/documentsizes.js b/src/services/management/documentsizes.js
index 66ca9af..9e8868b 100644
--- a/src/services/management/documentsizes.js
+++ b/src/services/management/documentsizes.js
@@ -116,6 +116,11 @@ export const editDocumentSizeRouteHandler = async (req, res) => {
width: req.body.width,
height: req.body.height,
infiniteHeight: req.body.infiniteHeight,
+ printPadding: req.body.printPadding,
+ paddingLeft: req.body.paddingLeft,
+ paddingRight: req.body.paddingRight,
+ paddingTop: req.body.paddingTop,
+ paddingBottom: req.body.paddingBottom,
};
// Create audit log before updating
const result = await editObject({
@@ -143,6 +148,11 @@ export const newDocumentSizeRouteHandler = async (req, res) => {
width: req.body.width,
height: req.body.height,
infiniteHeight: req.body.infiniteHeight,
+ printPadding: req.body.printPadding,
+ paddingLeft: req.body.paddingLeft,
+ paddingRight: req.body.paddingRight,
+ paddingTop: req.body.paddingTop,
+ paddingBottom: req.body.paddingBottom,
};
const result = await newObject({
model: documentSizeModel,
diff --git a/src/services/management/documenttemplates.js b/src/services/management/documenttemplates.js
index f00bb23..fde625a 100644
--- a/src/services/management/documenttemplates.js
+++ b/src/services/management/documenttemplates.js
@@ -20,6 +20,27 @@ import { formatTemplateContent } from '../../templates/templateformatter.js';
const logger = log4js.getLogger('Document Templates');
logger.level = config.server.logLevel;
+function parseOptionalBoolean(value) {
+ if (value === undefined || value === null || value === '') {
+ return undefined;
+ }
+ if (value === true || value === false) {
+ return value;
+ }
+ const lower = String(value).trim().toLowerCase();
+ if (lower === 'true' || lower === '1') {
+ return true;
+ }
+ if (lower === 'false' || lower === '0') {
+ return false;
+ }
+ return undefined;
+}
+
+function getPaddingOption(req, defaultValue = true) {
+ return parseOptionalBoolean(req.body?.padding ?? req.query?.padding) ?? defaultValue;
+}
+
export const listDocumentTemplatesRouteHandler = async (
req,
res,
@@ -293,6 +314,7 @@ export const previewDocumentTemplateRouteHandler = async (req, res) => {
const content = req.body?.content;
const testObject = req.body?.testObject || req.body?.object || {};
const scale = req.body?.scale ?? 1;
+ const padding = getPaddingOption(req, true);
logger.debug(`Previewing document template with ID: ${id}`);
@@ -301,7 +323,7 @@ export const previewDocumentTemplateRouteHandler = async (req, res) => {
content,
testObject,
scale,
- {},
+ { padding },
true
);
@@ -320,7 +342,9 @@ export const downloadDocumentTemplateRouteHandler = async (req, res) => {
const data = req.body?.object || req.body?.testObject || {};
const width = req.body?.width || req.query.width;
const filename = req.body?.filename || req.query.filename || 'document';
- const options = {};
+ const options = {
+ padding: getPaddingOption(req, true),
+ };
if (width) {
options.width = Number(width);
}
diff --git a/src/templates/__tests__/templatemanager.test.js b/src/templates/__tests__/templatemanager.test.js
index db28041..60c0d6a 100644
--- a/src/templates/__tests__/templatemanager.test.js
+++ b/src/templates/__tests__/templatemanager.test.js
@@ -91,11 +91,51 @@ jest.unstable_mockModule('../../config.js', () => ({
},
}));
-const { TemplateManager } = await import('../templatemanager.js');
+const { TemplateManager, resolveDocumentPadding } = await import('../templatemanager.js');
const { getObject } = await import('../../database/database.js');
const ejs = (await import('ejs')).default;
const { generatePDF } = await import('../pdffactory.js');
+describe('resolveDocumentPadding', () => {
+ const paddedSize = {
+ printPadding: false,
+ paddingLeft: 10,
+ paddingRight: 8,
+ paddingTop: 6,
+ paddingBottom: 4,
+ };
+
+ it('does not apply padding unless requested or printPadding is true', () => {
+ expect(resolveDocumentPadding(paddedSize, false)).toEqual({
+ applyPadding: false,
+ paddingLeft: 0,
+ paddingRight: 0,
+ paddingTop: 0,
+ paddingBottom: 0,
+ });
+ });
+
+ it('applies padding when requested', () => {
+ expect(resolveDocumentPadding(paddedSize, true)).toEqual({
+ applyPadding: true,
+ paddingLeft: 10,
+ paddingRight: 8,
+ paddingTop: 6,
+ paddingBottom: 4,
+ });
+ });
+
+ it('applies padding when printPadding is true even if not requested', () => {
+ expect(resolveDocumentPadding({ ...paddedSize, printPadding: true }, false)).toEqual({
+ applyPadding: true,
+ paddingLeft: 10,
+ paddingRight: 8,
+ paddingTop: 6,
+ paddingBottom: 4,
+ });
+ });
+});
+
describe('TemplateManager', () => {
let templateManager;
@@ -161,6 +201,87 @@ describe('TemplateManager', () => {
expect(generatePDF).toHaveBeenCalled();
expect(result.pdf).toBeDefined();
});
+
+ it('adds padding to page size when padding is requested', async () => {
+ getObject.mockResolvedValue({
+ documentSize: {
+ width: 100,
+ height: 50,
+ infiniteHeight: false,
+ printPadding: false,
+ paddingLeft: 10,
+ paddingRight: 10,
+ paddingTop: 5,
+ paddingBottom: 5,
+ },
+ global: false,
+ objectType: 'printer',
+ });
+
+ await templateManager.renderPDF('temp-id', 'content', {}, { padding: true });
+
+ expect(generatePDF).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ width: 120,
+ height: 60,
+ })
+ );
+ });
+
+ it('adds padding when printPadding is true even if padding is false', async () => {
+ getObject.mockResolvedValue({
+ documentSize: {
+ width: 100,
+ height: 50,
+ infiniteHeight: false,
+ printPadding: true,
+ paddingLeft: 10,
+ paddingRight: 10,
+ paddingTop: 5,
+ paddingBottom: 5,
+ },
+ global: false,
+ objectType: 'printer',
+ });
+
+ await templateManager.renderPDF('temp-id', 'content', {}, { padding: false });
+
+ expect(generatePDF).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ width: 120,
+ height: 60,
+ })
+ );
+ });
+
+ it('keeps the original page size when padding is not applied', async () => {
+ getObject.mockResolvedValue({
+ documentSize: {
+ width: 100,
+ height: 50,
+ infiniteHeight: false,
+ printPadding: false,
+ paddingLeft: 10,
+ paddingRight: 10,
+ paddingTop: 5,
+ paddingBottom: 5,
+ },
+ global: false,
+ objectType: 'printer',
+ });
+
+ await templateManager.renderPDF('temp-id', 'content', {}, { padding: false });
+
+ expect(generatePDF).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ width: 100,
+ height: 50,
+ })
+ );
+ });
});
describe('renderDownload', () => {
diff --git a/src/templates/assets/basetemplate.ejs b/src/templates/assets/basetemplate.ejs
index b236782..cec82b4 100644
--- a/src/templates/assets/basetemplate.ejs
+++ b/src/templates/assets/basetemplate.ejs
@@ -18,18 +18,22 @@
}
.previewWrapper {
- width: <%= (scaledWidth) || '50mm' %>;
- height: <%= (scaledHeight) || '50mm' %>;
+ width: <%= (scaledPageWidth) || (scaledWidth) || '50mm' %>;
+ height: <%= (scaledPageHeight) || (scaledHeight) || '50mm' %>;
}
.previewDocument {
width: <%= (width) || '50mm' %>;
height: <%= (height) || '50mm' %>;
+ padding: <%= (paddingTop) || '0mm' %> <%= (paddingRight) || '0mm' %> <%= (paddingBottom) || '0mm' %> <%= (paddingLeft) || '0mm' %>;
+ box-sizing: content-box;
transform: scale(<%= scale || '1' %>);
transform-origin: top left;
}
.renderDocument {
- width: <%= (scaledWidth) || '50mm' %>;
- height: <%= (scaledHeight) || '50mm' %>;
+ width: <%= (width) || '50mm' %>;
+ height: <%= (height) || '50mm' %>;
+ padding: <%= (paddingTop) || '0mm' %> <%= (paddingRight) || '0mm' %> <%= (paddingBottom) || '0mm' %> <%= (paddingLeft) || '0mm' %>;
+ box-sizing: content-box;
transform: scale(<%= scale || '1' %>);
}
diff --git a/src/templates/assets/styles.css b/src/templates/assets/styles.css
index a25c38d..1565a7c 100644
--- a/src/templates/assets/styles.css
+++ b/src/templates/assets/styles.css
@@ -25,6 +25,11 @@ body {
transform-origin: top left;
position: relative;
overflow: hidden;
+ box-sizing: content-box;
+}
+
+.renderDocument {
+ box-sizing: content-box;
}
.documentText {
diff --git a/src/templates/templatemanager.js b/src/templates/templatemanager.js
index 340c0a8..596e316 100644
--- a/src/templates/templatemanager.js
+++ b/src/templates/templatemanager.js
@@ -357,6 +357,32 @@ function imageToSvg(pngBuffer, widthPx, heightPx, documentWidthMm, documentHeigh
`;
}
+function toPaddingMm(value) {
+ const numeric = Number(value);
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
+}
+
+export function resolveDocumentPadding(documentSize = {}, paddingRequested = false) {
+ const applyPadding = documentSize.printPadding == true || paddingRequested == true;
+ if (!applyPadding) {
+ return {
+ applyPadding: false,
+ paddingLeft: 0,
+ paddingRight: 0,
+ paddingTop: 0,
+ paddingBottom: 0,
+ };
+ }
+
+ return {
+ applyPadding: true,
+ paddingLeft: toPaddingMm(documentSize.paddingLeft),
+ paddingRight: toPaddingMm(documentSize.paddingRight),
+ paddingTop: toPaddingMm(documentSize.paddingTop),
+ paddingBottom: toPaddingMm(documentSize.paddingBottom),
+ };
+}
+
export class TemplateManager {
constructor() {
this.fc = {
@@ -368,9 +394,10 @@ export class TemplateManager {
async renderTemplate(id, content, data = {}, scale = 1, options = {}, preview = true) {
try {
+ const { padding: paddingRequested = false, ...ejsOptions } = options;
const defaultOptions = {
async: true,
- ...options,
+ ...ejsOptions,
};
logger.debug('Rendering template:', id);
@@ -477,6 +504,11 @@ export class TemplateManager {
}
const infiniteHeight = documentSize.infiniteHeight == true;
+ const padding = resolveDocumentPadding(documentSize, paddingRequested == true);
+ const pageWidth = (documentSize.width || 0) + padding.paddingLeft + padding.paddingRight;
+ const pageHeight = infiniteHeight
+ ? null
+ : (documentSize.height || 0) + padding.paddingTop + padding.paddingBottom;
const baseHtml = await ejs.render(
baseTemplate,
@@ -486,6 +518,12 @@ export class TemplateManager {
height: infiniteHeight ? 'fit-content' : `${documentSize.height}mm`,
scaledWidth: `${documentSize.width * scale}mm`,
scaledHeight: infiniteHeight ? 'auto' : `${documentSize.height * scale}mm`,
+ scaledPageWidth: `${pageWidth * scale}mm`,
+ scaledPageHeight: infiniteHeight ? 'auto' : `${pageHeight * scale}mm`,
+ paddingTop: `${padding.paddingTop}mm`,
+ paddingRight: `${padding.paddingRight}mm`,
+ paddingBottom: `${padding.paddingBottom}mm`,
+ paddingLeft: `${padding.paddingLeft}mm`,
scale: `${scale}`,
baseCSS: baseCSS,
previewPaginationScript: preview ? previewPaginationScript : '',
@@ -495,9 +533,14 @@ export class TemplateManager {
return {
html: baseHtml,
- width: documentSize.width,
- height: infiniteHeight ? 'auto' : documentSize.height,
+ width: pageWidth,
+ height: infiniteHeight ? 'auto' : pageHeight,
infiniteHeight: infiniteHeight,
+ printPadding: documentSize.printPadding == true,
+ paddingLeft: padding.paddingLeft,
+ paddingRight: padding.paddingRight,
+ paddingTop: padding.paddingTop,
+ paddingBottom: padding.paddingBottom,
};
} catch (error) {
console.error(error);
@@ -548,12 +591,13 @@ export class TemplateManager {
try {
logger.debug(`Rendering ${format} for template:`, id);
+ const { padding: _padding, ...imageOptions } = options;
const pdfResult = await this.renderPDF(id, content, data, options);
if (pdfResult.error != undefined) {
return { error: pdfResult.error, code: pdfResult.code };
}
- const images = await convertPDFToImage(pdfResult.pdf, options);
+ const images = await convertPDFToImage(pdfResult.pdf, imageOptions);
const converted = [];
for (const image of images) {
if (format === 'png') {
@@ -573,12 +617,13 @@ export class TemplateManager {
try {
logger.debug('Rendering SVG for template:', id);
+ const { padding: _padding, ...imageOptions } = options;
const pdfResult = await this.renderPDF(id, content, data, options);
if (pdfResult.error != undefined) {
return { error: pdfResult.error, code: pdfResult.code };
}
- const images = await convertPDFToImage(pdfResult.pdf, options);
+ const images = await convertPDFToImage(pdfResult.pdf, imageOptions);
const svgs = [];
for (const image of images) {
const png = await sharp(image).png().toBuffer();