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:
2025-09-01 13:33:16 +02:00
parent 39e60e0b3f
commit e639c5e807
4 changed files with 265 additions and 8 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
}
} }

View File

@@ -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 && (