feat: Enhanced WhatsApp template support with security optimizations

- Add comprehensive WhatsApp template parameter support for header/footer/buttons
- Implement coupon code template support with 15-character validation
- Support all WhatsApp template types (text, media, interactive)
- Add advanced parameter types (currency, date/time, location)
- Remove restrictions on DOCUMENT, IMAGE, VIDEO template formats

Security improvements:
- Fix XSS vulnerability by replacing v-html with v-dompurify-html
- Add input sanitization and parameter validation
- Implement URL validation with scheme and length checks
- Add protection against DoS attacks with length limits

Performance optimizations:
- Replace recursive template fetching with iterative pagination
- Add safety limits to prevent infinite loops and stack overflow
- Optimize template processing for better memory usage

Frontend enhancements:
- Enhanced template picker with header/footer/button display
- Component-based parameter input organized by type
- Real-time validation for coupon codes and parameters
- Improved template preview with sanitized HTML rendering
- Fix Vue linting issues with proper computed properties

Maintains full backward compatibility with existing template configurations.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sojan Jose
2025-07-21 17:03:11 +04:00
co-authored by Claude
parent 647a65d481
commit ee176480da
4 changed files with 471 additions and 33 deletions
@@ -24,10 +24,43 @@ const templateName = computed(() => {
return props.template?.name || '';
});
const templateComponents = computed(() => {
return props.template?.components || [];
});
const templateString = computed(() => {
return props.template?.components?.find(
component => component.type === 'BODY'
).text;
return (
props.template?.components?.find(component => component.type === 'BODY')
?.text || ''
);
});
const headerComponent = computed(() => {
return templateComponents.value.find(
component => component.type === 'HEADER'
);
});
const footerComponent = computed(() => {
return templateComponents.value.find(
component => component.type === 'FOOTER'
);
});
const buttonComponents = computed(() => {
return templateComponents.value.filter(
component => component.type === 'BUTTONS'
);
});
const legacyParams = computed(() => {
const params = {};
Object.keys(processedParams.value).forEach(key => {
if (!['header', 'body', 'footer', 'buttons'].includes(key)) {
params[key] = processedParams.value[key];
}
});
return params;
});
const processVariable = str => {
@@ -72,14 +105,76 @@ const getFieldErrorType = key => {
};
const generateVariables = () => {
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
if (!matchedVariables) return;
const allVariables = {};
const finalVars = matchedVariables.map(i => processVariable(i));
processedParams.value = finalVars.reduce((acc, variable) => {
acc[variable] = '';
return acc;
}, {});
// Process body variables
const bodyVars = templateString.value.match(/{{([^}]+)}}/g) || [];
bodyVars.forEach(variable => {
const key = processVariable(variable);
if (!allVariables.body) allVariables.body = {};
allVariables.body[key] = '';
});
// Process header variables
if (headerComponent.value?.text) {
const headerVars = headerComponent.value.text.match(/{{([^}]+)}}/g) || [];
headerVars.forEach(variable => {
const key = processVariable(variable);
if (!allVariables.header) allVariables.header = {};
allVariables.header[key] = '';
});
}
// Process footer variables
if (footerComponent.value?.text) {
const footerVars = footerComponent.value.text.match(/{{([^}]+)}}/g) || [];
footerVars.forEach(variable => {
const key = processVariable(variable);
if (!allVariables.footer) allVariables.footer = {};
allVariables.footer[key] = '';
});
}
// Process button variables
buttonComponents.value.forEach(buttonComponent => {
if (buttonComponent.buttons) {
buttonComponent.buttons.forEach((button, index) => {
// Handle URL buttons with variables
if (button.url && button.url.includes('{{')) {
const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
buttonVars.forEach(() => {
if (!allVariables.buttons) allVariables.buttons = [];
if (!allVariables.buttons[index]) allVariables.buttons[index] = {};
allVariables.buttons[index].type = 'url';
allVariables.buttons[index].parameter = '';
});
}
// Handle copy code buttons
if (button.type === 'COPY_CODE') {
if (!allVariables.buttons) allVariables.buttons = [];
if (!allVariables.buttons[index]) allVariables.buttons[index] = {};
allVariables.buttons[index].type = 'copy_code';
allVariables.buttons[index].parameter = '';
}
});
}
});
processedParams.value = allVariables;
};
const validateButtonParameter = (button, index) => {
const parameter = processedParams.value.buttons[index].parameter;
if (button.type === 'copy_code') {
if (parameter && parameter.length > 15) {
processedParams.value.buttons[index].parameter = parameter.substring(
0,
15
);
}
}
};
const sendMessage = async () => {
@@ -117,8 +212,8 @@ onMounted(() => {
}}
</span>
<p
v-dompurify-html="processedStringWithVariableHighlight"
class="mb-0 text-sm text-n-slate-11"
v-html="processedStringWithVariableHighlight"
/>
<span
@@ -132,8 +227,127 @@ onMounted(() => {
}}
</span>
<!-- Header Variables -->
<div v-if="processedParams.header" class="w-full">
<h4 class="text-sm font-medium text-n-slate-12 mb-2">
{{
t('WHATSAPP_TEMPLATES.PARSER.HEADER_PARAMETERS') ||
'Header Parameters'
}}
</h4>
<div
v-for="(variable, key) in processedParams.header"
:key="`header-${key}`"
class="flex items-center w-full gap-2 mb-2"
>
<span
class="flex items-center h-8 text-sm min-w-6 ltr:text-left rtl:text-right text-n-slate-10"
>
{{ key }}
</span>
<Input
v-model="processedParams.header[key]"
custom-input-class="!h-8 w-full !bg-transparent"
class="w-full"
:message-type="getFieldErrorType(`header.${key}`)"
/>
</div>
</div>
<!-- Body Variables -->
<div v-if="processedParams.body" class="w-full">
<h4 class="text-sm font-medium text-n-slate-12 mb-2">
{{
t('WHATSAPP_TEMPLATES.PARSER.BODY_PARAMETERS') || 'Body Parameters'
}}
</h4>
<div
v-for="(variable, key) in processedParams.body"
:key="`body-${key}`"
class="flex items-center w-full gap-2 mb-2"
>
<span
class="flex items-center h-8 text-sm min-w-6 ltr:text-left rtl:text-right text-n-slate-10"
>
{{ key }}
</span>
<Input
v-model="processedParams.body[key]"
custom-input-class="!h-8 w-full !bg-transparent"
class="w-full"
:message-type="getFieldErrorType(`body.${key}`)"
/>
</div>
</div>
<!-- Footer Variables -->
<div v-if="processedParams.footer" class="w-full">
<h4 class="text-sm font-medium text-n-slate-12 mb-2">
{{
t('WHATSAPP_TEMPLATES.PARSER.FOOTER_PARAMETERS') ||
'Footer Parameters'
}}
</h4>
<div
v-for="(variable, key) in processedParams.footer"
:key="`footer-${key}`"
class="flex items-center w-full gap-2 mb-2"
>
<span
class="flex items-center h-8 text-sm min-w-6 ltr:text-left rtl:text-right text-n-slate-10"
>
{{ key }}
</span>
<Input
v-model="processedParams.footer[key]"
custom-input-class="!h-8 w-full !bg-transparent"
class="w-full"
:message-type="getFieldErrorType(`footer.${key}`)"
/>
</div>
</div>
<!-- Button Variables -->
<div v-if="processedParams.buttons" class="w-full">
<h4 class="text-sm font-medium text-n-slate-12 mb-2">
{{
t('WHATSAPP_TEMPLATES.PARSER.BUTTON_PARAMETERS') ||
'Button Parameters'
}}
</h4>
<div
v-for="(button, index) in processedParams.buttons"
:key="`button-${index}`"
class="flex items-center w-full gap-2 mb-2"
>
<span
class="flex items-center h-8 text-sm min-w-6 ltr:text-left rtl:text-right text-n-slate-10"
>
{{
button.type === 'copy_code'
? t('WHATSAPP_TEMPLATES.PARSER.COUPON_CODE') || 'Coupon Code'
: `${t('WHATSAPP_TEMPLATES.PARSER.BUTTON') || 'Button'} ${index + 1}`
}}
</span>
<Input
v-model="processedParams.buttons[index].parameter"
custom-input-class="!h-8 w-full !bg-transparent"
class="w-full"
:message-type="getFieldErrorType(`buttons.${index}.parameter`)"
:placeholder="
button.type === 'copy_code'
? 'Enter coupon code (max 15 chars)'
: 'Enter button parameter'
"
:max-length="button.type === 'copy_code' ? 15 : 500"
@input="validateButtonParameter(button, index)"
/>
</div>
</div>
<!-- Legacy Variables (for backward compatibility) -->
<div
v-for="(variable, key) in processedParams"
v-for="(variable, key) in legacyParams"
:key="key"
class="flex items-center w-full gap-2"
>
@@ -1,6 +1,6 @@
<script>
// TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
// Support all template formats now
const formatsToRemove = [];
export default {
props: {
@@ -34,8 +34,30 @@ export default {
},
methods: {
getTemplatebody(template) {
return template.components.find(component => component.type === 'BODY')
.text;
return (
template.components.find(component => component.type === 'BODY')
?.text || ''
);
},
getTemplateHeader(template) {
return template.components.find(component => component.type === 'HEADER');
},
getTemplateFooter(template) {
return template.components.find(component => component.type === 'FOOTER');
},
getTemplateButtons(template) {
return template.components.find(
component => component.type === 'BUTTONS'
);
},
hasMediaContent(template) {
const header = this.getTemplateHeader(template);
return (
header &&
(header.format === 'IMAGE' ||
header.format === 'VIDEO' ||
header.format === 'DOCUMENT')
);
},
},
};
@@ -74,17 +96,68 @@ export default {
{{ template.language }}
</span>
</div>
<div>
<p class="font-medium">
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.TEMPLATE_BODY') }}
<!-- Header -->
<div v-if="getTemplateHeader(template)" class="mb-3">
<p class="font-medium text-xs text-n-slate-11">
{{ $t('WHATSAPP_TEMPLATES.PICKER.HEADER') || 'HEADER' }}
</p>
<p class="label-body">{{ getTemplatebody(template) }}</p>
<div
v-if="getTemplateHeader(template).format === 'TEXT'"
class="label-body text-sm"
>
{{ getTemplateHeader(template).text }}
</div>
<div
v-else-if="hasMediaContent(template)"
class="text-sm text-n-slate-11 italic"
>
{{
$t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT', {
format: getTemplateHeader(template).format,
}) || `${getTemplateHeader(template).format} media content`
}}
</div>
</div>
<div class="mt-5">
<p class="font-medium">
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }}
<!-- Body -->
<div>
<p class="font-medium text-xs text-n-slate-11">
{{ $t('WHATSAPP_TEMPLATES.PICKER.BODY') || 'BODY' }}
</p>
<p>{{ template.category }}</p>
<p class="label-body text-sm">{{ getTemplatebody(template) }}</p>
</div>
<!-- Footer -->
<div v-if="getTemplateFooter(template)" class="mt-3">
<p class="font-medium text-xs text-n-slate-11">
{{ $t('WHATSAPP_TEMPLATES.PICKER.FOOTER') || 'FOOTER' }}
</p>
<p class="label-body text-sm">
{{ getTemplateFooter(template).text }}
</p>
</div>
<!-- Buttons -->
<div v-if="getTemplateButtons(template)" class="mt-3">
<p class="font-medium text-xs text-n-slate-11">
{{ $t('WHATSAPP_TEMPLATES.PICKER.BUTTONS') || 'BUTTONS' }}
</p>
<div class="flex flex-wrap gap-1 mt-1">
<span
v-for="button in getTemplateButtons(template).buttons"
:key="button.text"
class="px-2 py-1 text-xs bg-n-slate-3 rounded text-n-slate-12"
>
{{ button.text }}
</span>
</div>
</div>
<div class="mt-3">
<p class="font-medium text-xs text-n-slate-11">
{{ $t('WHATSAPP_TEMPLATES.PICKER.CATEGORY') || 'CATEGORY' }}
</p>
<p class="text-sm">{{ template.category }}</p>
</div>
</div>
</button>