diff --git a/backend/main.py b/backend/main.py index f848479..17ac833 100644 --- a/backend/main.py +++ b/backend/main.py @@ -32,6 +32,8 @@ move_folder, rename_folder, delete_folder, + save_uploaded_image, + validate_path_security, ) from .plugins import PluginManager from .themes import get_available_themes, get_theme_css @@ -433,6 +435,91 @@ async def create_new_folder(data: dict): raise HTTPException(status_code=500, detail=str(e)) +@api_router.get("/images/{image_path:path}") +async def get_image(image_path: str): + """ + Serve an image file with authentication protection. + """ + try: + notes_dir = config['storage']['notes_dir'] + full_path = Path(notes_dir) / image_path + + # Security: Validate path is within notes directory + if not validate_path_security(notes_dir, full_path): + raise HTTPException(status_code=403, detail="Access denied") + + # Check file exists and is an image + if not full_path.exists() or not full_path.is_file(): + raise HTTPException(status_code=404, detail="Image not found") + + # Validate it's an image file + allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} + if full_path.suffix.lower() not in allowed_extensions: + raise HTTPException(status_code=400, detail="Not an image file") + + # Return the file + return FileResponse(full_path) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@api_router.post("/upload-image") +async def upload_image(file: UploadFile = File(...), note_path: str = Form(...)): + """ + Upload an image file and save it to the attachments directory. + Returns the relative path to the image for markdown linking. + """ + try: + # Validate file type + allowed_types = {'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'} + allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} + + # Get file extension + file_ext = Path(file.filename).suffix.lower() if file.filename else '' + + if file.content_type not in allowed_types and file_ext not in allowed_extensions: + raise HTTPException( + status_code=400, + detail=f"Invalid file type. Allowed: jpg, jpeg, png, gif, webp. Got: {file.content_type}" + ) + + # Read file data + file_data = await file.read() + + # Validate file size (10MB max) + max_size = 10 * 1024 * 1024 # 10MB in bytes + if len(file_data) > max_size: + raise HTTPException( + status_code=400, + detail=f"File too large. Maximum size: 10MB. Uploaded: {len(file_data) / 1024 / 1024:.2f}MB" + ) + + # Save the image + image_path = save_uploaded_image( + config['storage']['notes_dir'], + note_path, + file.filename, + file_data + ) + + if not image_path: + raise HTTPException(status_code=500, detail="Failed to save image") + + return { + "success": True, + "path": image_path, + "filename": Path(image_path).name, + "message": "Image uploaded successfully" + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @api_router.post("/notes/move") async def move_note_endpoint(data: dict): """Move a note to a different folder""" diff --git a/backend/utils.py b/backend/utils.py index 9020cbd..e953e93 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -157,23 +157,29 @@ def delete_folder(notes_dir: str, folder_path: str) -> bool: def get_all_notes(notes_dir: str) -> List[Dict]: - """Recursively get all markdown notes""" - notes = [] + """Recursively get all markdown notes and images""" + items = [] notes_path = Path(notes_dir) + # Get all markdown notes for md_file in notes_path.rglob("*.md"): relative_path = md_file.relative_to(notes_path) stat = md_file.stat() - notes.append({ + items.append({ "name": md_file.stem, "path": str(relative_path.as_posix()), "folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "", "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), - "size": stat.st_size + "size": stat.st_size, + "type": "note" }) - return sorted(notes, key=lambda x: x['modified'], reverse=True) + # Get all images + images = get_all_images(notes_dir) + items.extend(images) + + return sorted(items, key=lambda x: x['modified'], reverse=True) def get_note_content(notes_dir: str, note_path: str) -> Optional[str]: @@ -296,3 +302,127 @@ def create_note_metadata(notes_dir: str, note_path: str) -> Dict: "lines": line_count } + +def sanitize_filename(filename: str) -> str: + """ + Sanitize a filename by removing/replacing invalid characters. + Keeps only alphanumeric chars, dots, dashes, and underscores. + """ + # 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) + + # Rejoin with extension + return f"{name}.{ext}" if ext else name + + +def get_attachment_dir(notes_dir: str, note_path: str) -> Path: + """ + Get the attachments directory for a given note. + If note is in root, returns /data/_attachments/ + If note is in folder, returns /data/folder/_attachments/ + """ + if not note_path: + # Root level + return Path(notes_dir) / "_attachments" + + note_path_obj = Path(note_path) + folder = note_path_obj.parent + + if str(folder) == '.': + # Note is in root + return Path(notes_dir) / "_attachments" + else: + # Note is in a folder + return Path(notes_dir) / folder / "_attachments" + + +def save_uploaded_image(notes_dir: str, note_path: str, filename: str, file_data: bytes) -> Optional[str]: + """ + Save an uploaded image to the appropriate attachments directory. + Returns the relative path to the image if successful, None otherwise. + + Args: + notes_dir: Base notes directory + note_path: Path of the note the image is being uploaded to + filename: Original filename + file_data: Binary file data + + Returns: + Relative path to the saved image, or None if failed + """ + # Sanitize filename + sanitized_name = sanitize_filename(filename) + + # Get extension + ext = Path(sanitized_name).suffix + name_without_ext = Path(sanitized_name).stem + + # Add timestamp to prevent collisions + timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + final_filename = f"{name_without_ext}-{timestamp}{ext}" + + # Get attachments directory + attachments_dir = get_attachment_dir(notes_dir, note_path) + + # Create directory if it doesn't exist + attachments_dir.mkdir(parents=True, exist_ok=True) + + # Full path to save the image + full_path = attachments_dir / final_filename + + # Security check + if not validate_path_security(notes_dir, full_path): + print(f"Security: Attempted to save image outside notes directory: {full_path}") + return None + + try: + # Write the file + with open(full_path, 'wb') as f: + f.write(file_data) + + # Return relative path from notes_dir + relative_path = full_path.relative_to(Path(notes_dir)) + return str(relative_path.as_posix()) + except Exception as e: + print(f"Error saving image: {e}") + return None + + +def get_all_images(notes_dir: str) -> List[Dict]: + """ + Get all images from attachments directories. + Returns list of image dictionaries with metadata. + """ + images = [] + notes_path = Path(notes_dir) + + # Common image extensions + image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} + + # Find all attachments directories + for attachments_dir in notes_path.rglob("_attachments"): + if not attachments_dir.is_dir(): + continue + + # Find all images in this attachments directory + for image_file in attachments_dir.iterdir(): + if image_file.is_file() and image_file.suffix.lower() in image_extensions: + relative_path = image_file.relative_to(notes_path) + stat = image_file.stat() + + images.append({ + "name": image_file.name, + "path": str(relative_path.as_posix()), + "folder": str(relative_path.parent.as_posix()), + "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "size": stat.st_size, + "type": "image" + }) + + return images + diff --git a/documentation/API.md b/documentation/API.md index 08a0689..96385bd 100644 --- a/documentation/API.md +++ b/documentation/API.md @@ -81,6 +81,67 @@ Content-Type: application/json } ``` +## 🖼️ Images + +### Get Image +```http +GET /api/images/{image_path} +``` +Retrieve an image file with authentication protection. + +**Example:** +```bash +curl http://localhost:8000/api/images/folder/_attachments/image-20240417093343.png +``` + +**Security Note:** This endpoint requires authentication and validates that: +- The image path is within the notes directory (prevents directory traversal) +- The file exists and is a valid image format +- The requesting user is authenticated (if auth is enabled) + +### Upload Image +```http +POST /api/upload-image +Content-Type: multipart/form-data + +file: +note_path: +``` + +Upload an image file to the `_attachments` directory. Images are automatically organized per-folder and named with timestamps to prevent conflicts. + +**Supported formats:** JPG, JPEG, PNG, GIF, WEBP +**Maximum size:** 10MB + +**Response:** +```json +{ + "success": true, + "path": "folder/_attachments/image-20240417093343.png", + "filename": "image-20240417093343.png", + "message": "Image uploaded successfully" +} +``` + +**Example (using curl):** +```bash +curl -X POST http://localhost:8000/api/upload-image \ + -F "file=@/path/to/image.png" \ + -F "note_path=folder/mynote.md" +``` + +**Windows PowerShell:** +```powershell +curl.exe -X POST http://localhost:8000/api/upload-image -F "file=@C:\path\to\image.png" -F "note_path=folder/mynote.md" +``` + +**Notes:** +- Images are stored in `_attachments` folders relative to the note's location +- Filenames are automatically timestamped (e.g., `image-20240417093343.png`) +- Images appear in the sidebar navigation and can be viewed/deleted directly +- Drag & drop images into the editor automatically uploads and inserts markdown +- All image access requires authentication when security is enabled + ## 📁 Folders ### Create Folder @@ -93,6 +154,17 @@ Content-Type: application/json } ``` +### Delete Folder +```http +DELETE /api/folders/{folder_path} +``` +Deletes a folder and all its contents. + +**Example:** +```bash +curl -X DELETE http://localhost:8000/api/folders/Projects/Archive +``` + ### Move Folder ```http POST /api/folders/move diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index d0a6022..994fe06 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -13,6 +13,11 @@ - **Mermaid diagrams** - Create flowcharts, sequence diagrams, and more (see [MERMAID.md](MERMAID.md)) - **HTML Export** - Export notes as standalone HTML files +### Image Support +- **Drag & drop upload** - Drop images from your file system directly into the editor +- **Clipboard paste** - Paste images from clipboard with Ctrl+V +- **Multiple formats** - Supports JPG, PNG, GIF, and WebP (max 10MB) + ### Organization - **Folder hierarchy** - Organize notes in nested folders - **Drag & drop** - Move notes and folders effortlessly @@ -24,7 +29,7 @@ ### Internal Links - **Wiki-style links** - `[[Note Name]]` syntax -- **Drag to link** - Hold Ctrl and drag a note into the editor +- **Drag to link** - Drag notes or images into the editor to insert links - **Click to navigate** - Jump between notes seamlessly - **External links** - Open in new tabs automatically @@ -143,7 +148,6 @@ graph TD | `Ctrl+Y` or `Ctrl+Shift+Z` | `Cmd+Y` or `Cmd+Shift+Z` | Redo | | `F3` | `F3` | Next search match | | `Shift+F3` | `Shift+F3` | Previous search match | -| `Ctrl+Drag` | `Cmd+Drag` | Create internal link | ## 🚀 Performance diff --git a/frontend/app.js b/frontend/app.js index 849b33f..b55c0a4 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -64,8 +64,9 @@ function noteApp() { // Scroll sync state isScrolling: false, - // Drag state for internal linking - draggedNoteForLink: null, + // Unified drag state + draggedItem: null, // { path: string, type: 'note' | 'image' } + dropTarget: null, // 'editor' | 'folder' | null // Undo/Redo history undoHistory: [], @@ -95,6 +96,9 @@ function noteApp() { // Mermaid state cache lastMermaidTheme: null, + // Image viewer state + currentImage: '', + // DOM element cache (to avoid repeated querySelector calls) _domCache: { editor: null, @@ -131,6 +135,8 @@ function noteApp() { this.searchResults = []; this.clearSearchHighlights(); } + } else if (e.state && e.state.imagePath) { + this.viewImage(e.state.imagePath, false); // false = don't update history } }); @@ -525,28 +531,46 @@ function noteApp() { }); } - // Then, render notes in this folder (after subfolders) + // Then, render notes and images in this folder (after subfolders) if (folder.notes && folder.notes.length > 0) { folder.notes.forEach(note => { + // Check if it's an image or a note + const isImage = note.type === 'image'; const isCurrentNote = this.currentNote === note.path; + const isCurrentImage = this.currentImage === note.path; + const isCurrent = isImage ? isCurrentImage : isCurrentNote; + + // Different icon for images + const icon = isImage ? '🖼️' : ''; + + // Click handler + const clickHandler = isImage + ? `viewImage('${note.path.replace(/'/g, "\\'")}')` + : `loadNote('${note.path.replace(/'/g, "\\'")}')`; + + // Delete handler + const deleteHandler = isImage + ? `deleteImage('${note.path.replace(/'/g, "\\'")}')` + : `deleteNote('${note.path.replace(/'/g, "\\'")}', '${note.name.replace(/'/g, "\\'")}')`; + html += `
- ${note.name} + ${icon}${icon ? ' ' : ''}${note.name}
@@ -939,24 +947,27 @@

- +