diff --git a/backend/export.py b/backend/export.py new file mode 100644 index 0000000..15a8bdf --- /dev/null +++ b/backend/export.py @@ -0,0 +1,560 @@ +""" +HTML Export Module for NoteDiscovery +Generates standalone HTML files for notes with embedded images and styling. +Used by both /api/export (download) and /public (sharing) endpoints. +""" + +import base64 +import re +from pathlib import Path +from typing import Optional +import mimetypes + + +def get_image_as_base64(image_path: Path) -> Optional[str]: + """Read an image file and return it as a base64 data URL.""" + if not image_path.exists() or not image_path.is_file(): + return None + + # Get MIME type + mime_type, _ = mimetypes.guess_type(str(image_path)) + if not mime_type or not mime_type.startswith('image/'): + return None + + try: + with open(image_path, 'rb') as f: + image_data = f.read() + base64_data = base64.b64encode(image_data).decode('utf-8') + return f"data:{mime_type};base64,{base64_data}" + except Exception as e: + print(f"Failed to read image {image_path}: {e}") + return None + + +def strip_frontmatter(content: str) -> str: + """ + Remove YAML frontmatter from markdown content. + Frontmatter is delimited by --- at the start and end. + """ + if not content.strip().startswith('---'): + return content + + lines = content.split('\n') + if lines[0].strip() != '---': + return content + + # Find closing --- + end_idx = -1 + for i in range(1, len(lines)): + if lines[i].strip() == '---': + end_idx = i + break + + if end_idx == -1: + return content + + # Remove frontmatter and return the rest + return '\n'.join(lines[end_idx + 1:]).strip() + + +def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Path) -> Optional[Path]: + """ + Search for an image file in common attachment locations. + Returns the resolved path if found, None otherwise. + """ + # Common locations to search for images (fast path) + search_paths = [ + note_folder / image_name, # Same folder as note + note_folder / '_attachments' / image_name, # Note's _attachments folder + notes_dir / '_attachments' / image_name, # Root _attachments folder + ] + + # Also search in parent folders' _attachments (for nested notes) + current = note_folder + while current != notes_dir and current.parent != current: + search_paths.append(current / '_attachments' / image_name) + current = current.parent + + for path in search_paths: + resolved = path.resolve() + if resolved.exists() and resolved.is_file(): + # Security: ensure path is within notes_dir + try: + resolved.relative_to(notes_dir.resolve()) + return resolved + except ValueError: + continue + + # Fallback: search all _attachments folders recursively (slower but thorough) + # This handles cross-folder image references like in Obsidian + try: + for attachment_folder in notes_dir.rglob('_attachments'): + if attachment_folder.is_dir(): + candidate = attachment_folder / image_name + if candidate.exists() and candidate.is_file(): + try: + candidate.resolve().relative_to(notes_dir.resolve()) + return candidate.resolve() + except ValueError: + continue + except Exception: + pass # Ignore errors in recursive search + + return None + + +def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str: + """ + Find all image references in markdown and embed them as base64. + Handles: + - Standard markdown images: ![alt](path) + - Wikilink images: ![[image.png]] or ![[image.png|alt text]] + """ + + # First, handle wikilink images: ![[image.png]] or ![[image.png|alt text]] + wikilink_img_pattern = r'!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]' + + def replace_wikilink_image(match): + image_name = match.group(1).strip() + alt_text = match.group(2).strip() if match.group(2) else image_name.split('/')[-1].rsplit('.', 1)[0] + + # Find the image + resolved_path = find_image_in_attachments(image_name, note_folder, notes_dir) + + if resolved_path: + base64_url = get_image_as_base64(resolved_path) + if base64_url: + return f'![{alt_text}]({base64_url})' + + # Image not found, convert to placeholder + return f'![{alt_text}]()' + + markdown_content = re.sub(wikilink_img_pattern, replace_wikilink_image, markdown_content) + + # Then, handle standard markdown images: ![alt](path) + img_pattern = r'!\[([^\]]*)\]\(([^)]+)\)' + + def replace_image(match): + alt_text = match.group(1) + img_path = match.group(2) + + # Skip external URLs and already-embedded base64 + if img_path.startswith(('http://', 'https://', 'data:')): + return match.group(0) + + # Skip empty paths (from failed wikilink conversion) + if not img_path: + return match.group(0) + + # Handle /api/images/ paths (convert to filesystem paths) + if img_path.startswith('/api/images/'): + # Strip the /api/images/ prefix to get the relative path within notes_dir + relative_path = img_path[len('/api/images/'):] + resolved_path = (notes_dir / relative_path).resolve() + else: + # Try to resolve the image path relative to note folder + resolved_path = (note_folder / img_path).resolve() + + # If not found, try the attachment search + if not resolved_path.exists(): + # Extract just the filename and search + image_name = Path(img_path).name + resolved_path = find_image_in_attachments(image_name, note_folder, notes_dir) + if not resolved_path: + return match.group(0) # Keep original if not found + + # Security: ensure path is within notes_dir + try: + resolved_path.relative_to(notes_dir.resolve()) + except ValueError: + # Path is outside notes_dir, skip + return match.group(0) + + # Get base64 data + base64_url = get_image_as_base64(resolved_path) + if base64_url: + return f'![{alt_text}]({base64_url})' + + # Image not found, keep original + return match.group(0) + + markdown_content = re.sub(img_pattern, replace_image, markdown_content) + + return markdown_content + + +def convert_wikilinks_to_html(markdown_content: str) -> str: + """ + Convert wikilinks [[note]] or [[note|display text]] to HTML links. + In standalone export mode, these are non-functional decorative links. + """ + # Pattern for wikilinks: [[target]] or [[target|display text]] + # But NOT image wikilinks (those start with !) + wikilink_pattern = r'(?{display}' + + return re.sub(wikilink_pattern, replace_wikilink, markdown_content) + + +def generate_export_html( + title: str, + content: str, + theme_css: str, + is_dark: bool = False +) -> str: + """ + Generate a standalone HTML document for a note. + Uses marked.js for client-side markdown rendering. + + Args: + title: The note title (for and display) + content: Raw markdown content (images should already be base64 embedded) + theme_css: CSS content for theming + is_dark: Whether using a dark theme (for Mermaid/Highlight.js) + + Returns: + Complete HTML document as string + """ + # Escape content for JavaScript string + escaped_content = ( + content + .replace('\\', '\\\\') + .replace('`', '\\`') + .replace('$', '\\$') + .replace('</', '<\\/') # Prevent </script> breaking + ) + + highlight_theme = 'github-dark' if is_dark else 'github' + mermaid_theme = 'dark' if is_dark else 'default' + + html = f'''<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{title} + + + + + + + + + + + + + + + + + + +
+ + + +''' + + return html diff --git a/backend/main.py b/backend/main.py index aaa547e..7747747 100644 --- a/backend/main.py +++ b/backend/main.py @@ -44,6 +44,17 @@ from .utils import ( ) from .plugins import PluginManager from .themes import get_available_themes, get_theme_css +from .share import ( + create_share_token, + get_share_token, + get_share_info, + revoke_share_token, + get_note_by_token, + delete_token_for_note, + update_token_path, + get_all_shared_paths, +) +from .export import generate_export_html, embed_images_as_base64, convert_wikilinks_to_html, strip_frontmatter # Load configuration config_path = Path(__file__).parent.parent / "config.yaml" @@ -715,6 +726,9 @@ async def move_note_endpoint(request: Request, data: dict): if not success: raise HTTPException(status_code=400, detail=error_msg or "Failed to move note") + # Update share token path if note was shared + update_token_path(config['storage']['notes_dir'], old_path, new_path) + # Run plugin hooks plugin_manager.run_hook('on_note_save', note_path=new_path, content='') @@ -1041,6 +1055,9 @@ async def remove_note(request: Request, note_path: str): if not success: raise HTTPException(status_code=404, detail="Note not found") + # Clean up any share token for this note + delete_token_for_note(config['storage']['notes_dir'], note_path) + # Run plugin hooks plugin_manager.run_hook('on_note_delete', note_path=note_path) @@ -1256,6 +1273,195 @@ async def toggle_plugin(request: Request, plugin_name: str, enabled: dict): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to toggle plugin")) +# ============================================================================ +# Share Token Endpoints (authenticated) +# ============================================================================ + +@api_router.post("/share/{note_path:path}") +@limiter.limit("30/minute") +async def create_share(request: Request, note_path: str, data: dict = None): + """ + Create a share token for a note. + Returns the share URL that can be accessed without authentication. + Optionally accepts { "theme": "theme-name" } to set the display theme. + """ + try: + notes_dir = config['storage']['notes_dir'] + + # Get theme from request body (default to light) + theme = "light" + if data and isinstance(data, dict): + theme = data.get('theme', 'light') + + # Add .md extension if not present + if not note_path.endswith('.md'): + note_path = f"{note_path}.md" + + # Check if note exists + content = get_note_content(notes_dir, note_path) + if content is None: + raise HTTPException(status_code=404, detail="Note not found") + + # Create or get existing token (with theme) + token = create_share_token(notes_dir, note_path, theme) + if not token: + raise HTTPException(status_code=500, detail="Failed to create share token") + + # Build share URL + base_url = str(request.base_url).rstrip('/') + share_url = f"{base_url}/share/{token}" + + return { + "success": True, + "token": token, + "url": share_url, + "path": note_path, + "theme": theme + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create share")) + + +@api_router.get("/share/{note_path:path}") +async def get_share_status(note_path: str, request: Request): + """ + Get the share status for a note. + Returns whether the note is shared and its share URL if so. + """ + try: + notes_dir = config['storage']['notes_dir'] + + # Add .md extension if not present + if not note_path.endswith('.md'): + note_path = f"{note_path}.md" + + # Get share info + info = get_share_info(notes_dir, note_path) + + if info.get('shared'): + base_url = str(request.base_url).rstrip('/') + info['url'] = f"{base_url}/share/{info['token']}" + + return info + except Exception as e: + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get share status")) + + +@api_router.get("/shared-notes") +async def list_shared_notes(): + """ + Get a list of all currently shared note paths. + Used for displaying share indicators in the UI. + """ + try: + notes_dir = config['storage']['notes_dir'] + shared_paths = get_all_shared_paths(notes_dir) + return {"paths": shared_paths} + except Exception as e: + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get shared notes")) + + +@api_router.delete("/share/{note_path:path}") +@limiter.limit("30/minute") +async def delete_share(request: Request, note_path: str): + """ + Revoke sharing for a note (delete the share token). + """ + try: + notes_dir = config['storage']['notes_dir'] + + # Add .md extension if not present + if not note_path.endswith('.md'): + note_path = f"{note_path}.md" + + # Revoke token + success = revoke_share_token(notes_dir, note_path) + + return { + "success": success, + "message": "Share revoked" if success else "Note was not shared" + } + except Exception as e: + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to revoke share")) + + +# ============================================================================ +# Public Share Endpoint (no authentication required) +# ============================================================================ + +@app.get("/share/{token}", response_class=HTMLResponse) +@limiter.limit("60/minute") +async def view_shared_note(request: Request, token: str): + """ + View a shared note by its token. + No authentication required - anyone with the token can view. + """ + try: + notes_dir = Path(config['storage']['notes_dir']) + + # Look up note by token (returns dict with path and theme) + share_info = get_note_by_token(str(notes_dir), token) + if not share_info: + raise HTTPException(status_code=404, detail="Shared note not found or link expired") + + note_path = share_info['path'] + theme = share_info.get('theme', 'light') + + # Read note content + content = get_note_content(str(notes_dir), note_path) + if content is None: + # Note was deleted but token still exists - clean up + delete_token_for_note(str(notes_dir), note_path) + raise HTTPException(status_code=404, detail="Note no longer exists") + + # Strip YAML frontmatter (like the preview does) + content = strip_frontmatter(content) + + # Get note folder for resolving relative image paths + note_file_path = notes_dir / note_path + note_folder = note_file_path.parent + + # Embed images as base64 + content_with_images = embed_images_as_base64(content, note_folder, notes_dir) + + # Convert wikilinks to decorative HTML links + content_with_links = convert_wikilinks_to_html(content_with_images) + + # Use the theme that was set when sharing + themes_dir = Path(__file__).parent.parent / "themes" + theme_css = get_theme_css(str(themes_dir), theme) + if not theme_css: + theme_css = get_theme_css(str(themes_dir), "light") + theme = "light" + + # Strip data-theme selector + theme_css = theme_css.replace(f':root[data-theme="{theme}"]', ':root') + theme_css = theme_css.replace(':root[data-theme="light"]', ':root') + theme_css = theme_css.replace(':root[data-theme="dark"]', ':root') + + # Determine if dark theme + is_dark = 'dark' in theme.lower() or theme in ['dracula', 'nord', 'monokai', 'cobalt2', 'gruvbox-dark'] + + # Get note title + title = Path(note_path).stem + + # Generate HTML + html_content = generate_export_html( + title=title, + content=content_with_links, + theme_css=theme_css, + is_dark=is_dark + ) + + return HTMLResponse(content=html_content) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load shared note")) + + @app.get("/health") async def health_check(): """Health check endpoint""" @@ -1305,4 +1511,3 @@ if __name__ == "__main__": port=config['server']['port'], reload=config['server']['reload'] ) - diff --git a/backend/share.py b/backend/share.py new file mode 100644 index 0000000..c4198b5 --- /dev/null +++ b/backend/share.py @@ -0,0 +1,238 @@ +""" +Share Token Management for NoteDiscovery +Handles creating, storing, and revoking share tokens for public note access. +""" + +import json +import secrets +import string +from pathlib import Path +from datetime import datetime, timezone +from typing import Optional, Dict, Any +import threading + +# Thread lock for safe concurrent access +_lock = threading.Lock() + + +def generate_token(length: int = 16) -> str: + """Generate a URL-safe random token.""" + # Use alphanumeric + underscore/hyphen (URL-safe) + alphabet = string.ascii_letters + string.digits + '_-' + return ''.join(secrets.choice(alphabet) for _ in range(length)) + + +def get_tokens_file_path(data_dir: str) -> Path: + """Get the path to the share tokens file.""" + return Path(data_dir) / '.share-tokens.json' + + +def load_tokens(data_dir: str) -> Dict[str, Dict[str, Any]]: + """Load share tokens from file.""" + tokens_file = get_tokens_file_path(data_dir) + + if not tokens_file.exists(): + return {} + + try: + with open(tokens_file, 'r', encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return {} + + +def save_tokens(data_dir: str, tokens: Dict[str, Dict[str, Any]]) -> bool: + """Save share tokens to file.""" + tokens_file = get_tokens_file_path(data_dir) + + try: + # Ensure parent directory exists + tokens_file.parent.mkdir(parents=True, exist_ok=True) + + with open(tokens_file, 'w', encoding='utf-8') as f: + json.dump(tokens, f, indent=2, ensure_ascii=False) + return True + except IOError as e: + print(f"Failed to save share tokens: {e}") + return False + + +def create_share_token(data_dir: str, note_path: str, theme: str = "light") -> Optional[str]: + """ + Create a share token for a note. + If the note already has a token, returns the existing one. + + Args: + data_dir: Path to the data directory + note_path: Path to the note (relative to notes_dir) + theme: The theme to use when viewing the shared note + + Returns: + The share token, or None on error + """ + with _lock: + tokens = load_tokens(data_dir) + + # Check if note already has a token + for token, info in tokens.items(): + if info.get('path') == note_path: + return token + + # Generate new token + token = generate_token() + + # Ensure uniqueness (extremely unlikely collision, but check anyway) + while token in tokens: + token = generate_token() + + # Store token with theme + tokens[token] = { + 'path': note_path, + 'theme': theme, + 'created': datetime.now(timezone.utc).isoformat() + } + + if save_tokens(data_dir, tokens): + return token + return None + + +def get_share_token(data_dir: str, note_path: str) -> Optional[str]: + """ + Get the share token for a note, if it exists. + + Args: + data_dir: Path to the data directory + note_path: Path to the note + + Returns: + The share token, or None if not shared + """ + tokens = load_tokens(data_dir) + + for token, info in tokens.items(): + if info.get('path') == note_path: + return token + + return None + + +def get_note_by_token(data_dir: str, token: str) -> Optional[Dict[str, Any]]: + """ + Get the note info for a share token. + + Args: + data_dir: Path to the data directory + token: The share token + + Returns: + Dict with 'path' and 'theme', or None if token not found + """ + tokens = load_tokens(data_dir) + + if token in tokens: + return { + 'path': tokens[token].get('path'), + 'theme': tokens[token].get('theme', 'light') + } + + return None + + +def get_all_shared_paths(data_dir: str) -> list: + """ + Get a list of all currently shared note paths. + Used for displaying share indicators in the UI. + + Args: + data_dir: Path to the data directory + + Returns: + List of note paths that are currently shared + """ + tokens = load_tokens(data_dir) + return [info.get('path') for info in tokens.values() if info.get('path')] + + +def revoke_share_token(data_dir: str, note_path: str) -> bool: + """ + Revoke (delete) the share token for a note. + + Args: + data_dir: Path to the data directory + note_path: Path to the note + + Returns: + True if token was revoked, False if not found or error + """ + with _lock: + tokens = load_tokens(data_dir) + + # Find and remove token for this note + token_to_remove = None + for token, info in tokens.items(): + if info.get('path') == note_path: + token_to_remove = token + break + + if token_to_remove: + del tokens[token_to_remove] + return save_tokens(data_dir, tokens) + + return False + + +def get_share_info(data_dir: str, note_path: str) -> Optional[Dict[str, Any]]: + """ + Get share information for a note. + + Args: + data_dir: Path to the data directory + note_path: Path to the note + + Returns: + Dict with token, theme, and created date, or None if not shared + """ + tokens = load_tokens(data_dir) + + for token, info in tokens.items(): + if info.get('path') == note_path: + return { + 'token': token, + 'theme': info.get('theme', 'light'), + 'created': info.get('created'), + 'shared': True + } + + return {'shared': False} + + +def update_token_path(data_dir: str, old_path: str, new_path: str) -> bool: + """ + Update the path for a token when a note is moved/renamed. + + Args: + data_dir: Path to the data directory + old_path: Old note path + new_path: New note path + + Returns: + True if updated, False if not found or error + """ + with _lock: + tokens = load_tokens(data_dir) + + for token, info in tokens.items(): + if info.get('path') == old_path: + info['path'] = new_path + return save_tokens(data_dir, tokens) + + return False + + +def delete_token_for_note(data_dir: str, note_path: str) -> bool: + """ + Delete the share token when a note is deleted. + Alias for revoke_share_token for clarity. + """ + return revoke_share_token(data_dir, note_path) diff --git a/frontend/app.js b/frontend/app.js index 525c23b..592fd2f 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -255,6 +255,13 @@ function noteApp() { selectedTemplate: '', newTemplateNoteName: '', + // Share state + showShareModal: false, + shareInfo: null, + shareLoading: false, + shareLinkCopied: false, + _sharedNotePaths: new Set(), // O(1) lookup for shared note indicators + // Homepage state selectedHomepageFolder: '', _homepageCache: { @@ -442,6 +449,7 @@ function noteApp() { // Note: Translations are preloaded synchronously before Alpine init (see index.html) // loadLocale() is only called when user changes language from settings await this.loadNotes(); + await this.loadSharedNotePaths(); await this.loadTemplates(); await this.checkStatsPlugin(); this.loadSidebarWidth(); @@ -483,6 +491,7 @@ function noteApp() { this.noteContent = ''; this.currentNoteName = ''; this.outline = []; + this.shareInfo = null; // Reset share info document.title = this.appName; // Restore homepage folder state if it was saved @@ -1792,7 +1801,9 @@ function noteApp() { const isCurrentImage = this.currentImage === note.path; const isCurrent = isImage ? isCurrentImage : isCurrentNote; - // Different icon for images + // Different icon for images, share icon for shared notes + const isShared = !isImage && this.isNoteShared(note.path); + const shareIcon = isShared ? '' : ''; const icon = isImage ? '🖼️' : ''; // Escape paths for use in native event handlers @@ -1818,7 +1829,7 @@ function noteApp() { onmouseover="if('${escapedNotePath}' !== window.$root.currentNote && '${escapedNotePath}' !== window.$root.currentImage) this.style.backgroundColor='var(--bg-hover)'" onmouseout="if('${escapedNotePath}' !== window.$root.currentNote && '${escapedNotePath}' !== window.$root.currentImage) this.style.backgroundColor='transparent'" > - ${icon}${icon ? ' ' : ''}${note.name} + ${shareIcon}${icon}${icon ? ' ' : ''}${note.name}
+ + +
+ + + +
+

+ + +
+ + +
+ + + +
+ + +
+ +
+ + +