validation of folder/note names and allow for unicode characters
This commit is contained in:
parent
d66687ab91
commit
35a0b8c46c
|
|
@ -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
|
||||||
|
|
|
||||||
188
frontend/app.js
188
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() {
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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": "名称不能以点或空格结尾。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue