diff --git a/backend/__tests__/integration/imageSecurityDefaults.test.js b/backend/__tests__/integration/imageSecurityDefaults.test.js new file mode 100644 index 00000000..32b61ded --- /dev/null +++ b/backend/__tests__/integration/imageSecurityDefaults.test.js @@ -0,0 +1,99 @@ +/** + * Image-security settings applied as creation defaults (#1296). + * + * Four controls in Settings → Image security were written, reloaded and + * rendered as toggles, and read by nothing: + * + * default_protection_level, default_image_quality, + * enable_canvas_rendering, default_fragmentation_level + * + * Each maps onto an `events` column migration 038 already created, and each + * is labelled "… by default". `enable_devtools_protection` was the only one + * of the five ever wired. + * + * The load-bearing constraint is that this is CREATION-time only. Applying + * these to existing events would silently change live galleries on upgrade — + * an install with enable_canvas_rendering already on would flip every grid to + * canvas rendering, which is the memory profile under investigation in #1287. + */ + +const { bootCrmDb } = require('./helpers/crmDb'); + +describe('image-security creation defaults', () => { + let db; + let cleanup; + let getImageSecurityDefaults; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ({ getImageSecurityDefaults } = require('../../src/routes/adminEvents/helpers')); + }, 120000); + + afterAll(async () => { + if (cleanup) await cleanup(); + }); + + const setSetting = async (key, value) => { + await db('app_settings') + .insert({ setting_key: key, setting_value: JSON.stringify(value), setting_type: 'security' }) + .onConflict('setting_key') + .merge(); + }; + + beforeEach(async () => { + await db('app_settings').whereIn('setting_key', [ + 'default_protection_level', 'default_image_quality', + 'enable_canvas_rendering', 'default_fragmentation_level', + ]).del(); + }); + + it('returns nothing when no settings are configured', async () => { + // Every key absent must fall through to the column defaults, which is + // exactly the behaviour before this existed. + expect(await getImageSecurityDefaults()).toEqual({}); + }); + + it('maps each setting onto its events column', async () => { + await setSetting('default_protection_level', 'enhanced'); + await setSetting('default_image_quality', 72); + await setSetting('enable_canvas_rendering', true); + await setSetting('default_fragmentation_level', 5); + + expect(await getImageSecurityDefaults()).toEqual({ + protection_level: 'enhanced', + image_quality: 72, + use_canvas_rendering: true, + fragmentation_level: 5, + }); + }); + + it('carries a false canvas setting through, rather than dropping it', async () => { + // `false` is a real choice — dropping it as falsy would leave the column + // default in place and make "off" unreachable. + await setSetting('enable_canvas_rendering', false); + expect(await getImageSecurityDefaults()).toEqual({ use_canvas_rendering: false }); + }); + + it.each([ + ['an unknown protection level', 'default_protection_level', 'paranoid'], + ['a non-enum protection level', 'default_protection_level', 42], + ['image quality above 100', 'default_image_quality', 250], + ['image quality of zero', 'default_image_quality', 0], + ['a non-numeric quality', 'default_image_quality', 'high'], + ['fragmentation above the range', 'default_fragmentation_level', 99], + ['a non-boolean canvas value', 'enable_canvas_rendering', 'yes'], + ])('ignores %s and falls through to the column default', async (_label, key, value) => { + await setSetting(key, value); + expect(await getImageSecurityDefaults()).toEqual({}); + }); + + it('applies only the keys that are configured', async () => { + await setSetting('default_protection_level', 'maximum'); + expect(await getImageSecurityDefaults()).toEqual({ protection_level: 'maximum' }); + }); + + it('never throws, so a settings failure cannot block event creation', async () => { + await setSetting('default_image_quality', { nonsense: true }); + await expect(getImageSecurityDefaults()).resolves.toEqual({}); + }); +}); diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js index fc2dce8e..d6a0d500 100644 --- a/backend/src/routes/adminEvents/crud.js +++ b/backend/src/routes/adminEvents/crud.js @@ -30,7 +30,8 @@ const { clampIntOrUndefined } = require('../../utils/numericHelpers'); const { getFrontendBaseUrl, getAbsoluteFrontendUrl } = require('../../utils/frontendUrl'); const downloadZipService = require('../../services/downloadZipService'); const { resolveEventFeedbackDefaults, applyFeedbackDefaults, KEYBIND_MODES } = require('../../services/feedbackDefaults'); -const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers'); +const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, + getImageSecurityDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers'); /** * `events.slug` is UNIQUE, and both routes that mint one do a read-then-insert @@ -544,6 +545,10 @@ module.exports = (router) => { // the request explicitly overrides it (#317 — admin disabled it globally // but new events still got it ON because the column default is true). const protectionDefaults = await getDownloadProtectionDefaults(); + // #1296 — the other four Image-security settings, which were written, + // rendered as controls, and read by nothing. Creation-time only; see + // getImageSecurityDefaults for why existing events are left alone. + const imageSecurityDefaults = await getImageSecurityDefaults(); const effectiveEnableDevtoolsProtection = enableDevtoolsProtectionInput !== undefined ? enableDevtoolsProtectionInput @@ -617,6 +622,18 @@ module.exports = (router) => { allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true), disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false), enable_devtools_protection: formatBoolean(effectiveEnableDevtoolsProtection), + // Spread AFTER the explicit columns so a value the request supplied + // still wins; each key is present only when the global setting held + // a usable value, so anything unset falls through to the column + // default exactly as before (#1296). + ...(imageSecurityDefaults.protection_level !== undefined + ? { protection_level: imageSecurityDefaults.protection_level } : {}), + ...(imageSecurityDefaults.image_quality !== undefined + ? { image_quality: imageSecurityDefaults.image_quality } : {}), + ...(imageSecurityDefaults.use_canvas_rendering !== undefined + ? { use_canvas_rendering: formatBoolean(imageSecurityDefaults.use_canvas_rendering) } : {}), + ...(imageSecurityDefaults.fragmentation_level !== undefined + ? { fragmentation_level: imageSecurityDefaults.fragmentation_level } : {}), watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false), watermark_text, allow_presigned_download: formatBoolean(allow_presigned_download === true || allow_presigned_download === 'true'), diff --git a/backend/src/routes/adminEvents/helpers.js b/backend/src/routes/adminEvents/helpers.js index be10a8cc..fd271bee 100644 --- a/backend/src/routes/adminEvents/helpers.js +++ b/backend/src/routes/adminEvents/helpers.js @@ -95,6 +95,85 @@ const getDownloadProtectionDefaults = async () => { return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') }; }; +/** + * The rest of Settings → Image security, as creation defaults (#1296). + * + * Four settings in that panel were written, reloaded and rendered as + * controls, and read by nothing: + * + * default_protection_level → events.protection_level + * default_image_quality → events.image_quality + * enable_canvas_rendering → events.use_canvas_rendering + * default_fragmentation_level → events.fragmentation_level + * + * Each maps onto a column migration 038 already created, and each is + * labelled "… by default", so applying them at creation is what the panel + * has always claimed to do. `enable_devtools_protection` above is the only + * one of the five that was ever wired. + * + * Creation-time only, deliberately. Applying them to EXISTING events would + * silently change live galleries on upgrade — an install with + * enable_canvas_rendering already on would switch every grid to canvas + * rendering, which is memory-expensive at scale and is the profile under + * investigation in #1287. New events only; existing rows untouched. + * + * Any value that is missing or malformed comes back undefined so the caller + * falls through to the column default, exactly as before this existed. + */ +const PROTECTION_LEVELS = ['basic', 'standard', 'enhanced', 'maximum']; + +const getImageSecurityDefaults = async () => { + const defaults = {}; + try { + const rows = await db('app_settings') + .whereIn('setting_key', [ + 'default_protection_level', + 'default_image_quality', + 'enable_canvas_rendering', + 'default_fragmentation_level', + ]) + .select('setting_key', 'setting_value'); + + const read = (key) => { + const row = rows.find((r) => r.setting_key === key); + if (!row) return undefined; + let value = row.setting_value; + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch { /* keep raw */ } + } + return value; + }; + + const level = read('default_protection_level'); + if (typeof level === 'string' && PROTECTION_LEVELS.includes(level)) { + defaults.protection_level = level; + } + + // The column is an integer percentage; anything outside 1..100 is a + // misconfiguration and falls through rather than being clamped into + // something the operator did not choose. + const quality = parseInt(read('default_image_quality'), 10); + if (Number.isInteger(quality) && quality >= 1 && quality <= 100) { + defaults.image_quality = quality; + } + + const canvas = read('enable_canvas_rendering'); + if (typeof canvas === 'boolean') { + defaults.use_canvas_rendering = canvas; + } + + const fragmentation = parseInt(read('default_fragmentation_level'), 10); + if (Number.isInteger(fragmentation) && fragmentation >= 1 && fragmentation <= 10) { + defaults.fragmentation_level = fragmentation; + } + } catch (error) { + // A settings read must never block event creation; the column defaults + // are a correct fallback. + logger.error('Failed to read image-security defaults', { error: error.message }); + } + return defaults; +}; + // Helper to get branding defaults for new events (Feature 7: Branding Inheritance). // // Note: `branding_logo_position` (header bar — left/center/right) is a @@ -528,6 +607,7 @@ module.exports = { getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, + getImageSecurityDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload,