Implement client-side validation and sanitization
Add UX-focused validation to prevent user accidents and provide immediate feedback. Includes DOMPurify integration for basic content sanitization and real-time form validation. Features: - Real-time input validation with visual feedback - Field-type specific validation (text, URL, markdown) - DOMPurify integration for display sanitization - Validation messages with auto-dismiss - Prevents common user mistakes (HTML in text fields, invalid URLs) - Client-side sanitization for markdown rendering - Maintains security awareness while focusing on user experience Note: This is UX-focused validation - server-side validation remains the primary security boundary.
This commit is contained in:
@@ -110,6 +110,7 @@
|
||||
|
||||
<!-- Insertr JavaScript Library -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@16.2.1/lib/marked.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.5/dist/purify.min.js"></script>
|
||||
<script src="insertr/insertr.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -86,6 +86,7 @@
|
||||
|
||||
<!-- Insertr JavaScript Library -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@16.2.1/lib/marked.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.5/dist/purify.min.js"></script>
|
||||
<script src="insertr/insertr.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -234,3 +234,35 @@
|
||||
.insertr-auth-status.edit-mode {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
/* Validation messages */
|
||||
.insertr-validation-message {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.insertr-validation-message.error {
|
||||
background-color: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.insertr-validation-message.success {
|
||||
background-color: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,9 @@ class Insertr {
|
||||
|
||||
// Initialize markdown support
|
||||
this.initializeMarkdown();
|
||||
|
||||
// Initialize client-side validation
|
||||
this.initializeValidation();
|
||||
}
|
||||
|
||||
initializeMarkdown() {
|
||||
@@ -74,6 +77,17 @@ class Insertr {
|
||||
}
|
||||
}
|
||||
|
||||
initializeValidation() {
|
||||
// Check if DOMPurify is available
|
||||
if (typeof DOMPurify === 'undefined' && typeof window.DOMPurify === 'undefined') {
|
||||
console.warn('DOMPurify not loaded! Client-side validation will be limited.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.DOMPurify = window.DOMPurify || DOMPurify;
|
||||
console.log('✅ DOMPurify loaded for client-side validation');
|
||||
}
|
||||
|
||||
async init() {
|
||||
console.log('🚀 Insertr initializing with element-level editing...');
|
||||
|
||||
@@ -292,6 +306,9 @@ class Insertr {
|
||||
this.saveElementContent(contentId, form);
|
||||
});
|
||||
|
||||
// Add real-time validation
|
||||
this.setupFormValidation(form, config);
|
||||
|
||||
// Focus on first input
|
||||
setTimeout(() => {
|
||||
const firstInput = form.querySelector('input, textarea');
|
||||
@@ -306,6 +323,51 @@ class Insertr {
|
||||
return form;
|
||||
}
|
||||
|
||||
setupFormValidation(form, config) {
|
||||
const inputs = form.querySelectorAll('input, textarea');
|
||||
|
||||
inputs.forEach(input => {
|
||||
let fieldType;
|
||||
|
||||
// Determine field type for validation
|
||||
if (input.name === 'url') {
|
||||
fieldType = 'link';
|
||||
} else if (config.type === 'markdown') {
|
||||
fieldType = 'markdown';
|
||||
} else if (input.tagName === 'TEXTAREA') {
|
||||
fieldType = 'textarea';
|
||||
} else {
|
||||
fieldType = 'text';
|
||||
}
|
||||
|
||||
// Real-time validation on input
|
||||
input.addEventListener('input', () => {
|
||||
// Clear previous validation messages
|
||||
const existingMsg = form.querySelector('.insertr-validation-message');
|
||||
if (existingMsg) {
|
||||
existingMsg.remove();
|
||||
}
|
||||
|
||||
// Only validate if there's content and it's not just whitespace
|
||||
const value = input.value.trim();
|
||||
if (value.length > 10) { // Only validate after some content is entered
|
||||
const validation = this.validateInput(value, fieldType);
|
||||
if (!validation.valid) {
|
||||
this.showValidationMessage(input, validation.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clear validation message when user focuses to fix issues
|
||||
input.addEventListener('focus', () => {
|
||||
const existingMsg = form.querySelector('.insertr-validation-message.error');
|
||||
if (existingMsg) {
|
||||
existingMsg.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
showEditForm(element, form) {
|
||||
// Hide edit button during editing
|
||||
const editBtn = element.querySelector('.insertr-edit-btn');
|
||||
@@ -348,12 +410,43 @@ class Insertr {
|
||||
// For markdown, store just the string directly
|
||||
const input = form.querySelector('textarea[name="content"]');
|
||||
newContent = input.value;
|
||||
|
||||
// Validate markdown content
|
||||
const validation = this.validateInput(newContent, 'markdown');
|
||||
if (!validation.valid) {
|
||||
this.showValidationMessage(input, validation.message);
|
||||
return;
|
||||
}
|
||||
} else if (config.type === 'link' && config.includeUrl) {
|
||||
newContent.text = form.querySelector('input[name="text"]').value;
|
||||
newContent.url = form.querySelector('input[name="url"]').value;
|
||||
const textInput = form.querySelector('input[name="text"]');
|
||||
const urlInput = form.querySelector('input[name="url"]');
|
||||
|
||||
newContent.text = textInput.value;
|
||||
newContent.url = urlInput.value;
|
||||
|
||||
// Validate text and URL
|
||||
const textValidation = this.validateInput(newContent.text, 'text');
|
||||
if (!textValidation.valid) {
|
||||
this.showValidationMessage(textInput, textValidation.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const urlValidation = this.validateInput(newContent.url, 'link');
|
||||
if (!urlValidation.valid) {
|
||||
this.showValidationMessage(urlInput, urlValidation.message);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const input = form.querySelector('input[name="content"], textarea[name="content"]');
|
||||
newContent.text = input.value;
|
||||
|
||||
// Validate text content
|
||||
const fieldType = config.type === 'textarea' ? 'textarea' : 'text';
|
||||
const validation = this.validateInput(newContent.text, fieldType);
|
||||
if (!validation.valid) {
|
||||
this.showValidationMessage(input, validation.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Add saving state
|
||||
@@ -394,14 +487,14 @@ class Insertr {
|
||||
// Handle markdown collection - content is a string
|
||||
this.applyMarkdownContent(element, content);
|
||||
} else if (config.type === 'link' && config.includeUrl && content.url !== undefined) {
|
||||
// Update link text and URL
|
||||
element.textContent = content.text || element.textContent;
|
||||
// Update link text and URL with basic sanitization
|
||||
element.textContent = this.sanitizeForDisplay(content.text, 'text') || element.textContent;
|
||||
if (content.url) {
|
||||
element.href = content.url;
|
||||
element.href = this.sanitizeForDisplay(content.url, 'url');
|
||||
}
|
||||
} else if (content.text !== undefined) {
|
||||
// Update text content
|
||||
element.textContent = content.text;
|
||||
// Update text content with basic sanitization
|
||||
element.textContent = this.sanitizeForDisplay(content.text, 'text');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,11 +522,19 @@ class Insertr {
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic sanitization for display (allow common formatting tags)
|
||||
const sanitizedHtml = this.DOMPurify ?
|
||||
this.DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ['p', 'strong', 'em', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'br'],
|
||||
ALLOWED_ATTR: ['href', 'class'],
|
||||
ALLOWED_SCHEMES: ['http', 'https', 'mailto']
|
||||
}) : html;
|
||||
|
||||
// Store original edit button
|
||||
const editBtn = element.querySelector('.insertr-edit-btn');
|
||||
|
||||
// Update element content
|
||||
element.innerHTML = html;
|
||||
element.innerHTML = sanitizedHtml;
|
||||
|
||||
// Re-add edit button
|
||||
if (editBtn) {
|
||||
@@ -641,6 +742,128 @@ class Insertr {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Client-side validation (UX focused, not security)
|
||||
validateInput(input, fieldType) {
|
||||
if (!input || typeof input !== 'string') {
|
||||
return { valid: false, message: 'Content cannot be empty' };
|
||||
}
|
||||
|
||||
// Basic length validation
|
||||
if (input.length > 10000) {
|
||||
return { valid: false, message: 'Content is too long (max 10,000 characters)' };
|
||||
}
|
||||
|
||||
// Field-specific validation
|
||||
switch (fieldType) {
|
||||
case 'text':
|
||||
return this.validateTextInput(input);
|
||||
case 'textarea':
|
||||
return this.validateTextInput(input);
|
||||
case 'link':
|
||||
return this.validateLinkInput(input);
|
||||
case 'markdown':
|
||||
return this.validateMarkdownInput(input);
|
||||
default:
|
||||
return { valid: true };
|
||||
}
|
||||
}
|
||||
|
||||
validateTextInput(input) {
|
||||
// Check for obvious HTML that users might accidentally include
|
||||
if (input.includes('<script>') || input.includes('</script>')) {
|
||||
return { valid: false, message: 'Script tags are not allowed for security reasons' };
|
||||
}
|
||||
|
||||
if (input.includes('<') && input.includes('>')) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'HTML tags are not allowed in text fields. Use markdown collections for formatted content.'
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
validateLinkInput(input) {
|
||||
// Basic URL validation for user feedback
|
||||
const urlPattern = /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/;
|
||||
if (input.startsWith('http') && !urlPattern.test(input)) {
|
||||
return { valid: false, message: 'Please enter a valid URL (e.g., https://example.com)' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
validateMarkdownInput(input) {
|
||||
// Check for potentially problematic content
|
||||
if (input.includes('<script>') || input.includes('javascript:')) {
|
||||
return { valid: false, message: 'Script content is not allowed for security reasons' };
|
||||
}
|
||||
|
||||
// Warn about excessive HTML (user might be pasting from Word/etc)
|
||||
const htmlTagCount = (input.match(/<[^>]+>/g) || []).length;
|
||||
if (htmlTagCount > 20) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Too much HTML detected. Please use markdown formatting instead of HTML tags.'
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Show validation message to user
|
||||
showValidationMessage(element, message, isError = true) {
|
||||
// Remove existing message
|
||||
const existingMsg = element.parentNode.querySelector('.insertr-validation-message');
|
||||
if (existingMsg) {
|
||||
existingMsg.remove();
|
||||
}
|
||||
|
||||
if (!message) return;
|
||||
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.className = `insertr-validation-message ${isError ? 'error' : 'success'}`;
|
||||
msgDiv.textContent = message;
|
||||
|
||||
element.parentNode.appendChild(msgDiv);
|
||||
|
||||
// Auto-remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
if (msgDiv.parentNode) {
|
||||
msgDiv.parentNode.removeChild(msgDiv);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Basic client-side sanitization for display (UX focused)
|
||||
sanitizeForDisplay(content, type) {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'text':
|
||||
// Remove any HTML tags for text display
|
||||
return this.DOMPurify ?
|
||||
this.DOMPurify.sanitize(content, { ALLOWED_TAGS: [] }) :
|
||||
content.replace(/<[^>]*>/g, '');
|
||||
|
||||
case 'url':
|
||||
// Basic URL sanitization
|
||||
return this.DOMPurify ?
|
||||
this.DOMPurify.sanitize(content, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] }) :
|
||||
content.replace(/javascript:/gi, '').replace(/data:/gi, '');
|
||||
|
||||
case 'markdown':
|
||||
// For markdown, we'll let marked.js handle it with our safe config
|
||||
return content;
|
||||
|
||||
default:
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to detect if a paragraph should have 'lead' class
|
||||
isLeadParagraph(text) {
|
||||
return text.length > 100 && (
|
||||
|
||||
Reference in New Issue
Block a user