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:
+226
-12
@@ -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"
|
||||
>
|
||||
|
||||
+85
-12
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user