From 4c45be18036f06762cf9d4577d088e3970c0f8c4 Mon Sep 17 00:00:00 2001 From: rpletz Date: Tue, 7 Jan 2025 11:39:53 -0800 Subject: [PATCH 1/7] Add auto image resizing --- app/libs/image.js | 44 +++++++-- app/routes/report/images.js | 191 +----------------------------------- 2 files changed, 40 insertions(+), 195 deletions(-) diff --git a/app/libs/image.js b/app/libs/image.js index ec46cf7aa..2171d3317 100644 --- a/app/libs/image.js +++ b/app/libs/image.js @@ -1,6 +1,35 @@ const sharp = require('sharp'); const db = require('../models'); +/** + * Delete image from images table + * + * @param {string} ident - Ident of image to delete + * @param {boolean} force - Whether it is a hard delete or not + * @param {object} transaction - Transaction to run delete under + * @returns {Promise} - Returns the newly updated image data + */ +const autoDownsize = async (image, size, format = 'png') => { + // Base case: If the image size is less than or equal to size, return the image + console.log(size); + if (image.info.size <= size) { + return image; + } + + // Process the image (resize and format) and update the size + const resizedImage = await sharp(image.data) + .resize( + Math.floor(image.info.width / 1.5), + Math.floor(image.info.height / 1.5), + {fit: 'inside', withoutEnlargement: true}, + ) + .toFormat(format.toLowerCase()) + .toBuffer({resolveWithObject: true}); + + // Recursive call with the resized image + return autoDownsize(resizedImage, size, format); +}; + /** * Resize, reformat and return base64 representation of image. * @@ -12,13 +41,14 @@ const db = require('../models'); * @returns {Promise} - Returns base64 representation of image * @throws {Promise} - File doesn't exist, incorrect permissions, etc. */ -const processImage = async (image, width, height, format = 'png') => { - const imageData = await sharp(image) - .resize(width, height, {fit: 'inside', withoutEnlargement: true}) +const processImage = async (image, size, format = 'png') => { + let imageData = await sharp(image) .toFormat(format.toLowerCase()) - .toBuffer(); + .toBuffer({resolveWithObject: true}); + + imageData = await autoDownsize(imageData, size, format); - return imageData.toString('base64'); + return imageData.data.toString('base64'); }; /** @@ -37,11 +67,11 @@ const processImage = async (image, width, height, format = 'png') => { */ const uploadImage = async (image, options = {}) => { const { - data, width, height, filename, format, type, + data, filename, format, type, } = image; // Resize image - const imageData = await processImage(data, width, height, format.replace('image/', '')); + const imageData = await processImage(data, format.replace('image/', '')); // Upload image data return db.models.image.create({ diff --git a/app/routes/report/images.js b/app/routes/report/images.js index db7a71f0d..a40294c59 100644 --- a/app/routes/report/images.js +++ b/app/routes/report/images.js @@ -2,182 +2,9 @@ const logger = require('../../log'); const db = require('../../models'); const {processImage} = require('../../libs/image'); -const DEFAULT_WIDTH = 500; -const DEFAULT_HEIGHT = 500; +const DEFAULT_SIZE = 20000; // In bytes const DEFAULT_FORMAT = 'PNG'; -// takes first pattern match (order matters) -const IMAGES_CONFIG = [ - { - pattern: 'subtypePlot\\.ped_\\S+', - width: 420, - height: 900, - format: 'PNG', - }, - { - pattern: 'subtypePlot\\.\\S+', - width: 600, - height: 375, - format: 'PNG', - }, - { - pattern: '(cnv|loh)\\.[1]', - height: 166, - width: 1000, - format: 'PNG', - }, - { - pattern: '(cnv|loh)\\.[2345]', - height: 161, - width: 1000, - format: 'PNG', - }, - { - pattern: 'mutationBurden\\.(barplot|density|legend)_(sv|snv|indel|snv_indel)\\.(primary|secondary|tertiary|quaternary)', - width: 560, - height: 151, - format: 'PNG', - }, - { - pattern: 'cnvLoh.circos', - width: 1000, - height: 1000, - format: 'JPG', - }, - { - pattern: 'mutSignature.corPcors\\.(dbs|indels|sbs)', - width: 1000, - height: 2000, - format: 'JPG', - }, - { - pattern: 'mutSignature.barplot\\.sbs', - width: 480, - height: 480, - format: 'PNG', - }, - { - pattern: 'mutSignature.barplot\\.(dbs|indels)', - width: 2000, - height: 1000, - format: 'PNG', - }, - { - pattern: 'circosSv\\.(genome|transcriptome)', - width: 1001, - height: 900, - format: 'PNG', - }, - { - pattern: 'expDensity.histogram.\\S+', - width: 450, - height: 450, - format: 'PNG', - }, - { - pattern: 'expDensity.violin.\\S+', - width: 825, - height: 1965, - format: 'PNG', - }, - { - pattern: 'expression\\.chart', - width: 800, - height: 1500, - format: 'PNG', - }, - { - pattern: 'expression\\.legend', - width: 800, - height: 400, - format: 'PNG', - }, - { - pattern: 'microbial\\.circos\\.(genome|transcriptome)', - width: 900, - height: 900, - format: 'PNG', - }, - { - pattern: 'cibersort\\.(cd8_positive|combined)_t-cell_scatter', - width: 1020, - height: 1020, - format: 'PNG', - }, - { - pattern: 'mixcr\\.circos_trb_vj_gene_usage', - width: 1000, - height: 1000, - format: 'PNG', - }, - { - pattern: 'mixcr\\.dominance_vs_alpha_beta_t-cells_scatter', - width: 640, - height: 480, - format: 'PNG', - }, - { - pattern: 'scpPlot', - width: 1400, - height: 900, - format: 'PNG', - }, - { - pattern: 'msi.scatter', - width: 1000, - height: 1000, - format: 'PNG', - }, - { - pattern: 'pathwayAnalysis.legend', - width: 990, - height: 765, - format: 'PNG', - }, - { - pattern: 'expression.spearman.tcga', - width: 1100, - height: 3000, - format: 'PNG', - }, - { - pattern: 'expression.spearman.gtex', - width: 1100, - height: 4050, - format: 'PNG', - }, - { - pattern: 'expression.spearman.cser', - width: 1100, - height: 1125, - format: 'PNG', - }, - { - pattern: 'expression.spearman.hartwig', - width: 1100, - height: 1500, - format: 'PNG', - }, - { - pattern: 'expression.spearman.pediatric', - width: 1100, - height: 2775, - format: 'PNG', - }, - { - pattern: 'expression.spearman.target', - width: 640, - height: 480, - format: 'PNG', - }, - { - pattern: 'expression.spearman.brca\\.(molecular|receptor)', - width: 1100, - height: 450, - format: 'PNG', - }, -]; - /** * Resize, reformat and upload a report image to the reports_image_data table * @@ -197,22 +24,10 @@ const IMAGES_CONFIG = [ const uploadReportImage = async (reportId, key, image, options = {}) => { logger.verbose(`Loading (${key}) image`); - let config; - for (const {pattern, ...conf} of IMAGES_CONFIG) { - const regexp = new RegExp(`^${pattern}$`); - if (regexp.exec(key)) { - config = conf; - break; - } - } - - if (!config) { - logger.warn(`No format/size configuration for ${key}. Using default values`); - config = {format: DEFAULT_FORMAT, height: DEFAULT_HEIGHT, width: DEFAULT_WIDTH}; - } + const config = {format: DEFAULT_FORMAT, size: DEFAULT_SIZE}; try { - const imageData = await processImage(image, config.width, config.height, config.format); + const imageData = await processImage(image, config.size, config.format); return db.models.imageData.create({ reportId, From 8470f064f1e59e5f9be52afd34da7a2ad31751f4 Mon Sep 17 00:00:00 2001 From: rpletz Date: Tue, 7 Jan 2025 11:59:31 -0800 Subject: [PATCH 2/7] Add a max allowed limit of images --- README.md | 8 -------- app/constants.js | 20 -------------------- app/routes/report/image/index.js | 24 +++--------------------- app/schemas/report/reportUpload/index.js | 4 ++-- 4 files changed, 5 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 027cf10bd..88e64468d 100644 --- a/README.md +++ b/README.md @@ -152,14 +152,6 @@ sequence by running this SQL command after the insert: Otherwise, you will get a unique constraint error when inserting via the API because Sequelize will try use an id that is now taken. -## Adding new image types - -Steps to add a new image type: -1. Open file app/routes/report/images.js -2. Add new object to IMAGES_CONFIG. Pattern will be the regex for the image key being uploaded -3. Open file app/constants.js -4. Add new entry to VALID_IMAGE_KEY_PATTERN based on the IMAGES_CONFIG pattern - ## Process Manager The production installation of IPR is run & managed by [pm2](http://pm2.keymetrics.io/) diff --git a/app/constants.js b/app/constants.js index d34e37498..04ae5dfdd 100644 --- a/app/constants.js +++ b/app/constants.js @@ -23,26 +23,6 @@ module.exports = { 'probeResults', 'proteinVariants', ], - VALID_IMAGE_KEY_PATTERN: `^${[ - 'mutSignature\\.(corPcors|barplot)\\.(dbs|indels|sbs)', - 'subtypePlot\\.\\S+', - '(cnv|loh)\\.[12345]', - 'cnvLoh.circos', - 'mutationBurden\\.(barplot|density|legend)_(sv|snv|indel|snv_indel)\\.(primary|secondary|tertiary|quaternary)', - 'circosSv\\.(genome|transcriptome)', - 'expDensity\\.(histogram|violin\\.(tcga|gtex|cser|hartwig|pediatric))\\.\\S+', - 'expression\\.(chart|legend|spearman\\.(tcga|gtex|cser|hartwig|target|pediatric|brca\\.(molecular|receptor)))', - 'microbial\\.circos\\.(genome|transcriptome)', - 'cibersort\\.(cd8_positive|combined)_t-cell_scatter', - 'mixcr\\.circos_trb_vj_gene_usage', - 'mixcr\\.dominance_vs_alpha_beta_t-cells_scatter', - 'scpPlot', - 'msi.scatter', - 'pathwayAnalysis.legend', - 'copyNumber(.*)', - ].map((patt) => { - return `(${patt})`; - }).join('|')}$`, REPORT_CREATE_BASE_URI: '/reports/create', REPORT_UPDATE_BASE_URI: '/reports/update', GERMLINE_CREATE_BASE_URI: '/germline/create', diff --git a/app/routes/report/image/index.js b/app/routes/report/image/index.js index 108b620ad..126f5e44e 100644 --- a/app/routes/report/image/index.js +++ b/app/routes/report/image/index.js @@ -5,7 +5,6 @@ const {Op} = require('sequelize'); const db = require('../../../models'); const logger = require('../../../log'); const {uploadReportImage} = require('../images'); -const {VALID_IMAGE_KEY_PATTERN} = require('../../../constants'); const router = express.Router({mergeParams: true}); @@ -62,26 +61,9 @@ router.route('/') return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: 'No attached images to upload'}}); } - // Check for valid and duplicate keys - const keys = []; - const pattern = new RegExp(VALID_IMAGE_KEY_PATTERN); - - for (let [key, value] of Object.entries(req.files)) { - key = key.trim(); - - // Check if key is valid - if (!pattern.test(key)) { - logger.error(`Invalid key: ${key}`); - return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: `Invalid key: ${key}`}}); - } - - // Check if key is a duplicate - if (keys.includes(key) || Array.isArray(value)) { - logger.error(`Duplicate keys are not allowed. Duplicate key: ${key}`); - return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: `Duplicate keys are not allowed. Duplicate key: ${key}`}}); - } - - keys.push(key); + if (Object.entries(req.files).length > 80) { + logger.error('Image limit reached'); + return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: 'Image limit reached'}}); } try { diff --git a/app/schemas/report/reportUpload/index.js b/app/schemas/report/reportUpload/index.js index 7c0d455db..f175b51b7 100644 --- a/app/schemas/report/reportUpload/index.js +++ b/app/schemas/report/reportUpload/index.js @@ -3,7 +3,7 @@ const {BASE_EXCLUDE} = require('../../exclude'); const variantSchemas = require('./variant'); const kbMatchesSchema = require('./kbMatches'); const schemaGenerator = require('../../schemaGenerator'); -const {VALID_IMAGE_KEY_PATTERN, UPLOAD_BASE_URI} = require('../../../constants'); +const {UPLOAD_BASE_URI} = require('../../../constants'); /** * Generate schema for uploading a report @@ -31,6 +31,7 @@ const generateReportUploadSchema = (isJsonSchema) => { }, images: { type: 'array', + maxItems: 80, items: { type: 'object', required: ['path', 'key'], @@ -40,7 +41,6 @@ const generateReportUploadSchema = (isJsonSchema) => { }, key: { type: 'string', - pattern: VALID_IMAGE_KEY_PATTERN, }, }, }, From aa37d72eb79a42762c1ec2d89964760b7525a21e Mon Sep 17 00:00:00 2001 From: rpletz Date: Tue, 7 Jan 2025 14:36:11 -0800 Subject: [PATCH 3/7] Add size control --- app/constants.js | 1 + app/libs/createReport.js | 26 +++++++++++++++++++++++++- app/libs/image.js | 1 - 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/app/constants.js b/app/constants.js index 04ae5dfdd..cf6d18016 100644 --- a/app/constants.js +++ b/app/constants.js @@ -37,4 +37,5 @@ module.exports = { MASTER_REPORT_ACCESS: ['admin', 'manager'], ALL_PROJECTS_ACCESS: ['admin', 'all projects access'], UPDATE_METHODS: ['POST', 'PUT', 'DELETE'], + IMAGE_UPLOAD_LIMIT: 3000000, }; diff --git a/app/libs/createReport.js b/app/libs/createReport.js index 9682089e1..528f4e0bf 100644 --- a/app/libs/createReport.js +++ b/app/libs/createReport.js @@ -4,7 +4,7 @@ const path = require('path'); const db = require('../models'); const {uploadReportImage} = require('../routes/report/images'); const logger = require('../log'); -const {GENE_LINKED_VARIANT_MODELS, KB_PIVOT_MAPPING} = require('../constants'); +const {GENE_LINKED_VARIANT_MODELS, KB_PIVOT_MAPPING, IMAGE_UPLOAD_LIMIT} = require('../constants'); const {sanitizeHtml} = require('./helperFunctions'); const EXCLUDE_SECTIONS = new Set([ @@ -418,6 +418,30 @@ const createReport = async (data) => { // Create report sections try { await createReportSections(report, data, transaction); + + try { + const result = await db.query( + `SELECT + SUM(pg_column_size("reports_image_data")) AS avg_size_bytes + FROM + "reports_image_data" + WHERE + "report_id" = :reportId`, + { + replacements: {reportId: report.id}, + type: 'select', + transaction, + }, + ); + + const totalImageSize = result[0].avg_size_bytes; + if (totalImageSize > IMAGE_UPLOAD_LIMIT) { + throw new Error(`Total image size exceeds ${IMAGE_UPLOAD_LIMIT / 1000000} megabytes`); + } + } catch (error) { + throw new Error('Error getting image size'); + } + await transaction.commit(); return report; } catch (error) { diff --git a/app/libs/image.js b/app/libs/image.js index 2171d3317..27600853d 100644 --- a/app/libs/image.js +++ b/app/libs/image.js @@ -11,7 +11,6 @@ const db = require('../models'); */ const autoDownsize = async (image, size, format = 'png') => { // Base case: If the image size is less than or equal to size, return the image - console.log(size); if (image.info.size <= size) { return image; } From 9f16fa599ca9f2332a1115cc5539188105f7bd0f Mon Sep 17 00:00:00 2001 From: rpletz Date: Tue, 7 Jan 2025 14:37:48 -0800 Subject: [PATCH 4/7] Remove maxitems --- app/schemas/report/reportUpload/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/schemas/report/reportUpload/index.js b/app/schemas/report/reportUpload/index.js index f175b51b7..fae16b02a 100644 --- a/app/schemas/report/reportUpload/index.js +++ b/app/schemas/report/reportUpload/index.js @@ -31,7 +31,6 @@ const generateReportUploadSchema = (isJsonSchema) => { }, images: { type: 'array', - maxItems: 80, items: { type: 'object', required: ['path', 'key'], From 120305e98995bdbff3b1cf92db73c6f8c83474af Mon Sep 17 00:00:00 2001 From: rpletz Date: Tue, 7 Jan 2025 14:39:04 -0800 Subject: [PATCH 5/7] Remove 80 images limit --- app/routes/report/image/index.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/routes/report/image/index.js b/app/routes/report/image/index.js index 126f5e44e..931e2d47e 100644 --- a/app/routes/report/image/index.js +++ b/app/routes/report/image/index.js @@ -61,11 +61,6 @@ router.route('/') return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: 'No attached images to upload'}}); } - if (Object.entries(req.files).length > 80) { - logger.error('Image limit reached'); - return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: 'Image limit reached'}}); - } - try { const results = await Promise.all(Object.entries(req.files).map(async ([key, image]) => { // Remove trailing space from key From 9cae9843e05f955c91318be91f6314189ac7e28a Mon Sep 17 00:00:00 2001 From: rpletz Date: Tue, 7 Jan 2025 15:05:33 -0800 Subject: [PATCH 6/7] Rollback --- app/libs/createReport.js | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/app/libs/createReport.js b/app/libs/createReport.js index 528f4e0bf..072ef2f33 100644 --- a/app/libs/createReport.js +++ b/app/libs/createReport.js @@ -419,27 +419,23 @@ const createReport = async (data) => { try { await createReportSections(report, data, transaction); - try { - const result = await db.query( - `SELECT + const result = await db.query( + `SELECT SUM(pg_column_size("reports_image_data")) AS avg_size_bytes FROM "reports_image_data" WHERE "report_id" = :reportId`, - { - replacements: {reportId: report.id}, - type: 'select', - transaction, - }, - ); + { + replacements: {reportId: report.id}, + type: 'select', + transaction, + }, + ); - const totalImageSize = result[0].avg_size_bytes; - if (totalImageSize > IMAGE_UPLOAD_LIMIT) { - throw new Error(`Total image size exceeds ${IMAGE_UPLOAD_LIMIT / 1000000} megabytes`); - } - } catch (error) { - throw new Error('Error getting image size'); + const totalImageSize = result[0].avg_size_bytes; + if (totalImageSize > IMAGE_UPLOAD_LIMIT) { + throw new Error(`Total image size exceeds ${IMAGE_UPLOAD_LIMIT / 1000000} megabytes`); } await transaction.commit(); From e64c3df1d36e31c5ace737f42eb7a626944653a0 Mon Sep 17 00:00:00 2001 From: rpletz Date: Wed, 8 Jan 2025 12:06:22 -0800 Subject: [PATCH 7/7] Fix image dependant sections --- app/constants.js | 1 + app/libs/image.js | 5 +++-- app/routes/report/image/index.js | 14 ++++++++++++++ app/routes/report/images.js | 4 ++-- app/routes/template/template.js | 4 ---- test/routes/report/image/index.test.js | 13 ------------- 6 files changed, 20 insertions(+), 21 deletions(-) diff --git a/app/constants.js b/app/constants.js index cf6d18016..80a120362 100644 --- a/app/constants.js +++ b/app/constants.js @@ -38,4 +38,5 @@ module.exports = { ALL_PROJECTS_ACCESS: ['admin', 'all projects access'], UPDATE_METHODS: ['POST', 'PUT', 'DELETE'], IMAGE_UPLOAD_LIMIT: 3000000, + IMAGE_SIZE_LIMIT: 20000, // in bytes }; diff --git a/app/libs/image.js b/app/libs/image.js index 27600853d..23d2df60b 100644 --- a/app/libs/image.js +++ b/app/libs/image.js @@ -1,5 +1,6 @@ const sharp = require('sharp'); const db = require('../models'); +const {IMAGE_SIZE_LIMIT} = require('../constants'); /** * Delete image from images table @@ -40,7 +41,7 @@ const autoDownsize = async (image, size, format = 'png') => { * @returns {Promise} - Returns base64 representation of image * @throws {Promise} - File doesn't exist, incorrect permissions, etc. */ -const processImage = async (image, size, format = 'png') => { +const processImage = async (image, size = IMAGE_SIZE_LIMIT, format = 'png') => { let imageData = await sharp(image) .toFormat(format.toLowerCase()) .toBuffer({resolveWithObject: true}); @@ -70,7 +71,7 @@ const uploadImage = async (image, options = {}) => { } = image; // Resize image - const imageData = await processImage(data, format.replace('image/', '')); + const imageData = await processImage(data, IMAGE_SIZE_LIMIT, format.replace('image/', '')); // Upload image data return db.models.image.create({ diff --git a/app/routes/report/image/index.js b/app/routes/report/image/index.js index 931e2d47e..72c9525fa 100644 --- a/app/routes/report/image/index.js +++ b/app/routes/report/image/index.js @@ -61,6 +61,20 @@ router.route('/') return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: 'No attached images to upload'}}); } + const keys = []; + + for (let [key, value] of Object.entries(req.files)) { + key = key.trim(); + + // Check if key is a duplicate + if (keys.includes(key) || Array.isArray(value)) { + logger.error(`Duplicate keys are not allowed. Duplicate key: ${key}`); + return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: `Duplicate keys are not allowed. Duplicate key: ${key}`}}); + } + + keys.push(key); + } + try { const results = await Promise.all(Object.entries(req.files).map(async ([key, image]) => { // Remove trailing space from key diff --git a/app/routes/report/images.js b/app/routes/report/images.js index a40294c59..370475133 100644 --- a/app/routes/report/images.js +++ b/app/routes/report/images.js @@ -1,8 +1,8 @@ const logger = require('../../log'); const db = require('../../models'); const {processImage} = require('../../libs/image'); +const {IMAGE_SIZE_LIMIT} = require('../../constants'); -const DEFAULT_SIZE = 20000; // In bytes const DEFAULT_FORMAT = 'PNG'; /** @@ -24,7 +24,7 @@ const DEFAULT_FORMAT = 'PNG'; const uploadReportImage = async (reportId, key, image, options = {}) => { logger.verbose(`Loading (${key}) image`); - const config = {format: DEFAULT_FORMAT, size: DEFAULT_SIZE}; + const config = {format: DEFAULT_FORMAT, size: IMAGE_SIZE_LIMIT}; try { const imageData = await processImage(image, config.size, config.format); diff --git a/app/routes/template/template.js b/app/routes/template/template.js index 0b46ac30d..8971f33a0 100644 --- a/app/routes/template/template.js +++ b/app/routes/template/template.js @@ -72,8 +72,6 @@ router.route('/:template([A-z0-9-]{36})') data: logo.data, filename: logo.name, format: logo.mimetype, - height: DEFAULT_LOGO_HEIGHT, - width: DEFAULT_LOGO_WIDTH, type: 'Logo', }, {transaction}), ); @@ -87,8 +85,6 @@ router.route('/:template([A-z0-9-]{36})') data: header.data, filename: header.name, format: header.mimetype, - height: DEFAULT_HEADER_HEIGHT, - width: DEFAULT_HEADER_WIDTH, type: 'Header', }, {transaction}), ); diff --git a/test/routes/report/image/index.test.js b/test/routes/report/image/index.test.js index be0e99595..824c43878 100644 --- a/test/routes/report/image/index.test.js +++ b/test/routes/report/image/index.test.js @@ -260,19 +260,6 @@ describe('/reports/{REPORTID}/image', () => { })); }); - test('POST / - 400 Bad Request invalid key', async () => { - const res = await request - .post(`/api/reports/${report.ident}/image`) - .attach('INVALID_KEY', 'test/testData/images/golden.jpg') - .auth(username, password) - .expect(HTTP_STATUS.BAD_REQUEST); - - // Check invalid key error - expect(res.body.error).toEqual(expect.objectContaining({ - message: 'Invalid key: INVALID_KEY', - })); - }); - test('POST / - 400 Bad Request duplicate key', async () => { const res = await request .post(`/api/reports/${report.ident}/image`)