diff --git a/README.md b/README.md index 3872bf7..611bd87 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ NoteDiscovery is a **lightweight, self-hosted note-taking application** that put - 🧮 **Math Support** - LaTeX/MathJax for beautiful equations - 📄 **HTML Export & Print** - Export notes as standalone HTML or print - 🕸️ **Graph View** - Interactive visualization of connected notes +- ✏️ **Drawing editor** - In-app sketches as `drawing-*.png` next to your notes — see [documentation/DRAWING.md](documentation/DRAWING.md) - ⭐ **Favorites** - Star your most-used notes for instant access - 📑 **Outline Panel** - Navigate headings with click-to-jump TOC - 🤖 **AI Assistant Ready** - MCP integration for Claude, Cursor & more @@ -240,6 +241,7 @@ Want to learn more? - 🎨 **[THEMES.md](documentation/THEMES.md)** - Theme customization and creating custom themes - ✨ **[FEATURES.md](documentation/FEATURES.md)** - Complete feature list and keyboard shortcuts +- ✏️ **[DRAWING.md](documentation/DRAWING.md)** - Built-in drawing editor (`drawing-*.png`), save behavior, and API notes - 🏷️ **[TAGS.md](documentation/TAGS.md)** - Organize notes with tags and combined filtering - 📋 **[TEMPLATES.md](documentation/TEMPLATES.md)** - Create notes from reusable templates with dynamic placeholders - 🧮 **[MATHJAX.md](documentation/MATHJAX.md)** - LaTeX/Math notation examples and syntax reference diff --git a/backend/export.py b/backend/export.py index 0a0bd8f..4feb441 100644 --- a/backend/export.py +++ b/backend/export.py @@ -49,7 +49,7 @@ def get_media_as_base64(media_path: Path) -> Optional[Tuple[str, str]]: def get_image_as_base64(image_path: Path) -> Optional[str]: """Read an image file and return it as a base64 data URL.""" result = get_media_as_base64(image_path) - if result and result[1] == 'image': + if result and result[1] in ('image', 'drawing'): return result[0] return None diff --git a/backend/main.py b/backend/main.py index 6499f8b..bcf5ff2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -625,11 +625,72 @@ async def get_media(media_path: str): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load media file")) +@api_router.put("/media/{media_path:path}", tags=["Media"]) +@limiter.limit("120/minute") +async def put_media(media_path: str, request: Request): + """ + Overwrite an existing media file in place (used for saving drawing PNGs). + Only files named drawing-*.png are accepted to avoid accidental overwrites. + """ + try: + from backend.utils import ALL_MEDIA_EXTENSIONS + + notes_dir = config['storage']['notes_dir'] + full_path = Path(notes_dir) / media_path + + if not validate_path_security(notes_dir, full_path): + raise HTTPException(status_code=403, detail="Access denied") + + name_lower = full_path.name.lower() + if not (name_lower.startswith('drawing-') and name_lower.endswith('.png')): + raise HTTPException( + status_code=400, + detail="Only drawing PNG files (drawing-*.png) can be updated in place", + ) + + if full_path.suffix.lower() not in ALL_MEDIA_EXTENSIONS: + raise HTTPException(status_code=400, detail="Not an allowed media file") + + if not full_path.exists() or not full_path.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + body = await request.body() + max_size = UPLOAD_MAX_IMAGE_MB * 1024 * 1024 + if len(body) > max_size: + raise HTTPException( + status_code=400, + detail=f"File too large. Maximum size: {UPLOAD_MAX_IMAGE_MB}MB", + ) + + # Reject non-PNG payloads (defense in depth; path already restricts to .png) + if len(body) < 8 or body[:8] != b"\x89PNG\r\n\x1a\n": + raise HTTPException(status_code=400, detail="Body must be a valid PNG image") + + try: + with open(full_path, 'wb') as f: + f.write(body) + except OSError as e: + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to save file")) + + return {"success": True, "path": media_path} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to update media file")) + + @api_router.post("/upload-media", tags=["Media"]) @limiter.limit("20/minute") -async def upload_media(request: Request, file: UploadFile = File(...), note_path: str = Form(...)): +async def upload_media( + request: Request, + file: UploadFile = File(...), + note_path: str = Form(""), + content_folder: str = Form(""), + next_to_notes: str = Form(""), +): """ - Upload a media file (image, audio, video, PDF) and save it to the attachments directory. + Upload a media file (image, audio, video, PDF) and save it to the attachments directory, + or (when next_to_notes=1) save a new drawing PNG next to markdown notes in content_folder. Returns the relative path for markdown linking. """ try: @@ -677,6 +738,32 @@ async def upload_media(request: Request, file: UploadFile = File(...), note_path detail=f"File too large. Maximum size for {media_type or 'this type'}: {max_size // (1024*1024)}MB. Uploaded: {len(file_data) / 1024 / 1024:.2f}MB" ) + if (next_to_notes or "").strip() == "1": + is_png = file.content_type in ("image/png",) or (file.filename and file.filename.lower().endswith(".png")) + if not is_png: + raise HTTPException( + status_code=400, + detail="next_to_notes requires a PNG file", + ) + file_path = save_uploaded_image( + config["storage"]["notes_dir"], + "", + file.filename or "drawing.png", + file_data, + sibling_folder=content_folder or "", + ) + if not file_path: + raise HTTPException(status_code=500, detail="Failed to save drawing") + out_name = Path(file_path).name + media_type = get_media_type(out_name) or "drawing" + return { + "success": True, + "path": file_path, + "filename": out_name, + "type": media_type, + "message": "Drawing created", + } + # Save the file (reusing image save function - it works for any file) file_path = save_uploaded_image( config['storage']['notes_dir'], diff --git a/backend/share.py b/backend/share.py index c4198b5..01b79ff 100644 --- a/backend/share.py +++ b/backend/share.py @@ -11,10 +11,56 @@ from datetime import datetime, timezone from typing import Optional, Dict, Any import threading +from .utils import validate_path_security + # Thread lock for safe concurrent access _lock = threading.Lock() +def _token_references_accessible_file(storage_dir: str, rel_path: Any) -> bool: + """ + Return True if rel_path is a string that resolves to a regular file under storage_dir + and passes the same path boundary check used elsewhere in the app. + """ + if not rel_path or not isinstance(rel_path, str): + return False + try: + full = (Path(storage_dir) / rel_path).resolve() + except (OSError, ValueError, RuntimeError): + return False + if not validate_path_security(str(storage_dir), full): + return False + return full.is_file() + + +def _prune_inaccessible_unsafe(data_dir: str) -> int: + """ + Remove share tokens whose stored path is missing or not under the notes directory. + Call only while holding _lock. Returns the number of tokens removed. + """ + tokens = load_tokens(data_dir) + if not tokens: + return 0 + kept: Dict[str, Dict[str, Any]] = {} + for token, info in tokens.items(): + path = info.get("path") + if _token_references_accessible_file(data_dir, path): + kept[token] = info + removed = len(tokens) - len(kept) + if removed and not save_tokens(data_dir, kept): + return 0 + return removed + + +def prune_inaccessible_share_tokens(data_dir: str) -> int: + """ + Remove entries whose path no longer resolves to a file under the storage root. + Called automatically after create/revoke share; may also be used from tests or one-off maintenance. + """ + with _lock: + return _prune_inaccessible_unsafe(data_dir) + + def generate_token(length: int = 16) -> str: """Generate a URL-safe random token.""" # Use alphanumeric + underscore/hyphen (URL-safe) @@ -76,8 +122,9 @@ def create_share_token(data_dir: str, note_path: str, theme: str = "light") -> O # Check if note already has a token for token, info in tokens.items(): if info.get('path') == note_path: + _prune_inaccessible_unsafe(data_dir) return token - + # Generate new token token = generate_token() @@ -93,7 +140,9 @@ def create_share_token(data_dir: str, note_path: str, theme: str = "light") -> O } if save_tokens(data_dir, tokens): + _prune_inaccessible_unsafe(data_dir) return token + _prune_inaccessible_unsafe(data_dir) return None @@ -177,8 +226,13 @@ def revoke_share_token(data_dir: str, note_path: str) -> bool: if token_to_remove: del tokens[token_to_remove] - return save_tokens(data_dir, tokens) - + if not save_tokens(data_dir, tokens): + _prune_inaccessible_unsafe(data_dir) + return False + _prune_inaccessible_unsafe(data_dir) + return True + + _prune_inaccessible_unsafe(data_dir) return False diff --git a/backend/utils.py b/backend/utils.py index 26629e0..06a37ac 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -604,54 +604,67 @@ def get_attachment_dir(notes_dir: str, note_path: str) -> Path: return Path(notes_dir) / folder / "_attachments" -def save_uploaded_image(notes_dir: str, note_path: str, filename: str, file_data: bytes) -> Optional[str]: +def save_uploaded_image( + notes_dir: str, + note_path: str, + filename: str, + file_data: bytes, + *, + sibling_folder: Optional[str] = None, +) -> 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 + Save uploaded media under the vault. + + Default (sibling_folder is None): store in ``_attachments`` next to the note implied by + ``note_path`` (drag/drop, paste, etc.). + + If ``sibling_folder`` is set (including ``""`` for vault root): store ``drawing-{timestamp}.png`` + in that folder next to ``.md`` files — used for new drawings from the + menu. + + Returns a relative path from ``notes_dir``, or None on failure. """ - # Sanitize filename + base = Path(notes_dir) + + if sibling_folder is not None: + cf = (sibling_folder or "").strip().replace("\\", "/") + segments = [p for p in cf.split("/") if p and p != "."] + if any(p == ".." for p in segments): + return None + dest_dir = base.joinpath(*segments) if segments else base + if not validate_path_security(notes_dir, dest_dir / ".nd_probe"): + return None + try: + dest_dir.mkdir(parents=True, exist_ok=True) + except OSError: + return None + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + full_path = dest_dir / f"drawing-{timestamp}.png" + if not validate_path_security(notes_dir, full_path): + return None + try: + with open(full_path, "wb") as f: + f.write(file_data) + return str(full_path.relative_to(base).as_posix()) + except OSError as e: + print(f"Error saving image: {e}") + return None + 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') + 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: + 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: + return str(full_path.relative_to(base).as_posix()) + except OSError as e: print(f"Error saving image: {e}") return None @@ -671,8 +684,13 @@ ALL_MEDIA_EXTENSIONS = set().union(*MEDIA_EXTENSIONS.values()) def get_media_type(filename: str) -> Optional[str]: """ Determine the media type based on file extension. - Returns: 'image', 'audio', 'video', 'document', or None if not a media file. + Returns: 'image', 'audio', 'video', 'document', 'drawing', or None if not a media file. + + Drawings are PNG files stored like images but named drawing-*.png (editable canvas in the app). """ + name_lower = Path(filename).name.lower() + if name_lower.startswith('drawing-') and name_lower.endswith('.png'): + return 'drawing' ext = Path(filename).suffix.lower() for media_type, extensions in MEDIA_EXTENSIONS.items(): if ext in extensions: diff --git a/docs/index.html b/docs/index.html index def810f..90f939a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -7,35 +7,45 @@