validation of folder/note names and allow for unicode characters

This commit is contained in:
Gamosoft 2025-12-17 10:03:40 +01:00
parent d66687ab91
commit 35a0b8c46c
7 changed files with 227 additions and 23 deletions

View File

@ -379,16 +379,35 @@ def create_note_metadata(notes_dir: str, note_path: str) -> Dict:
def sanitize_filename(filename: str) -> str: def sanitize_filename(filename: str) -> str:
""" """
Sanitize a filename by removing/replacing invalid characters. Sanitize a filename by removing/replacing dangerous filesystem characters.
Keeps only alphanumeric chars, dots, dashes, and underscores. 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 # Get the extension first
parts = filename.rsplit('.', 1) parts = filename.rsplit('.', 1)
name = parts[0] name = parts[0]
ext = parts[1] if len(parts) > 1 else '' ext = parts[1] if len(parts) > 1 else ''
# Remove/replace invalid characters # Remove dangerous characters (replace with underscore)
name = re.sub(r'[^a-zA-Z0-9_-]', '_', name) # 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 # Rejoin with extension
return f"{name}.{ext}" if ext else name return f"{name}.{ext}" if ext else name

View File

@ -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() { function noteApp() {
return { return {
// App state // App state
@ -806,6 +896,35 @@ function noteApp() {
return value.replace(/\{\{(\w+)\}\}/g, (_, name) => params[name] ?? `{{${name}}}`); 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 // Load available locales from backend
async loadAvailableLocales() { async loadAvailableLocales() {
try { try {
@ -972,8 +1091,15 @@ function noteApp() {
} }
try { 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 // Determine the note path based on dropdown context
let notePath = this.newTemplateNoteName.trim(); let notePath = validation.sanitized;
if (!notePath.endsWith('.md')) { if (!notePath.endsWith('.md')) {
notePath += '.md'; notePath += '.md';
} }
@ -994,7 +1120,7 @@ function noteApp() {
// CRITICAL: Check if note already exists // CRITICAL: Check if note already exists
const existingNote = this.notes.find(note => note.path === notePath); const existingNote = this.notes.find(note => note.path === notePath);
if (existingNote) { if (existingNote) {
alert(this.t('notes.already_exists', { name: this.newTemplateNoteName.trim() })); alert(this.t('notes.already_exists', { name: validation.sanitized }));
return; return;
} }
@ -2707,23 +2833,29 @@ function noteApp() {
const noteName = prompt(promptText); const noteName = prompt(promptText);
if (!noteName) return; if (!noteName) return;
const sanitizedName = noteName.trim().replace(/[^a-zA-Z0-9-_\s\/]/g, ''); // Validate the name/path (may contain / for paths when no target folder)
if (!sanitizedName) { const validation = targetFolder
alert(this.t('notes.invalid_name')); ? FilenameValidator.validateFilename(noteName)
: FilenameValidator.validatePath(noteName);
if (!validation.valid) {
alert(this.getValidationErrorMessage(validation, 'note'));
return; return;
} }
const validatedName = validation.sanitized;
let notePath; let notePath;
if (targetFolder) { if (targetFolder) {
notePath = `${targetFolder}/${sanitizedName}.md`; notePath = `${targetFolder}/${validatedName}.md`;
} else { } else {
notePath = sanitizedName.endsWith('.md') ? sanitizedName : `${sanitizedName}.md`; notePath = validatedName.endsWith('.md') ? validatedName : `${validatedName}.md`;
} }
// CRITICAL: Check if note already exists // CRITICAL: Check if note already exists
const existingNote = this.notes.find(note => note.path === notePath); const existingNote = this.notes.find(note => note.path === notePath);
if (existingNote) { if (existingNote) {
alert(this.t('notes.already_exists', { name: sanitizedName })); alert(this.t('notes.already_exists', { name: validatedName }));
return; return;
} }
@ -2768,18 +2900,23 @@ function noteApp() {
const folderName = prompt(promptText); const folderName = prompt(promptText);
if (!folderName) return; if (!folderName) return;
const sanitizedName = folderName.trim().replace(/[^a-zA-Z0-9-_\s\/]/g, ''); // Validate the name/path (may contain / for paths when no target folder)
if (!sanitizedName) { const validation = targetFolder
alert(this.t('folders.invalid_name')); ? FilenameValidator.validateFilename(folderName)
: FilenameValidator.validatePath(folderName);
if (!validation.valid) {
alert(this.getValidationErrorMessage(validation, 'folder'));
return; return;
} }
const folderPath = targetFolder ? `${targetFolder}/${sanitizedName}` : sanitizedName; const validatedName = validation.sanitized;
const folderPath = targetFolder ? `${targetFolder}/${validatedName}` : validatedName;
// Check if folder already exists // Check if folder already exists
const existingFolder = this.allFolders.find(folder => folder === folderPath); const existingFolder = this.allFolders.find(folder => folder === folderPath);
if (existingFolder) { if (existingFolder) {
alert(this.t('folders.already_exists', { name: sanitizedName })); alert(this.t('folders.already_exists', { name: validatedName }));
return; return;
} }
@ -2812,15 +2949,18 @@ function noteApp() {
const newName = prompt(this.t('folders.prompt_rename', { name: currentName }), currentName); const newName = prompt(this.t('folders.prompt_rename', { name: currentName }), currentName);
if (!newName || newName === currentName) return; if (!newName || newName === currentName) return;
const sanitizedName = newName.trim().replace(/[^a-zA-Z0-9-_\s]/g, ''); // Validate the new name (single segment, no path separators)
if (!sanitizedName) { const validation = FilenameValidator.validateFilename(newName);
alert(this.t('folders.invalid_name')); if (!validation.valid) {
alert(this.getValidationErrorMessage(validation, 'folder'));
return; return;
} }
const validatedName = validation.sanitized;
// Calculate new path // Calculate new path
const pathParts = folderPath.split('/'); const pathParts = folderPath.split('/');
pathParts[pathParts.length - 1] = sanitizedName; pathParts[pathParts.length - 1] = validatedName;
const newPath = pathParts.join('/'); const newPath = pathParts.join('/');
try { try {
@ -3187,15 +3327,25 @@ function noteApp() {
return; 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 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; if (oldPath === newPath) return;
// Check if a note with the new name already exists // Check if a note with the new name already exists
const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase()); const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase());
if (existingNote) { if (existingNote) {
alert(this.t('notes.already_exists', { name: newName })); alert(this.t('notes.already_exists', { name: validatedName }));
// Reset the name in the UI // Reset the name in the UI
this.currentNoteName = oldPath.split('/').pop().replace('.md', ''); this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
return; return;

View File

@ -217,6 +217,13 @@
"note_plural": "Notizen", "note_plural": "Notizen",
"folder_singular": "Ordner", "folder_singular": "Ordner",
"folder_plural": "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."
} }
} }

View File

@ -217,6 +217,13 @@
"note_plural": "notes", "note_plural": "notes",
"folder_singular": "folder", "folder_singular": "folder",
"folder_plural": "folders" "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."
} }
} }

View File

@ -217,6 +217,13 @@
"note_plural": "notas", "note_plural": "notas",
"folder_singular": "carpeta", "folder_singular": "carpeta",
"folder_plural": "carpetas" "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."
} }
} }

View File

@ -217,6 +217,13 @@
"note_plural": "notes", "note_plural": "notes",
"folder_singular": "dossier", "folder_singular": "dossier",
"folder_plural": "dossiers" "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."
} }
} }

View File

@ -217,6 +217,13 @@
"note_plural": "笔记", "note_plural": "笔记",
"folder_singular": "文件夹", "folder_singular": "文件夹",
"folder_plural": "文件夹" "folder_plural": "文件夹"
},
"validation": {
"forbidden_chars": "名称包含禁止的字符:{{chars}}",
"reserved_name": "此名称被操作系统保留。",
"invalid_dot": "名称不能只是一个点。",
"trailing_dot_space": "名称不能以点或空格结尾。"
} }
} }