From 35a0b8c46c9c6320a045163439055b7218bbf2ad Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Wed, 17 Dec 2025 10:03:40 +0100 Subject: [PATCH] validation of folder/note names and allow for unicode characters --- backend/utils.py | 27 ++++++- frontend/app.js | 188 ++++++++++++++++++++++++++++++++++++++++----- locales/de-DE.json | 7 ++ locales/en-US.json | 7 ++ locales/es-ES.json | 7 ++ locales/fr-FR.json | 7 ++ locales/zh-CN.json | 7 ++ 7 files changed, 227 insertions(+), 23 deletions(-) diff --git a/backend/utils.py b/backend/utils.py index f978f1c..7f093ac 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -379,16 +379,35 @@ def create_note_metadata(notes_dir: str, note_path: str) -> Dict: def sanitize_filename(filename: str) -> str: """ - Sanitize a filename by removing/replacing invalid characters. - Keeps only alphanumeric chars, dots, dashes, and underscores. + Sanitize a filename by removing/replacing dangerous filesystem characters. + Supports Unicode characters (international text) while blocking: + - Windows forbidden: \\ / : * ? " < > | + - Control characters (0x00-0x1f) + + Note: This is a safety net - the frontend validates before sending. """ + if not filename: + return filename + # Get the extension first parts = filename.rsplit('.', 1) name = parts[0] ext = parts[1] if len(parts) > 1 else '' - # Remove/replace invalid characters - name = re.sub(r'[^a-zA-Z0-9_-]', '_', name) + # Remove dangerous characters (replace with underscore) + # Blocklist approach: only remove what's truly dangerous + # Pattern: backslash, forward slash, colon, asterisk, question mark, quotes, angle brackets, pipe, control chars + name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', '_', name) + + # Collapse multiple underscores + name = re.sub(r'_+', '_', name) + + # Strip leading/trailing underscores and spaces + name = name.strip('_ ') + + # Ensure we have something left + if not name: + name = 'unnamed' # Rejoin with extension return f"{name}.{ext}" if ext else name diff --git a/frontend/app.js b/frontend/app.js index 41006dc..295cf4e 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -32,6 +32,96 @@ const ErrorHandler = { } }; +/** + * Centralized filename validation + * Supports Unicode characters (international text) but blocks dangerous filesystem characters. + * Does NOT silently modify filenames - validates and returns status. + */ +const FilenameValidator = { + // Characters that are forbidden in filenames across Windows/macOS/Linux + // Windows: \ / : * ? " < > | + // macOS: / : + // Linux: / \0 + // Common set to block (including control characters) + FORBIDDEN_CHARS: /[\\/:*?"<>|\x00-\x1f]/, + + // For display purposes - human readable list + FORBIDDEN_CHARS_DISPLAY: '\\ / : * ? " < > |', + + /** + * Validate a filename (single segment, no path separators) + * @param {string} name - The filename to validate + * @returns {{ valid: boolean, error?: string, sanitized?: string }} + */ + validateFilename(name) { + if (!name || typeof name !== 'string') { + return { valid: false, error: 'empty' }; + } + + const trimmed = name.trim(); + if (!trimmed) { + return { valid: false, error: 'empty' }; + } + + // Check for forbidden characters + if (this.FORBIDDEN_CHARS.test(trimmed)) { + return { + valid: false, + error: 'forbidden_chars', + forbiddenChars: this.FORBIDDEN_CHARS_DISPLAY + }; + } + + // Check for reserved Windows names (case-insensitive) + const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i; + if (reservedNames.test(trimmed)) { + return { valid: false, error: 'reserved_name' }; + } + + // Check for names starting/ending with dots or spaces (problematic on some systems) + if (trimmed.startsWith('.') && trimmed.length === 1) { + return { valid: false, error: 'invalid_dot' }; + } + if (trimmed.endsWith('.') || trimmed.endsWith(' ')) { + return { valid: false, error: 'trailing_dot_space' }; + } + + return { valid: true, sanitized: trimmed }; + }, + + /** + * Validate a path (may contain forward slashes for folder separators) + * @param {string} path - The path to validate + * @returns {{ valid: boolean, error?: string, sanitized?: string }} + */ + validatePath(path) { + if (!path || typeof path !== 'string') { + return { valid: false, error: 'empty' }; + } + + const trimmed = path.trim(); + if (!trimmed) { + return { valid: false, error: 'empty' }; + } + + // Split by forward slash and validate each segment + const segments = trimmed.split('/').filter(s => s.length > 0); + if (segments.length === 0) { + return { valid: false, error: 'empty' }; + } + + for (const segment of segments) { + const result = this.validateFilename(segment); + if (!result.valid) { + return result; + } + } + + // Rebuild path without empty segments + return { valid: true, sanitized: segments.join('/') }; + } +}; + function noteApp() { return { // App state @@ -806,6 +896,35 @@ function noteApp() { return value.replace(/\{\{(\w+)\}\}/g, (_, name) => params[name] ?? `{{${name}}}`); }, + /** + * Get localized error message from FilenameValidator result + * @param {object} validation - The validation result from FilenameValidator + * @param {string} type - 'note' or 'folder' + * @returns {string} Localized error message + */ + getValidationErrorMessage(validation, type = 'note') { + switch (validation.error) { + case 'empty': + return type === 'note' + ? this.t('notes.empty_name') + : this.t('folders.invalid_name'); + case 'forbidden_chars': + return this.t('validation.forbidden_chars', { + chars: validation.forbiddenChars + }); + case 'reserved_name': + return this.t('validation.reserved_name'); + case 'invalid_dot': + return this.t('validation.invalid_dot'); + case 'trailing_dot_space': + return this.t('validation.trailing_dot_space'); + default: + return type === 'note' + ? this.t('notes.invalid_name') + : this.t('folders.invalid_name'); + } + }, + // Load available locales from backend async loadAvailableLocales() { try { @@ -972,8 +1091,15 @@ function noteApp() { } try { + // Validate the note name + const validation = FilenameValidator.validateFilename(this.newTemplateNoteName); + if (!validation.valid) { + alert(this.getValidationErrorMessage(validation, 'note')); + return; + } + // Determine the note path based on dropdown context - let notePath = this.newTemplateNoteName.trim(); + let notePath = validation.sanitized; if (!notePath.endsWith('.md')) { notePath += '.md'; } @@ -994,7 +1120,7 @@ function noteApp() { // CRITICAL: Check if note already exists const existingNote = this.notes.find(note => note.path === notePath); if (existingNote) { - alert(this.t('notes.already_exists', { name: this.newTemplateNoteName.trim() })); + alert(this.t('notes.already_exists', { name: validation.sanitized })); return; } @@ -2707,23 +2833,29 @@ function noteApp() { const noteName = prompt(promptText); if (!noteName) return; - const sanitizedName = noteName.trim().replace(/[^a-zA-Z0-9-_\s\/]/g, ''); - if (!sanitizedName) { - alert(this.t('notes.invalid_name')); + // Validate the name/path (may contain / for paths when no target folder) + const validation = targetFolder + ? FilenameValidator.validateFilename(noteName) + : FilenameValidator.validatePath(noteName); + + if (!validation.valid) { + alert(this.getValidationErrorMessage(validation, 'note')); return; } + const validatedName = validation.sanitized; + let notePath; if (targetFolder) { - notePath = `${targetFolder}/${sanitizedName}.md`; + notePath = `${targetFolder}/${validatedName}.md`; } else { - notePath = sanitizedName.endsWith('.md') ? sanitizedName : `${sanitizedName}.md`; + notePath = validatedName.endsWith('.md') ? validatedName : `${validatedName}.md`; } // CRITICAL: Check if note already exists const existingNote = this.notes.find(note => note.path === notePath); if (existingNote) { - alert(this.t('notes.already_exists', { name: sanitizedName })); + alert(this.t('notes.already_exists', { name: validatedName })); return; } @@ -2768,18 +2900,23 @@ function noteApp() { const folderName = prompt(promptText); if (!folderName) return; - const sanitizedName = folderName.trim().replace(/[^a-zA-Z0-9-_\s\/]/g, ''); - if (!sanitizedName) { - alert(this.t('folders.invalid_name')); + // Validate the name/path (may contain / for paths when no target folder) + const validation = targetFolder + ? FilenameValidator.validateFilename(folderName) + : FilenameValidator.validatePath(folderName); + + if (!validation.valid) { + alert(this.getValidationErrorMessage(validation, 'folder')); return; } - const folderPath = targetFolder ? `${targetFolder}/${sanitizedName}` : sanitizedName; + const validatedName = validation.sanitized; + const folderPath = targetFolder ? `${targetFolder}/${validatedName}` : validatedName; // Check if folder already exists const existingFolder = this.allFolders.find(folder => folder === folderPath); if (existingFolder) { - alert(this.t('folders.already_exists', { name: sanitizedName })); + alert(this.t('folders.already_exists', { name: validatedName })); return; } @@ -2812,15 +2949,18 @@ function noteApp() { const newName = prompt(this.t('folders.prompt_rename', { name: currentName }), currentName); if (!newName || newName === currentName) return; - const sanitizedName = newName.trim().replace(/[^a-zA-Z0-9-_\s]/g, ''); - if (!sanitizedName) { - alert(this.t('folders.invalid_name')); + // Validate the new name (single segment, no path separators) + const validation = FilenameValidator.validateFilename(newName); + if (!validation.valid) { + alert(this.getValidationErrorMessage(validation, 'folder')); return; } + const validatedName = validation.sanitized; + // Calculate new path const pathParts = folderPath.split('/'); - pathParts[pathParts.length - 1] = sanitizedName; + pathParts[pathParts.length - 1] = validatedName; const newPath = pathParts.join('/'); try { @@ -3187,15 +3327,25 @@ function noteApp() { return; } + // Validate the new name (single segment, no path separators) + const validation = FilenameValidator.validateFilename(newName); + if (!validation.valid) { + alert(this.getValidationErrorMessage(validation, 'note')); + // Reset the name in the UI + this.currentNoteName = oldPath.split('/').pop().replace('.md', ''); + return; + } + + const validatedName = validation.sanitized; const folder = oldPath.split('/').slice(0, -1).join('/'); - const newPath = folder ? `${folder}/${newName}.md` : `${newName}.md`; + const newPath = folder ? `${folder}/${validatedName}.md` : `${validatedName}.md`; if (oldPath === newPath) return; // Check if a note with the new name already exists const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase()); if (existingNote) { - alert(this.t('notes.already_exists', { name: newName })); + alert(this.t('notes.already_exists', { name: validatedName })); // Reset the name in the UI this.currentNoteName = oldPath.split('/').pop().replace('.md', ''); return; diff --git a/locales/de-DE.json b/locales/de-DE.json index 19c57a5..208e72f 100644 --- a/locales/de-DE.json +++ b/locales/de-DE.json @@ -217,6 +217,13 @@ "note_plural": "Notizen", "folder_singular": "Ordner", "folder_plural": "Ordner" + }, + + "validation": { + "forbidden_chars": "Der Name enthält unzulässige Zeichen: {{chars}}", + "reserved_name": "Dieser Name ist vom Betriebssystem reserviert.", + "invalid_dot": "Der Name darf nicht nur ein Punkt sein.", + "trailing_dot_space": "Der Name darf nicht mit einem Punkt oder Leerzeichen enden." } } diff --git a/locales/en-US.json b/locales/en-US.json index aecc595..5c32cc6 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -217,6 +217,13 @@ "note_plural": "notes", "folder_singular": "folder", "folder_plural": "folders" + }, + + "validation": { + "forbidden_chars": "Name contains forbidden characters: {{chars}}", + "reserved_name": "This name is reserved by the operating system.", + "invalid_dot": "Name cannot be just a dot.", + "trailing_dot_space": "Name cannot end with a dot or space." } } diff --git a/locales/es-ES.json b/locales/es-ES.json index ba7e082..c51053e 100644 --- a/locales/es-ES.json +++ b/locales/es-ES.json @@ -217,6 +217,13 @@ "note_plural": "notas", "folder_singular": "carpeta", "folder_plural": "carpetas" + }, + + "validation": { + "forbidden_chars": "El nombre contiene caracteres no permitidos: {{chars}}", + "reserved_name": "Este nombre está reservado por el sistema operativo.", + "invalid_dot": "El nombre no puede ser solo un punto.", + "trailing_dot_space": "El nombre no puede terminar con un punto o espacio." } } diff --git a/locales/fr-FR.json b/locales/fr-FR.json index 0a4a741..5ef619c 100644 --- a/locales/fr-FR.json +++ b/locales/fr-FR.json @@ -217,6 +217,13 @@ "note_plural": "notes", "folder_singular": "dossier", "folder_plural": "dossiers" + }, + + "validation": { + "forbidden_chars": "Le nom contient des caractères interdits : {{chars}}", + "reserved_name": "Ce nom est réservé par le système d'exploitation.", + "invalid_dot": "Le nom ne peut pas être juste un point.", + "trailing_dot_space": "Le nom ne peut pas se terminer par un point ou un espace." } } diff --git a/locales/zh-CN.json b/locales/zh-CN.json index f216ea4..a8925a4 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -217,6 +217,13 @@ "note_plural": "笔记", "folder_singular": "文件夹", "folder_plural": "文件夹" + }, + + "validation": { + "forbidden_chars": "名称包含禁止的字符:{{chars}}", + "reserved_name": "此名称被操作系统保留。", + "invalid_dot": "名称不能只是一个点。", + "trailing_dot_space": "名称不能以点或空格结尾。" } }