diff --git a/backend/export.py b/backend/export.py new file mode 100644 index 0000000..c6219b4 --- /dev/null +++ b/backend/export.py @@ -0,0 +1,622 @@ +""" +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..2258440 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" @@ -101,12 +112,28 @@ if 'AUTHENTICATION_SECRET_KEY' in os.environ: config['authentication']['secret_key'] = os.getenv('AUTHENTICATION_SECRET_KEY') print("🔐 Secret key loaded from AUTHENTICATION_SECRET_KEY env var") +# OpenAPI tag metadata for grouping endpoints in Swagger UI +tags_metadata = [ + {"name": "Notes", "description": "Create, read, update, delete notes"}, + {"name": "Folders", "description": "Folder management"}, + {"name": "Images", "description": "Image viewing and uploads"}, + {"name": "Search", "description": "Full-text search"}, + {"name": "Sharing", "description": "Public note sharing via tokens"}, + {"name": "Themes", "description": "UI theme management"}, + {"name": "Templates", "description": "Note templates"}, + {"name": "Tags", "description": "Tag-based organization"}, + {"name": "Graph", "description": "Note relationship graph"}, + {"name": "Plugins", "description": "Plugin management"}, + {"name": "System", "description": "Health checks and configuration"}, +] + # Initialize app app = FastAPI( title=config['app']['name'], version=config['app']['version'], - docs_url=None, # Disable Swagger UI at /docs - redoc_url=None # Disable ReDoc at /redoc + docs_url='/api', # Default is /docs + redoc_url=None, # Disable ReDoc at /redoc + openapi_tags=tags_metadata ) # CORS middleware configuration @@ -346,171 +373,7 @@ pages_router = APIRouter( # Application Routes (with auth via router dependencies) # ============================================================================ -@api_router.get("") -async def api_documentation(): - """API Documentation - List all available endpoints""" - return { - "app": { - "name": config['app']['name'], - "version": config['app']['version'] - }, - "endpoints": [ - { - "method": "GET", - "path": "/api", - "description": "API documentation - lists all available endpoints", - "response": "API documentation object" - }, - { - "method": "GET", - "path": "/api/config", - "description": "Get application configuration", - "response": "{ name, version, searchEnabled }" - }, - { - "method": "GET", - "path": "/api/themes", - "description": "List all available themes", - "response": "{ themes: [{ id, name, builtin }] }" - }, - { - "method": "GET", - "path": "/api/themes/{theme_id}", - "description": "Get CSS content for a specific theme", - "parameters": {"theme_id": "Theme identifier (e.g., 'dark', 'light', 'dracula')"}, - "response": "{ css, theme_id }" - }, - { - "method": "GET", - "path": "/api/notes", - "description": "List all notes and folders", - "response": "{ notes: [{ path, name, folder }], folders: [path] }" - }, - { - "method": "GET", - "path": "/api/notes/{note_path}", - "description": "Get content of a specific note", - "parameters": {"note_path": "Path to note (e.g., 'test.md', 'folder/note.md')"}, - "response": "{ content }" - }, - { - "method": "POST", - "path": "/api/notes/{note_path}", - "description": "Create or update a note", - "parameters": {"note_path": "Path to note"}, - "body": {"content": "Markdown content of the note"}, - "response": "{ success, message }" - }, - { - "method": "DELETE", - "path": "/api/notes/{note_path}", - "description": "Delete a note", - "parameters": {"note_path": "Path to note"}, - "response": "{ success, message }" - }, - { - "method": "POST", - "path": "/api/notes/move", - "description": "Move a note to a different location", - "body": {"oldPath": "Current note path", "newPath": "New note path"}, - "response": "{ success, oldPath, newPath }" - }, - { - "method": "POST", - "path": "/api/folders", - "description": "Create a new folder", - "body": {"path": "Folder path (e.g., 'Projects', 'Work/2025')"}, - "response": "{ success, path }" - }, - { - "method": "POST", - "path": "/api/folders/move", - "description": "Move a folder to a different location", - "body": {"oldPath": "Current folder path", "newPath": "New folder path"}, - "response": "{ success, oldPath, newPath }" - }, - { - "method": "POST", - "path": "/api/folders/rename", - "description": "Rename a folder", - "body": {"oldPath": "Current folder path", "newPath": "New folder path"}, - "response": "{ success, oldPath, newPath }" - }, - { - "method": "GET", - "path": "/api/tags", - "description": "Get all tags used across all notes with their counts", - "response": "{ tags: { tag_name: count, ... } }" - }, - { - "method": "GET", - "path": "/api/tags/{tag_name}", - "description": "Get all notes that have a specific tag", - "parameters": {"tag_name": "Tag to filter by (case-insensitive)"}, - "response": "{ tag, count, notes: [{ path, name, folder, tags }] }" - }, - { - "method": "GET", - "path": "/api/search", - "description": "Search notes by content", - "parameters": {"q": "Search query string"}, - "response": "{ results: [{ path, name, folder, snippet }], query }" - }, - { - "method": "GET", - "path": "/api/graph", - "description": "Get graph data for note visualization", - "response": "{ nodes: [{ id, label }], edges: [] }" - }, - { - "method": "GET", - "path": "/api/plugins", - "description": "List all loaded plugins", - "response": "{ plugins: [{ id, name, version, enabled }] }" - }, - { - "method": "POST", - "path": "/api/plugins/{plugin_name}/toggle", - "description": "Enable or disable a plugin", - "parameters": {"plugin_name": "Plugin identifier"}, - "body": {"enabled": "true/false"}, - "response": "{ success, plugin, enabled }" - }, - { - "method": "GET", - "path": "/health", - "description": "Health check endpoint", - "response": "{ status: 'healthy', app, version }" - } - ], - "notes": { - "authentication": "Not required (add authentication in config.yaml if needed)", - "base_url": "http://localhost:8000", - "content_type": "application/json", - "cors": "Enabled for all origins" - }, - "examples": { - "create_note": { - "curl": "curl -X POST http://localhost:8000/api/notes/test.md -H 'Content-Type: application/json' -d '{\"content\": \"# Hello World\"}'", - "description": "Create a new note named test.md" - }, - "search_notes": { - "curl": "curl http://localhost:8000/api/search?q=hello", - "description": "Search for notes containing 'hello'" - }, - "list_themes": { - "curl": "curl http://localhost:8000/api/themes", - "description": "Get all available themes" - }, - "enable_plugin": { - "curl": "curl -X POST http://localhost:8000/api/plugins/git_backup/toggle -H 'Content-Type: application/json' -d '{\"enabled\": true}'", - "description": "Enable the git_backup plugin" - } - } - } - - -@api_router.get("/config") +@api_router.get("/config", tags=["System"]) async def get_config(): """Get app configuration for frontend""" return { @@ -524,7 +387,7 @@ async def get_config(): } -@api_router.get("/themes") +@api_router.get("/themes", tags=["Themes"]) async def list_themes(): """Get all available themes""" themes_dir = Path(__file__).parent.parent / "themes" @@ -588,7 +451,7 @@ async def get_locale(locale_code: str): raise HTTPException(status_code=500, detail=f"Failed to load locale: {str(e)}") -@api_router.post("/folders") +@api_router.post("/folders", tags=["Folders"]) @limiter.limit("30/minute") async def create_new_folder(request: Request, data: dict): """Create a new folder""" @@ -613,7 +476,7 @@ async def create_new_folder(request: Request, data: dict): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create folder")) -@api_router.get("/images/{image_path:path}") +@api_router.get("/images/{image_path:path}", tags=["Images"]) async def get_image(image_path: str): """ Serve an image file with authentication protection. @@ -643,7 +506,7 @@ async def get_image(image_path: str): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load image")) -@api_router.post("/upload-image") +@api_router.post("/upload-image", tags=["Images"]) @limiter.limit("20/minute") async def upload_image(request: Request, file: UploadFile = File(...), note_path: str = Form(...)): """ @@ -699,7 +562,7 @@ async def upload_image(request: Request, file: UploadFile = File(...), note_path raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to upload image")) -@api_router.post("/notes/move") +@api_router.post("/notes/move", tags=["Notes"]) @limiter.limit("30/minute") async def move_note_endpoint(request: Request, data: dict): """Move a note to a different folder""" @@ -715,6 +578,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='') @@ -730,7 +596,7 @@ async def move_note_endpoint(request: Request, data: dict): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move note")) -@api_router.post("/folders/move") +@api_router.post("/folders/move", tags=["Folders"]) @limiter.limit("20/minute") async def move_folder_endpoint(request: Request, data: dict): """Move a folder to a different location""" @@ -758,7 +624,7 @@ async def move_folder_endpoint(request: Request, data: dict): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move folder")) -@api_router.post("/folders/rename") +@api_router.post("/folders/rename", tags=["Folders"]) @limiter.limit("30/minute") async def rename_folder_endpoint(request: Request, data: dict): """Rename a folder""" @@ -786,7 +652,7 @@ async def rename_folder_endpoint(request: Request, data: dict): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to rename folder")) -@api_router.delete("/folders/{folder_path:path}") +@api_router.delete("/folders/{folder_path:path}", tags=["Folders"]) @limiter.limit("20/minute") async def delete_folder_endpoint(request: Request, folder_path: str): """Delete a folder and all its contents""" @@ -812,7 +678,7 @@ async def delete_folder_endpoint(request: Request, folder_path: str): # --- Tags Endpoints --- -@api_router.get("/tags") +@api_router.get("/tags", tags=["Tags"]) async def list_tags(): """ Get all tags used across all notes with their counts. @@ -827,7 +693,7 @@ async def list_tags(): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load tags")) -@api_router.get("/tags/{tag_name}") +@api_router.get("/tags/{tag_name}", tags=["Tags"]) async def get_notes_by_tag_endpoint(tag_name: str): """ Get all notes that have a specific tag. @@ -851,7 +717,7 @@ async def get_notes_by_tag_endpoint(tag_name: str): # --- Template Endpoints --- -@api_router.get("/templates") +@api_router.get("/templates", tags=["Templates"]) @limiter.limit("120/minute") async def list_templates(request: Request): """ @@ -867,7 +733,7 @@ async def list_templates(request: Request): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to list templates")) -@api_router.get("/templates/{template_name}") +@api_router.get("/templates/{template_name}", tags=["Templates"]) @limiter.limit("120/minute") async def get_template(request: Request, template_name: str): """ @@ -895,7 +761,7 @@ async def get_template(request: Request, template_name: str): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get template")) -@api_router.post("/templates/create-note") +@api_router.post("/templates/create-note", tags=["Templates"]) @limiter.limit("60/minute") async def create_note_from_template(request: Request, data: dict): """ @@ -955,7 +821,7 @@ async def create_note_from_template(request: Request, data: dict): # --- Notes Endpoints --- -@api_router.get("/notes") +@api_router.get("/notes", tags=["Notes"]) async def list_notes(): """List all notes with metadata""" try: @@ -966,7 +832,7 @@ async def list_notes(): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to list notes")) -@api_router.get("/notes/{note_path:path}") +@api_router.get("/notes/{note_path:path}", tags=["Notes"]) async def get_note(note_path: str): """Get a specific note's content""" try: @@ -990,7 +856,7 @@ async def get_note(note_path: str): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load note")) -@api_router.post("/notes/{note_path:path}") +@api_router.post("/notes/{note_path:path}", tags=["Notes"]) @limiter.limit("60/minute") async def create_or_update_note(request: Request, note_path: str, content: dict): """Create or update a note""" @@ -1031,7 +897,7 @@ async def create_or_update_note(request: Request, note_path: str, content: dict) raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to save note")) -@api_router.delete("/notes/{note_path:path}") +@api_router.delete("/notes/{note_path:path}", tags=["Notes"]) @limiter.limit("30/minute") async def remove_note(request: Request, note_path: str): """Delete a note""" @@ -1041,6 +907,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) @@ -1054,7 +923,7 @@ async def remove_note(request: Request, note_path: str): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to delete note")) -@api_router.get("/search") +@api_router.get("/search", tags=["Search"]) async def search(q: str): """Search notes by content""" try: @@ -1073,7 +942,7 @@ async def search(q: str): raise HTTPException(status_code=500, detail=safe_error_message(e, "Search failed")) -@api_router.get("/graph") +@api_router.get("/graph", tags=["Graph"]) async def get_graph(): """Get graph data for note visualization with wikilink and markdown link detection""" try: @@ -1216,13 +1085,13 @@ async def get_graph(): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to generate graph data")) -@api_router.get("/plugins") +@api_router.get("/plugins", tags=["Plugins"]) async def list_plugins(): """List all available plugins""" return {"plugins": plugin_manager.list_plugins()} -@api_router.get("/plugins/note_stats/calculate") +@api_router.get("/plugins/note_stats/calculate", tags=["Plugins"]) async def calculate_note_stats(content: str): """Calculate statistics for note content (if plugin enabled)""" try: @@ -1236,7 +1105,7 @@ async def calculate_note_stats(content: str): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to calculate note statistics")) -@api_router.post("/plugins/{plugin_name}/toggle") +@api_router.post("/plugins/{plugin_name}/toggle", tags=["Plugins"]) @limiter.limit("10/minute") async def toggle_plugin(request: Request, plugin_name: str, enabled: dict): """Enable or disable a plugin""" @@ -1256,7 +1125,198 @@ 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")) -@app.get("/health") +# ============================================================================ +# Share Token Endpoints (authenticated) +# ============================================================================ + +@api_router.post("/share/{note_path:path}", tags=["Sharing"]) +@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}", tags=["Sharing"]) +@limiter.limit("120/minute") +async def get_share_status(request: Request, note_path: str): + """ + 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", tags=["Sharing"]) +@limiter.limit("60/minute") +async def list_shared_notes(request: Request): + """ + 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}", tags=["Sharing"]) +@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, tags=["Sharing"]) +@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", tags=["System"]) async def health_check(): """Health check endpoint""" return { @@ -1305,4 +1365,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/docs/index.html b/docs/index.html index 6b02042..fc519be 100644 --- a/docs/index.html +++ b/docs/index.html @@ -980,6 +980,14 @@ +
+ 🌐 +
+ Public Sharing + Share notes via link, even with auth enabled +
+
+
🔗
diff --git a/documentation/API.md b/documentation/API.md index adde9a3..7705d26 100644 --- a/documentation/API.md +++ b/documentation/API.md @@ -305,11 +305,11 @@ GET /health ``` Returns system health status. -### API Info +### Swagger UI (Interactive Docs) ```http GET /api ``` -Self-documenting endpoint listing all available API routes. +Interactive API documentation with try-it-out functionality (Swagger UI). --- @@ -430,6 +430,75 @@ Creates a new note from a template with placeholder replacement. --- +## 🔗 Sharing + +Share notes publicly without requiring authentication. + +### Create Share Link +```http +POST /api/share/{note_path} +Content-Type: application/json + +{ + "theme": "dracula" +} +``` +Creates a share token for the note. The `theme` is optional (defaults to "light"). + +**Response:** +```json +{ + "success": true, + "token": "LRFEo86oSVeJ3Gju", + "url": "http://localhost:8000/share/LRFEo86oSVeJ3Gju", + "note_path": "folder/note.md" +} +``` + +### Get Share Status +```http +GET /api/share/{note_path} +``` +Check if a note is currently shared. + +**Response:** +```json +{ + "shared": true, + "token": "LRFEo86oSVeJ3Gju", + "url": "http://localhost:8000/share/LRFEo86oSVeJ3Gju", + "theme": "dracula", + "created": "2026-01-15T10:30:00+00:00" +} +``` + +### Revoke Share +```http +DELETE /api/share/{note_path} +``` +Removes public access to the note. + +### List Shared Notes +```http +GET /api/shared-notes +``` +Returns paths of all currently shared notes. + +**Response:** +```json +{ + "paths": ["folder/note.md", "another.md"] +} +``` + +### View Shared Note (Public) +```http +GET /share/{token} +``` +Public endpoint - no authentication required. Returns the note as a standalone HTML page with the theme set when sharing was created. + +--- + ## 📝 Response Format All endpoints return JSON responses: @@ -450,5 +519,5 @@ All endpoints return JSON responses: ``` --- -💡 **Tip:** Use the `/api` endpoint to get a live, self-documented list of all available endpoints! +💡 **Tip:** Visit `/api` for interactive Swagger UI documentation where you can try endpoints directly in your browser! diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index 1a8287d..0c52d73 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -12,7 +12,8 @@ - **Copy code blocks** - One-click copy button on hover - **LaTeX/Math rendering** - Beautiful mathematical equations with MathJax (see [MATHJAX.md](MATHJAX.md)) - **Mermaid diagrams** - Create flowcharts, sequence diagrams, and more (see [MERMAID.md](MERMAID.md)) -- **HTML Export** - Export notes as standalone HTML files +- **HTML Export** - Export notes as standalone HTML files with embedded images +- **Public Sharing** - Share notes via token-based URLs without requiring authentication (see [SHARING.md](SHARING.md)) ### Image Support - **Drag & drop upload** - Drop images from your file system directly into the editor diff --git a/documentation/SHARING.md b/documentation/SHARING.md new file mode 100644 index 0000000..ff0739f --- /dev/null +++ b/documentation/SHARING.md @@ -0,0 +1,60 @@ +# 🔗 Public Sharing + +Share notes publicly without requiring viewers to log in. + +## How It Works + +1. Open a note you want to share +2. Click the **Share** button in the toolbar +3. Click **Create Share Link** +4. Copy the generated URL and send it to anyone + +The recipient can view the note in their browser - no account needed. + +## Revoking Access + +To stop sharing a note: +1. Open the note +2. Click the **Share** button +3. Click **Revoke Link** + +The old URL will immediately stop working. + +## Features + +- **Theme preserved** - Shared notes display with the theme active when you created the link +- **Images embedded** - All images are included in the shared view +- **Code highlighting** - Syntax highlighting works in shared notes +- **Copy button** - Code blocks have a copy-to-clipboard button +- **MathJax & Mermaid** - Math equations and diagrams render correctly +- **No expiration** - Links work until you revoke them + +## Visual Indicators + +- A **share icon** appears next to shared notes in the sidebar +- The Share modal shows the current sharing status + +## Technical Details + +### Token Storage + +Share tokens are stored in `.share-tokens.json` in your data folder: + +```json +{ + "LRFEo86oSVeJ3Gju": { + "path": "folder/note.md", + "theme": "dracula", + "created": "2026-01-15T10:30:00+00:00" + } +} +``` + +Each note can have one share token. Creating a new link for an already-shared note returns the existing token. + +### Security + +- Tokens are random 16-character strings +- Only the exact token URL grants access +- Revoking deletes the token permanently +- Shared notes are read-only (viewers cannot edit) \ No newline at end of file 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}
+ + +
+ +
+
+ +

+ + + + +

+ + +
+ +
+ + +
+

+ +
+ + +
+

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