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>
@@ -34,14 +34,23 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
end
def fetch_whatsapp_templates(url)
response = HTTParty.get(url)
return [] unless response.success?
all_templates = []
current_url = url
next_url = next_url(response)
while current_url.present?
response = HTTParty.get(current_url)
break unless response.success?
return response['data'] + fetch_whatsapp_templates(next_url) if next_url.present?
templates = response['data'] || []
all_templates.concat(templates)
response['data']
current_url = next_url(response)
# Safety break to prevent infinite loops
break if all_templates.size > 1000
end
all_templates
end
def next_url(response)
@@ -119,17 +128,27 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
end
def template_body_parameters(template_info)
{
template_body = {
name: template_info[:name],
language: {
policy: 'deterministic',
code: template_info[:lang_code]
},
components: [{
}
}
# Handle enhanced template parameters structure
if template_info[:parameters].is_a?(Array) && template_info[:parameters].first.is_a?(Hash) && template_info[:parameters].first.key?(:type) && template_info[:parameters].first[:type] != 'text'
# New enhanced format with component structure
template_body[:components] = template_info[:parameters]
else
# Legacy format - maintain backward compatibility
template_body[:components] = [{
type: 'body',
parameters: template_info[:parameters]
}]
}
end
template_body
end
def whatsapp_reply_context(message)
@@ -75,6 +75,54 @@ class Whatsapp::TemplateProcessorService
template = find_template
return if template.blank?
# Handle enhanced template parameters structure
if template_params['processed_params'].is_a?(Hash) && template_params['processed_params'].key?('body')
process_enhanced_template_params(template)
else
# Legacy processing for backward compatibility
process_legacy_template_params(template)
end
end
def process_enhanced_template_params(_template)
processed_params = template_params['processed_params']
components = []
# Process body parameters
if processed_params['body'].present?
body_params = processed_params['body'].map { |_, value| build_parameter(value) }
components << { type: 'body', parameters: body_params }
end
# Process header parameters
if processed_params['header'].present?
header_params = processed_params['header'].map { |_, value| build_parameter(value) }
components << { type: 'header', parameters: header_params }
end
# Process footer parameters (rarely used but supported)
if processed_params['footer'].present?
footer_params = processed_params['footer'].map { |_, value| build_parameter(value) }
components << { type: 'footer', parameters: footer_params }
end
# Process button parameters
if processed_params['buttons'].present?
button_params = processed_params['buttons'].map.with_index do |button, index|
{
type: 'button',
sub_type: button['type'] || 'url',
index: index,
parameters: [build_button_parameter(button)]
}
end
components.concat(button_params)
end
components
end
def process_legacy_template_params(template)
parameter_format = template['parameter_format']
if parameter_format == 'NAMED'
@@ -84,6 +132,90 @@ class Whatsapp::TemplateProcessorService
end
end
def build_parameter(value)
case value
when String
sanitized_value = sanitize_parameter(value)
if sanitized_value.match?(%r{^https?://})
validate_url(sanitized_value)
# URL parameter (for media or documents)
{ type: 'image', image: { link: sanitized_value } }
else
# Text parameter
{ type: 'text', text: sanitized_value }
end
when Hash
# Advanced parameter types
case value['type']
when 'currency'
{
type: 'currency',
currency: {
fallback_value: value['fallback_value'],
code: value['code'],
amount_1000: value['amount_1000']
}
}
when 'date_time'
{
type: 'date_time',
date_time: {
fallback_value: value['fallback_value'],
day_of_week: value['day_of_week'],
day_of_month: value['day_of_month'],
month: value['month'],
year: value['year']
}
}
when 'location'
{
type: 'location',
location: {
latitude: value['latitude'],
longitude: value['longitude'],
name: value['name'],
address: value['address']
}
}
else
{ type: 'text', text: value.to_s }
end
else
{ type: 'text', text: value.to_s }
end
end
def build_button_parameter(button)
case button['type']
when 'copy_code'
coupon_code = button['parameter'].to_s.strip
raise ArgumentError, 'Coupon code cannot be empty' if coupon_code.blank?
raise ArgumentError, 'Coupon code cannot exceed 15 characters' if coupon_code.length > 15
{
type: 'coupon_code',
coupon_code: coupon_code
}
else
build_parameter(button['parameter'])
end
end
def sanitize_parameter(value)
# Basic sanitization - remove dangerous characters and limit length
sanitized = value.to_s.strip
sanitized = sanitized.gsub(/[<>\"']/, '') # Remove potential HTML/JS chars
sanitized[0...1000] # Limit length to prevent DoS
end
def validate_url(url)
uri = URI.parse(url)
raise ArgumentError, 'Invalid URL scheme' unless %w[http https].include?(uri.scheme)
raise ArgumentError, 'URL too long' if url.length > 2000
rescue URI::InvalidURIError
raise ArgumentError, 'Invalid URL format'
end
def validated_body_object(template)
# we don't care if its not approved template
return if template['status'] != 'approved'