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 -->
|
<!-- 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/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>
|
<script src="insertr/insertr.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -86,6 +86,7 @@
|
|||||||
|
|
||||||
<!-- Insertr JavaScript Library -->
|
<!-- 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/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>
|
<script src="insertr/insertr.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -233,4 +233,36 @@
|
|||||||
|
|
||||||
.insertr-auth-status.edit-mode {
|
.insertr-auth-status.edit-mode {
|
||||||
background: #3b82f6;
|
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
|
// Initialize markdown support
|
||||||
this.initializeMarkdown();
|
this.initializeMarkdown();
|
||||||
|
|
||||||
|
// Initialize client-side validation
|
||||||
|
this.initializeValidation();
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeMarkdown() {
|
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() {
|
async init() {
|
||||||
console.log('🚀 Insertr initializing with element-level editing...');
|
console.log('🚀 Insertr initializing with element-level editing...');
|
||||||
|
|
||||||
@@ -292,6 +306,9 @@ class Insertr {
|
|||||||
this.saveElementContent(contentId, form);
|
this.saveElementContent(contentId, form);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add real-time validation
|
||||||
|
this.setupFormValidation(form, config);
|
||||||
|
|
||||||
// Focus on first input
|
// Focus on first input
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const firstInput = form.querySelector('input, textarea');
|
const firstInput = form.querySelector('input, textarea');
|
||||||
@@ -306,6 +323,51 @@ class Insertr {
|
|||||||
return form;
|
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) {
|
showEditForm(element, form) {
|
||||||
// Hide edit button during editing
|
// Hide edit button during editing
|
||||||
const editBtn = element.querySelector('.insertr-edit-btn');
|
const editBtn = element.querySelector('.insertr-edit-btn');
|
||||||
@@ -348,12 +410,43 @@ class Insertr {
|
|||||||
// For markdown, store just the string directly
|
// For markdown, store just the string directly
|
||||||
const input = form.querySelector('textarea[name="content"]');
|
const input = form.querySelector('textarea[name="content"]');
|
||||||
newContent = input.value;
|
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) {
|
} else if (config.type === 'link' && config.includeUrl) {
|
||||||
newContent.text = form.querySelector('input[name="text"]').value;
|
const textInput = form.querySelector('input[name="text"]');
|
||||||
newContent.url = form.querySelector('input[name="url"]').value;
|
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 {
|
} else {
|
||||||
const input = form.querySelector('input[name="content"], textarea[name="content"]');
|
const input = form.querySelector('input[name="content"], textarea[name="content"]');
|
||||||
newContent.text = input.value;
|
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
|
// Add saving state
|
||||||
@@ -394,14 +487,14 @@ class Insertr {
|
|||||||
// Handle markdown collection - content is a string
|
// Handle markdown collection - content is a string
|
||||||
this.applyMarkdownContent(element, content);
|
this.applyMarkdownContent(element, content);
|
||||||
} else if (config.type === 'link' && config.includeUrl && content.url !== undefined) {
|
} else if (config.type === 'link' && config.includeUrl && content.url !== undefined) {
|
||||||
// Update link text and URL
|
// Update link text and URL with basic sanitization
|
||||||
element.textContent = content.text || element.textContent;
|
element.textContent = this.sanitizeForDisplay(content.text, 'text') || element.textContent;
|
||||||
if (content.url) {
|
if (content.url) {
|
||||||
element.href = content.url;
|
element.href = this.sanitizeForDisplay(content.url, 'url');
|
||||||
}
|
}
|
||||||
} else if (content.text !== undefined) {
|
} else if (content.text !== undefined) {
|
||||||
// Update text content
|
// Update text content with basic sanitization
|
||||||
element.textContent = content.text;
|
element.textContent = this.sanitizeForDisplay(content.text, 'text');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,11 +522,19 @@ class Insertr {
|
|||||||
return;
|
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
|
// Store original edit button
|
||||||
const editBtn = element.querySelector('.insertr-edit-btn');
|
const editBtn = element.querySelector('.insertr-edit-btn');
|
||||||
|
|
||||||
// Update element content
|
// Update element content
|
||||||
element.innerHTML = html;
|
element.innerHTML = sanitizedHtml;
|
||||||
|
|
||||||
// Re-add edit button
|
// Re-add edit button
|
||||||
if (editBtn) {
|
if (editBtn) {
|
||||||
@@ -641,6 +742,128 @@ class Insertr {
|
|||||||
return div.innerHTML;
|
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
|
// Helper method to detect if a paragraph should have 'lead' class
|
||||||
isLeadParagraph(text) {
|
isLeadParagraph(text) {
|
||||||
return text.length > 100 && (
|
return text.length > 100 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user