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();