NoteDiscovery/backend/main.py

1858 lines
68 KiB
Python
Raw Normal View History

2025-11-05 16:48:41 +00:00
"""
NoteDiscovery - Self-Hosted Markdown Knowledge Base
Main FastAPI application
"""
2026-01-01 14:26:03 +00:00
from fastapi import FastAPI, HTTPException, UploadFile, File, Request, Form, Depends, APIRouter, Response
2025-11-05 16:48:41 +00:00
from fastapi.staticfiles import StaticFiles
2025-11-14 13:34:19 +00:00
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse
2025-11-05 16:48:41 +00:00
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials, APIKeyHeader
2025-11-14 13:34:19 +00:00
from starlette.middleware.sessions import SessionMiddleware
2025-11-05 16:48:41 +00:00
import os
import yaml
import json
2026-06-30 14:13:01 +00:00
import logging
2025-11-05 16:48:41 +00:00
from pathlib import Path
from typing import List, Optional
import aiofiles
from datetime import datetime
2025-11-14 13:34:19 +00:00
import bcrypt
import secrets
2025-11-25 14:26:58 +00:00
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
2025-11-05 16:48:41 +00:00
2026-06-30 14:52:22 +00:00
logger = logging.getLogger("uvicorn.error")
2025-11-05 16:48:41 +00:00
from .utils import (
scan_notes_fast_walk,
2026-06-30 11:47:19 +00:00
ensure_index_built,
2025-11-05 16:48:41 +00:00
get_note_content,
save_note,
delete_note,
search_notes,
create_note_metadata,
ensure_directories,
create_folder,
move_note,
move_folder,
rename_folder,
delete_folder,
2025-11-20 13:10:17 +00:00
save_uploaded_image,
2025-11-20 13:44:49 +00:00
validate_path_security,
2025-11-24 16:11:22 +00:00
get_all_tags,
get_notes_by_tag,
2025-11-26 10:28:09 +00:00
get_templates,
get_template_content,
apply_template_placeholders,
paginate,
2026-03-24 09:58:32 +00:00
get_backlinks,
2025-11-05 16:48:41 +00:00
)
from . import note_index
2025-11-05 16:48:41 +00:00
from .plugins import PluginManager
from .themes import get_available_themes, get_theme_css
2026-01-15 09:07:51 +00:00
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
2025-11-05 16:48:41 +00:00
# Load configuration
config_path = Path(__file__).parent.parent / "config.yaml"
with open(config_path, 'r', encoding='utf-8') as f:
config = yaml.safe_load(f)
# Load version from VERSION file (single source of truth)
version_path = Path(__file__).parent.parent / "VERSION"
if not version_path.exists():
raise FileNotFoundError("VERSION file not found. Please create it with the current version number.")
with open(version_path, 'r', encoding='utf-8') as f:
version = f.read().strip()
config['app']['version'] = version
# Environment variable overrides for authentication settings
# Allows different configs for local vs production deployments
if 'AUTHENTICATION_ENABLED' in os.environ:
auth_enabled = os.getenv('AUTHENTICATION_ENABLED', 'false').lower() in ('true', '1', 'yes')
config['authentication']['enabled'] = auth_enabled
2026-06-30 14:13:01 +00:00
logger.info("Authentication %s (from AUTHENTICATION_ENABLED env var)", 'ENABLED' if auth_enabled else 'DISABLED')
else:
2026-06-30 14:13:01 +00:00
logger.info("Authentication %s (from config.yaml)", 'ENABLED' if config.get('authentication', {}).get('enabled', False) else 'DISABLED')
2026-01-08 15:29:14 +00:00
# Password configuration priority:
# 1. AUTHENTICATION_PASSWORD env var (hashed at startup)
# 2. authentication.password in config.yaml (hashed at startup)
2026-01-08 15:29:14 +00:00
# Default password is "admin" if nothing is configured
if 'AUTHENTICATION_PASSWORD' in os.environ:
plain_password = os.getenv('AUTHENTICATION_PASSWORD', '').strip()
if plain_password:
config['authentication']['password_hash'] = bcrypt.hashpw(
plain_password.encode('utf-8'),
bcrypt.gensalt()
).decode('utf-8')
2026-06-30 14:13:01 +00:00
logger.info("Password loaded from AUTHENTICATION_PASSWORD env var")
else:
2026-06-30 14:13:01 +00:00
logger.warning("AUTHENTICATION_PASSWORD env var is empty - ignoring")
elif config.get('authentication', {}).get('password', '').strip():
plain_password = config['authentication']['password'].strip()
2026-01-08 15:29:14 +00:00
config['authentication']['password_hash'] = bcrypt.hashpw(
plain_password.encode('utf-8'),
bcrypt.gensalt()
).decode('utf-8')
del config['authentication']['password']
2026-06-30 14:13:01 +00:00
logger.info("Password loaded from config.yaml")
# Allow secret key to be set via environment variable (for session security)
if 'AUTHENTICATION_SECRET_KEY' in os.environ:
config['authentication']['secret_key'] = os.getenv('AUTHENTICATION_SECRET_KEY')
2026-06-30 14:13:01 +00:00
logger.info("Secret key loaded from AUTHENTICATION_SECRET_KEY env var")
# API key configuration for external integrations (MCP servers, scripts, etc.)
# Priority: AUTHENTICATION_API_KEY env var > authentication.api_key in config.yaml
if 'AUTHENTICATION_API_KEY' in os.environ:
api_key_value = os.getenv('AUTHENTICATION_API_KEY', '').strip()
if api_key_value:
config['authentication']['api_key'] = api_key_value
2026-06-30 14:13:01 +00:00
logger.info("API key loaded from AUTHENTICATION_API_KEY env var")
else:
config['authentication']['api_key'] = ''
elif config.get('authentication', {}).get('api_key', '').strip():
2026-06-30 14:13:01 +00:00
logger.info("API key loaded from config.yaml")
else:
config['authentication']['api_key'] = ''
# Warnings for missing authentication methods (only when auth is enabled)
if config.get('authentication', {}).get('enabled', False):
_has_password = bool(config.get('authentication', {}).get('password_hash', ''))
_has_api_key = bool(config.get('authentication', {}).get('api_key', '').strip())
_secret_key = config.get('authentication', {}).get('secret_key', '')
_is_default_secret = _secret_key in ('', 'change_this_to_a_random_secret_key_in_production')
if not _has_password and not _has_api_key:
2026-06-30 14:13:01 +00:00
logger.critical("Authentication enabled but NO auth methods configured - ALL access will be denied!")
else:
if not _has_password:
2026-06-30 14:13:01 +00:00
logger.warning("No password configured - web UI login will not work")
if not _has_api_key:
2026-06-30 14:13:01 +00:00
logger.warning("No API key configured - external integrations will require session cookies")
if _is_default_secret:
2026-06-30 14:13:01 +00:00
logger.critical("Using default secret_key - sessions can be forged! Change it in config.yaml")
2026-01-15 10:51:36 +00:00
# OpenAPI tag metadata for grouping endpoints in Swagger UI
tags_metadata = [
{"name": "Notes", "description": "Create, read, update, delete notes"},
{"name": "Folders", "description": "Folder management"},
2026-01-19 10:38:36 +00:00
{"name": "Media", "description": "Media files (images, audio, video, PDF)"},
2026-01-15 10:51:36 +00:00
{"name": "Search", "description": "Full-text search"},
{"name": "Sharing", "description": "Public note sharing via tokens"},
{"name": "Tags", "description": "Tag-based organization"},
2026-01-19 10:38:36 +00:00
{"name": "Templates", "description": "Note templates"},
{"name": "Themes", "description": "UI theme management"},
{"name": "Locales", "description": "Internationalization (i18n)"},
2026-01-15 10:51:36 +00:00
{"name": "Graph", "description": "Note relationship graph"},
{"name": "Plugins", "description": "Plugin management"},
{"name": "System", "description": "Health checks and configuration"},
]
2025-11-05 16:48:41 +00:00
# Initialize app
app = FastAPI(
title=config['app']['name'],
2025-12-15 14:45:10 +00:00
version=config['app']['version'],
2026-01-15 10:51:36 +00:00
docs_url='/api', # Default is /docs
redoc_url=None, # Disable ReDoc at /redoc
openapi_tags=tags_metadata
2025-11-05 16:48:41 +00:00
)
# CORS middleware configuration
# Use config.yaml to control allowed origins (default: ["*"] for self-hosted simplicity)
allowed_origins = config.get('server', {}).get('allowed_origins', ["*"])
2025-11-05 16:48:41 +00:00
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
2025-11-05 16:48:41 +00:00
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
2026-06-30 14:13:01 +00:00
logger.info("CORS allowed origins: %s", allowed_origins)
2025-12-15 17:18:06 +00:00
# ===========================================================
# =================
# Security Helpers
# ============================================================================
def safe_error_message(error: Exception, user_message: str = "An error occurred") -> str:
"""
Return safe error message for API responses.
In debug mode, returns full error details.
In production, returns generic message and logs full details server-side.
Args:
error: The caught exception
user_message: User-friendly message to show in production
Returns:
Safe error message string
"""
error_details = f"{type(error).__name__}: {str(error)}"
# Always log the full error server-side
2026-06-30 14:13:01 +00:00
logger.error(error_details)
# In debug mode, return detailed error to help with development
if config.get('server', {}).get('debug', False):
return error_details
# In production, return generic message (full details already logged)
return user_message
2025-11-05 16:48:41 +00:00
2025-11-14 13:34:19 +00:00
# Session middleware for authentication
2025-11-25 16:56:11 +00:00
# Security: Session ID is regenerated after login to prevent session fixation attacks
2025-11-14 13:34:19 +00:00
app.add_middleware(
SessionMiddleware,
secret_key=config.get('authentication', {}).get('secret_key', 'insecure_default_key_change_this'),
max_age=config.get('authentication', {}).get('session_max_age', 604800), # 7 days default
2025-11-25 16:56:11 +00:00
same_site='lax', # Prevents CSRF attacks
https_only=False # Set to True if using HTTPS in production
2025-11-14 13:34:19 +00:00
)
# Demo mode - Centralizes all demo-specific restrictions
# When DEMO_MODE=true, enables rate limiting and other demo protections
# Add additional demo restrictions here as needed (e.g., disable certain features)
DEMO_MODE = os.getenv('DEMO_MODE', 'false').lower() in ('true', '1', 'yes')
2026-01-22 09:43:47 +00:00
ALREADY_DONATED = os.getenv('ALREADY_DONATED', 'false').lower() in ('true', '1', 'yes')
2025-11-25 14:26:58 +00:00
2026-04-06 13:23:30 +00:00
# Upload size limits (in MB) - configurable via environment variables
UPLOAD_MAX_IMAGE_MB = int(os.getenv('UPLOAD_MAX_IMAGE_MB', '10'))
UPLOAD_MAX_AUDIO_MB = int(os.getenv('UPLOAD_MAX_AUDIO_MB', '50'))
UPLOAD_MAX_VIDEO_MB = int(os.getenv('UPLOAD_MAX_VIDEO_MB', '100'))
UPLOAD_MAX_PDF_MB = int(os.getenv('UPLOAD_MAX_PDF_MB', '20'))
2026-06-08 09:02:05 +00:00
# Autosave debounce in milliseconds (applies to note typing AND drawing PNG autosave).
try:
_autosave_raw = int(os.getenv(
'AUTOSAVE_DELAY_MS',
str(config.get('ui', {}).get('autosave_delay_ms', 1000))
))
except (TypeError, ValueError):
_autosave_raw = 1000
AUTOSAVE_DELAY_MS = max(250, min(60000, _autosave_raw))
if DEMO_MODE:
# Enable rate limiting for demo deployments
2025-11-25 14:26:58 +00:00
limiter = Limiter(key_func=get_remote_address, default_limits=["200/hour"])
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
2026-06-30 14:13:01 +00:00
logger.info("DEMO MODE enabled - Rate limiting active")
2025-11-25 14:26:58 +00:00
else:
# Production/self-hosted mode - no restrictions
2025-11-25 14:26:58 +00:00
# Create a dummy limiter that doesn't actually limit
class DummyLimiter:
def limit(self, *args, **kwargs):
def decorator(func):
return func
return decorator
limiter = DummyLimiter()
2025-11-05 16:48:41 +00:00
# Ensure required directories exist
ensure_directories(config)
# Initialize plugin manager
plugin_manager = PluginManager(config['storage']['plugins_dir'])
# Run app startup hooks
plugin_manager.run_hook('on_app_startup')
2025-11-05 16:48:41 +00:00
# Mount static files
static_path = Path(__file__).parent.parent / "frontend"
app.mount("/static", StaticFiles(directory=static_path), name="static")
2026-01-01 14:26:03 +00:00
# PWA Service Worker - must be served from root for proper scope
2026-01-19 10:38:36 +00:00
@app.get("/sw.js", include_in_schema=False)
2026-01-01 14:26:03 +00:00
@limiter.limit("30/minute")
async def service_worker(request: Request):
"""Serve the PWA service worker from root path for proper scope.
Injects the app version from VERSION file for cache invalidation."""
sw_path = static_path / "sw.js"
if sw_path.exists():
async with aiofiles.open(sw_path, 'r', encoding='utf-8') as f:
content = await f.read()
# Inject app version into cache name
content = content.replace('__APP_VERSION__', version)
return Response(content=content, media_type="application/javascript")
raise HTTPException(status_code=404, detail="Service worker not found")
2025-11-05 16:48:41 +00:00
2025-11-14 13:34:19 +00:00
# ============================================================================
# Custom Exception Handlers
# ============================================================================
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
"""
Custom exception handler for HTTP exceptions.
Handles 401 errors specially:
- For API requests: return JSON error
- For page requests: redirect to login
"""
# Only handle 401 errors specially
if exc.status_code == 401:
# Check if this is an API request
if request.url.path.startswith('/api/'):
return JSONResponse(
status_code=401,
content={"detail": exc.detail}
)
# For page requests, redirect to login
return RedirectResponse(url='/login', status_code=303)
# For all other HTTP exceptions, return default JSON response
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail}
)
# ============================================================================
# Authentication Helpers
# ============================================================================
# Security schemes for API authentication (auto_error=False for optional auth)
# These are automatically added to OpenAPI docs (/api)
bearer_scheme = HTTPBearer(auto_error=False, description="Bearer token authentication")
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False, description="API key header authentication")
2025-11-14 13:34:19 +00:00
def auth_enabled() -> bool:
"""Check if authentication is enabled in config"""
return config.get('authentication', {}).get('enabled', False)
2025-11-14 13:34:19 +00:00
def get_api_key() -> str:
"""Get the configured API key (empty string if not set)"""
return config.get('authentication', {}).get('api_key', '').strip()
def verify_api_key(provided_key: str) -> bool:
"""
Verify an API key using constant-time comparison.
Uses secrets.compare_digest to prevent timing attacks where an attacker
could determine the correct key by measuring response times.
Args:
provided_key: The API key provided in the request
Returns:
True if the key is valid, False otherwise
"""
configured_key = get_api_key()
# No API key configured = API key auth disabled
if not configured_key:
return False
# Empty provided key is always invalid
if not provided_key:
return False
# Constant-time comparison to prevent timing attacks
try:
return secrets.compare_digest(provided_key.encode('utf-8'), configured_key.encode('utf-8'))
except Exception:
return False
async def require_auth(
request: Request,
bearer_credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
x_api_key: Optional[str] = Depends(api_key_header)
):
"""
Dependency to require authentication on protected routes.
Supports two authentication methods:
1. Session-based auth (web UI login with password)
2. API key auth (for external integrations like MCP servers)
API key can be provided via:
- Authorization: Bearer YOUR_API_KEY
- X-API-Key: YOUR_API_KEY
Raises:
HTTPException: 401 if authentication fails
"""
2025-11-14 13:34:19 +00:00
if not auth_enabled():
return # Auth disabled, allow all
# Method 1: Check Bearer token (parsed by FastAPI's HTTPBearer)
if bearer_credentials and verify_api_key(bearer_credentials.credentials):
return # Valid Bearer token - authenticated
# Method 2: Check X-API-Key header (parsed by FastAPI's APIKeyHeader)
if x_api_key and verify_api_key(x_api_key):
return # Valid API key header - authenticated
# Method 3: Check session-based authentication (web UI)
if request.session.get('authenticated'):
return # Valid session - authenticated
# No valid authentication method - deny access
raise HTTPException(status_code=401, detail="Not authenticated")
2025-11-14 13:34:19 +00:00
def verify_password(password: str) -> bool:
"""Verify password against stored hash"""
password_hash = config.get('authentication', {}).get('password_hash', '')
2025-11-14 13:34:19 +00:00
if not password_hash:
return False
try:
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
except Exception as e:
2026-06-30 14:13:01 +00:00
logger.error("Password verification error: %s", e)
2025-11-14 13:34:19 +00:00
return False
# ============================================================================
# Authentication Routes
# ============================================================================
2026-01-19 10:38:36 +00:00
@app.get("/login", response_class=HTMLResponse, include_in_schema=False)
2025-11-14 13:34:19 +00:00
async def login_page(request: Request, error: str = None):
"""Serve the login page"""
if not auth_enabled():
return RedirectResponse(url="/", status_code=303)
# If already authenticated, redirect to home
if request.session.get('authenticated'):
return RedirectResponse(url="/", status_code=303)
# Serve login page
login_path = static_path / "login.html"
async with aiofiles.open(login_path, 'r', encoding='utf-8') as f:
content = await f.read()
2026-01-05 10:05:08 +00:00
# Inject app name throughout the login page
app_name = config['app']['name']
content = content.replace('NoteDiscovery', app_name)
2025-11-14 13:34:19 +00:00
return content
2026-01-19 10:38:36 +00:00
@app.post("/login", include_in_schema=False)
2025-11-14 13:34:19 +00:00
async def login(request: Request, password: str = Form(...)):
"""Handle login form submission"""
if not auth_enabled():
return RedirectResponse(url="/", status_code=303)
# Verify password
if verify_password(password):
2025-11-25 16:56:11 +00:00
# Session regeneration: Clear old session to prevent session fixation attacks
# This forces the creation of a new session ID after successful authentication
request.session.clear()
# Set authenticated flag in the NEW session
2025-11-14 13:34:19 +00:00
request.session['authenticated'] = True
return RedirectResponse(url="/", status_code=303)
else:
2025-12-15 17:18:06 +00:00
# Redirect back to login with error code (frontend will translate)
return RedirectResponse(url="/login?error=incorrect_password", status_code=303)
2025-11-14 13:34:19 +00:00
2026-01-19 10:38:36 +00:00
@app.get("/logout", include_in_schema=False)
2025-11-14 13:34:19 +00:00
async def logout(request: Request):
"""Log out the current user"""
request.session.clear()
return RedirectResponse(url="/login", status_code=303)
# ============================================================================
2025-11-14 16:42:25 +00:00
# Routers with Authentication
2025-11-14 13:34:19 +00:00
# ============================================================================
2025-11-14 16:42:25 +00:00
# Create API router with authentication dependency applied globally
api_router = APIRouter(
prefix="/api",
dependencies=[Depends(require_auth)] # Apply auth to ALL routes in this router
)
# Create pages router with authentication dependency applied globally
pages_router = APIRouter(
dependencies=[Depends(require_auth)] # Apply auth to ALL routes in this router
)
# ============================================================================
# Application Routes (with auth via router dependencies)
# ============================================================================
2026-01-15 10:51:36 +00:00
@api_router.get("/config", tags=["System"])
2025-11-05 16:48:41 +00:00
async def get_config():
"""Get app configuration for frontend"""
return {
"name": config['app']['name'],
"version": config['app']['version'],
2025-11-14 13:34:19 +00:00
"searchEnabled": config['search']['enabled'],
"demoMode": DEMO_MODE, # Expose demo mode flag to frontend
2026-01-22 09:43:47 +00:00
"alreadyDonated": ALREADY_DONATED, # Hide support buttons if true
2026-06-08 09:02:05 +00:00
"autosaveDelayMs": AUTOSAVE_DELAY_MS, # Debounce for note/drawing autosave
"authentication": {
"enabled": config.get('authentication', {}).get('enabled', False)
2025-11-14 13:34:19 +00:00
}
2025-11-05 16:48:41 +00:00
}
2026-01-15 10:51:36 +00:00
@api_router.get("/themes", tags=["Themes"])
2025-11-05 16:48:41 +00:00
async def list_themes():
"""Get all available themes"""
themes_dir = Path(__file__).parent.parent / "themes"
themes = get_available_themes(str(themes_dir))
return {"themes": themes}
2026-01-19 10:38:36 +00:00
@app.get("/api/themes/{theme_id}", tags=["Themes"]) # Don't use the router here, as we want this route unsecured
2025-11-05 16:48:41 +00:00
async def get_theme(theme_id: str):
"""Get CSS for a specific theme"""
themes_dir = Path(__file__).parent.parent / "themes"
css = get_theme_css(str(themes_dir), theme_id)
if not css:
raise HTTPException(status_code=404, detail="Theme not found")
return {"css": css, "theme_id": theme_id}
# Locales endpoints (unauthenticated - needed for login page and initial load)
2026-01-19 10:38:36 +00:00
@app.get("/api/locales", tags=["Locales"])
async def get_available_locales():
"""Get list of available locales"""
import json
locales_dir = Path(__file__).parent.parent / "locales"
locales = []
if locales_dir.exists():
for file in sorted(locales_dir.glob("*.json")):
try:
with open(file, 'r', encoding='utf-8') as f:
data = json.load(f)
meta = data.get('_meta', {})
locales.append({
"code": meta.get('code', file.stem),
"name": meta.get('name', file.stem),
"flag": meta.get('flag', '🌐')
})
except (json.JSONDecodeError, IOError):
# Skip invalid locale files
continue
return {"locales": locales}
2026-01-19 10:38:36 +00:00
@app.get("/api/locales/{locale_code}", tags=["Locales"])
async def get_locale(locale_code: str):
"""Get translations for a specific locale"""
import json
2026-03-13 10:58:45 +00:00
import re
# Security: Validate locale_code to prevent path traversal
# Only allow alphanumeric, hyphens, and underscores (e.g., "en", "pt-BR", "zh_CN")
if not re.match(r'^[a-zA-Z0-9_-]+$', locale_code):
raise HTTPException(status_code=400, detail="Invalid locale code")
locales_dir = Path(__file__).parent.parent / "locales"
locale_file = locales_dir / f"{locale_code}.json"
2026-03-13 10:58:45 +00:00
# Security: Ensure resolved path is still within locales directory
if not locale_file.resolve().is_relative_to(locales_dir.resolve()):
raise HTTPException(status_code=400, detail="Invalid locale code")
if not locale_file.exists():
raise HTTPException(status_code=404, detail="Locale not found")
try:
with open(locale_file, 'r', encoding='utf-8') as f:
translations = json.load(f)
return translations
except (json.JSONDecodeError, IOError) as e:
raise HTTPException(status_code=500, detail=f"Failed to load locale: {str(e)}")
2026-01-15 10:51:36 +00:00
@api_router.post("/folders", tags=["Folders"])
2025-11-25 14:26:58 +00:00
@limiter.limit("30/minute")
async def create_new_folder(request: Request, data: dict):
2025-11-05 16:48:41 +00:00
"""Create a new folder"""
try:
folder_path = data.get('path', '')
if not folder_path:
raise HTTPException(status_code=400, detail="Folder path required")
success = create_folder(config['storage']['notes_dir'], folder_path)
if not success:
raise HTTPException(status_code=500, detail="Failed to create folder")
return {
"success": True,
"path": folder_path,
"message": "Folder created successfully"
}
except HTTPException:
raise
2025-11-05 16:48:41 +00:00
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create folder"))
2025-11-20 13:10:17 +00:00
2026-01-18 15:00:01 +00:00
@api_router.get("/media/{media_path:path}", tags=["Media"])
async def get_media(media_path: str):
"""
2026-01-18 15:00:01 +00:00
Serve a media file (image, audio, video, PDF) with authentication protection.
"""
try:
2026-01-18 15:00:01 +00:00
from backend.utils import ALL_MEDIA_EXTENSIONS
notes_dir = config['storage']['notes_dir']
2026-01-18 15:00:01 +00:00
full_path = Path(notes_dir) / media_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")
2026-01-18 15:00:01 +00:00
# Check file exists
if not full_path.exists() or not full_path.is_file():
2026-01-18 15:00:01 +00:00
raise HTTPException(status_code=404, detail="File not found")
2026-01-18 15:00:01 +00:00
# Validate it's an allowed media file
if full_path.suffix.lower() not in ALL_MEDIA_EXTENSIONS:
raise HTTPException(status_code=400, detail="Not an allowed media file")
# Return the file
return FileResponse(full_path)
except HTTPException:
raise
except Exception as e:
2026-01-18 15:00:01 +00:00
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load media file"))
2026-04-21 13:22:09 +00:00
@api_router.put("/media/{media_path:path}", tags=["Media"])
2026-04-21 16:00:50 +00:00
@limiter.limit("120/minute")
2026-04-21 13:22:09 +00:00
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",
)
2026-04-21 16:00:50 +00:00
# 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")
2026-04-21 13:22:09 +00:00
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"))
2026-01-18 15:00:01 +00:00
@api_router.post("/upload-media", tags=["Media"])
2025-11-25 14:26:58 +00:00
@limiter.limit("20/minute")
2026-04-21 14:00:58 +00:00
async def upload_media(
request: Request,
file: UploadFile = File(...),
note_path: str = Form(""),
content_folder: str = Form(""),
next_to_notes: str = Form(""),
):
2025-11-20 13:10:17 +00:00
"""
2026-04-21 14:00:58 +00:00
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.
2026-01-18 15:00:01 +00:00
Returns the relative path for markdown linking.
2025-11-20 13:10:17 +00:00
"""
try:
2026-01-18 15:00:01 +00:00
from backend.utils import ALL_MEDIA_EXTENSIONS, get_media_type
# Allowed MIME types for each category
allowed_types = {
# Images
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
# Audio
'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/x-m4a',
# Video
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo',
# Documents
'application/pdf',
}
2025-11-20 13:10:17 +00:00
# Get file extension
file_ext = Path(file.filename).suffix.lower() if file.filename else ''
2026-01-18 15:00:01 +00:00
if file.content_type not in allowed_types and file_ext not in ALL_MEDIA_EXTENSIONS:
2025-11-20 13:10:17 +00:00
raise HTTPException(
status_code=400,
2026-01-18 15:00:01 +00:00
detail=f"Invalid file type. Allowed: images, audio (mp3), video (mp4), PDF. Got: {file.content_type}"
2025-11-20 13:10:17 +00:00
)
# Read file data
file_data = await file.read()
2026-01-18 15:00:01 +00:00
# Validate file size - different limits for different types
media_type = get_media_type(file.filename) if file.filename else None
2026-04-06 13:23:30 +00:00
# Size limits (configurable via UPLOAD_MAX_*_MB environment variables)
2026-01-18 15:00:01 +00:00
size_limits = {
2026-04-06 13:23:30 +00:00
'image': UPLOAD_MAX_IMAGE_MB * 1024 * 1024,
'audio': UPLOAD_MAX_AUDIO_MB * 1024 * 1024,
'video': UPLOAD_MAX_VIDEO_MB * 1024 * 1024,
'document': UPLOAD_MAX_PDF_MB * 1024 * 1024,
2026-01-18 15:00:01 +00:00
}
2026-04-06 13:23:30 +00:00
max_size = size_limits.get(media_type, UPLOAD_MAX_IMAGE_MB * 1024 * 1024)
2026-01-18 15:00:01 +00:00
2025-11-20 13:10:17 +00:00
if len(file_data) > max_size:
raise HTTPException(
status_code=400,
2026-01-18 15:00:01 +00:00
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"
2025-11-20 13:10:17 +00:00
)
2026-04-21 14:00:58 +00:00
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",
}
2026-01-18 15:00:01 +00:00
# Save the file (reusing image save function - it works for any file)
file_path = save_uploaded_image(
2025-11-20 13:10:17 +00:00
config['storage']['notes_dir'],
note_path,
file.filename,
file_data
)
2026-01-18 15:00:01 +00:00
if not file_path:
raise HTTPException(status_code=500, detail="Failed to save file")
2025-11-20 13:10:17 +00:00
return {
"success": True,
2026-01-18 15:00:01 +00:00
"path": file_path,
"filename": Path(file_path).name,
"type": media_type,
"message": f"{media_type.capitalize() if media_type else 'File'} uploaded successfully"
2025-11-20 13:10:17 +00:00
}
except HTTPException:
raise
except Exception as e:
2026-01-18 15:00:01 +00:00
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to upload file"))
2025-11-05 16:48:41 +00:00
2026-01-19 08:42:32 +00:00
@api_router.post("/media/move", tags=["Media"])
@limiter.limit("30/minute")
async def move_media_endpoint(request: Request, data: dict):
"""Move a media file to a different folder"""
try:
from backend.utils import ALL_MEDIA_EXTENSIONS
old_path = data.get('oldPath', '')
new_path = data.get('newPath', '')
if not old_path or not new_path:
raise HTTPException(status_code=400, detail="Both oldPath and newPath required")
notes_dir = config['storage']['notes_dir']
old_full_path = Path(notes_dir) / old_path
new_full_path = Path(notes_dir) / new_path
# Security: Validate paths are within notes directory
if not validate_path_security(notes_dir, old_full_path):
raise HTTPException(status_code=403, detail="Invalid source path")
if not validate_path_security(notes_dir, new_full_path):
raise HTTPException(status_code=403, detail="Invalid destination path")
# Validate it's a media file
if old_full_path.suffix.lower() not in ALL_MEDIA_EXTENSIONS:
raise HTTPException(status_code=400, detail="Not a valid media file")
# Check source exists
if not old_full_path.exists():
raise HTTPException(status_code=404, detail=f"Media file not found: {old_path}")
# Check target doesn't exist
if new_full_path.exists():
raise HTTPException(status_code=409, detail=f"A file already exists at: {new_path}")
# Create parent directory if needed
new_full_path.parent.mkdir(parents=True, exist_ok=True)
# Move the file
import shutil
shutil.move(str(old_full_path), str(new_full_path))
return {"success": True, "message": "Media moved successfully", "newPath": new_path}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move media file"))
2026-01-15 10:51:36 +00:00
@api_router.post("/notes/move", tags=["Notes"])
2025-11-25 14:26:58 +00:00
@limiter.limit("30/minute")
async def move_note_endpoint(request: Request, data: dict):
2025-11-05 16:48:41 +00:00
"""Move a note to a different folder"""
try:
old_path = data.get('oldPath', '')
new_path = data.get('newPath', '')
if not old_path or not new_path:
raise HTTPException(status_code=400, detail="Both oldPath and newPath required")
2025-12-15 11:18:14 +00:00
success, error_msg = move_note(config['storage']['notes_dir'], old_path, new_path)
2025-11-05 16:48:41 +00:00
if not success:
2025-12-15 11:18:14 +00:00
raise HTTPException(status_code=400, detail=error_msg or "Failed to move note")
2025-11-05 16:48:41 +00:00
2026-01-15 09:07:51 +00:00
# Update share token path if note was shared
update_token_path(config['storage']['notes_dir'], old_path, new_path)
2025-11-05 16:48:41 +00:00
# Run plugin hooks
plugin_manager.run_hook('on_note_save', note_path=new_path, content='')
return {
"success": True,
"oldPath": old_path,
"newPath": new_path,
"message": "Note moved successfully"
}
except HTTPException:
raise
2025-11-05 16:48:41 +00:00
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move note"))
2025-11-05 16:48:41 +00:00
2026-01-15 10:51:36 +00:00
@api_router.post("/folders/move", tags=["Folders"])
2025-11-25 14:26:58 +00:00
@limiter.limit("20/minute")
async def move_folder_endpoint(request: Request, data: dict):
2025-11-05 16:48:41 +00:00
"""Move a folder to a different location"""
try:
old_path = data.get('oldPath', '')
new_path = data.get('newPath', '')
if not old_path or not new_path:
raise HTTPException(status_code=400, detail="Both oldPath and newPath required")
2025-12-15 11:18:14 +00:00
success, error_msg = move_folder(config['storage']['notes_dir'], old_path, new_path)
2025-11-05 16:48:41 +00:00
if not success:
2025-12-15 11:18:14 +00:00
raise HTTPException(status_code=400, detail=error_msg or "Failed to move folder")
2025-11-05 16:48:41 +00:00
return {
"success": True,
"oldPath": old_path,
"newPath": new_path,
"message": "Folder moved successfully"
}
except HTTPException:
raise
2025-11-05 16:48:41 +00:00
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move folder"))
2025-11-05 16:48:41 +00:00
2026-01-15 10:51:36 +00:00
@api_router.post("/folders/rename", tags=["Folders"])
2025-11-25 14:26:58 +00:00
@limiter.limit("30/minute")
async def rename_folder_endpoint(request: Request, data: dict):
2025-11-05 16:48:41 +00:00
"""Rename a folder"""
try:
old_path = data.get('oldPath', '')
new_path = data.get('newPath', '')
if not old_path or not new_path:
raise HTTPException(status_code=400, detail="Both oldPath and newPath required")
2025-12-15 11:18:14 +00:00
success, error_msg = rename_folder(config['storage']['notes_dir'], old_path, new_path)
2025-11-05 16:48:41 +00:00
if not success:
2025-12-15 11:18:14 +00:00
raise HTTPException(status_code=400, detail=error_msg or "Failed to rename folder")
2025-11-05 16:48:41 +00:00
return {
"success": True,
"oldPath": old_path,
"newPath": new_path,
"message": "Folder renamed successfully"
}
except HTTPException:
raise
2025-11-05 16:48:41 +00:00
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to rename folder"))
2025-11-05 16:48:41 +00:00
2026-01-15 10:51:36 +00:00
@api_router.delete("/folders/{folder_path:path}", tags=["Folders"])
2025-11-25 14:26:58 +00:00
@limiter.limit("20/minute")
async def delete_folder_endpoint(request: Request, folder_path: str):
2025-11-05 16:48:41 +00:00
"""Delete a folder and all its contents"""
try:
if not folder_path:
raise HTTPException(status_code=400, detail="Folder path required")
success = delete_folder(config['storage']['notes_dir'], folder_path)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete folder")
return {
"success": True,
"path": folder_path,
"message": "Folder deleted successfully"
}
except HTTPException:
raise
2025-11-05 16:48:41 +00:00
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to delete folder"))
2025-11-05 16:48:41 +00:00
2025-11-24 16:11:22 +00:00
# --- Tags Endpoints ---
2026-01-15 10:51:36 +00:00
@api_router.get("/tags", tags=["Tags"])
2025-11-24 16:11:22 +00:00
async def list_tags():
"""
Get all tags used across all notes with their counts.
Returns:
Dictionary mapping tag names to note counts
"""
try:
tags = get_all_tags(config['storage']['notes_dir'])
return {"tags": tags}
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load tags"))
2025-11-24 16:11:22 +00:00
2026-01-15 10:51:36 +00:00
@api_router.get("/tags/{tag_name}", tags=["Tags"])
async def get_notes_by_tag_endpoint(
tag_name: str,
limit: Optional[int] = None,
offset: int = 0
):
2025-11-24 16:11:22 +00:00
"""
Get all notes that have a specific tag with optional pagination.
2025-11-24 16:11:22 +00:00
Args:
tag_name: The tag to filter by (case-insensitive)
limit: Maximum number of notes to return (optional, no default limit)
offset: Number of notes to skip (default: 0)
2025-11-24 16:11:22 +00:00
Returns:
List of notes matching the tag
Examples:
GET /api/tags/docker -> All notes with #docker tag
GET /api/tags/docker?limit=10 -> First 10 notes with #docker tag
2025-11-24 16:11:22 +00:00
"""
try:
notes = get_notes_by_tag(config['storage']['notes_dir'], tag_name)
# Apply pagination with consistent sorting by path
paginated = paginate(
items=notes,
limit=limit,
offset=offset,
sort_key=lambda x: x.get('path', '').lower()
)
response = {
2025-11-24 16:11:22 +00:00
"tag": tag_name,
"count": paginated.total,
"notes": paginated.items
2025-11-24 16:11:22 +00:00
}
# Include pagination metadata only when limit is specified
if limit is not None:
response["pagination"] = paginated.to_dict()
return response
2025-11-24 16:11:22 +00:00
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get notes by tag"))
2025-11-24 16:11:22 +00:00
2025-11-26 10:28:09 +00:00
# --- Template Endpoints ---
2026-01-15 10:51:36 +00:00
@api_router.get("/templates", tags=["Templates"])
@limiter.limit("120/minute")
async def list_templates(request: Request):
2025-11-26 10:28:09 +00:00
"""
List all available templates from _templates folder.
Returns:
List of template metadata
"""
try:
templates = get_templates(config['storage']['notes_dir'])
return {"templates": templates}
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to list templates"))
2026-01-15 10:51:36 +00:00
@api_router.get("/templates/{template_name}", tags=["Templates"])
@limiter.limit("120/minute")
async def get_template(request: Request, template_name: str):
2025-11-26 10:28:09 +00:00
"""
Get content of a specific template.
Args:
template_name: Name of the template (without .md extension)
Returns:
Template name and content
"""
try:
content = get_template_content(config['storage']['notes_dir'], template_name)
if content is None:
raise HTTPException(status_code=404, detail="Template not found")
return {
"name": template_name,
"content": content
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get template"))
2026-01-15 10:51:36 +00:00
@api_router.post("/templates/create-note", tags=["Templates"])
2025-11-26 10:28:09 +00:00
@limiter.limit("60/minute")
async def create_note_from_template(request: Request, data: dict):
"""
Create a new note from a template with placeholder replacement.
Args:
data: Dictionary containing templateName and notePath
Returns:
Success status, path, and created content
"""
try:
template_name = data.get('templateName', '')
note_path = data.get('notePath', '')
if not template_name or not note_path:
raise HTTPException(status_code=400, detail="Template name and note path required")
# Get template content
template_content = get_template_content(config['storage']['notes_dir'], template_name)
if template_content is None:
raise HTTPException(status_code=404, detail="Template not found")
# Apply placeholder replacements
final_content = apply_template_placeholders(template_content, note_path)
# Run on_note_create hook BEFORE saving (allows plugins to modify initial content)
final_content = plugin_manager.run_hook_with_return(
'on_note_create',
note_path=note_path,
initial_content=final_content
)
# Run on_note_save hook (can transform content, e.g., encrypt)
transformed_content = plugin_manager.run_hook('on_note_save', note_path=note_path, content=final_content)
if transformed_content is None:
transformed_content = final_content
# Save the note with the (potentially modified/transformed) content
success = save_note(config['storage']['notes_dir'], note_path, transformed_content)
2025-11-26 10:28:09 +00:00
if not success:
raise HTTPException(status_code=500, detail="Failed to create note from template")
return {
"success": True,
"path": note_path,
"message": "Note created from template successfully",
"content": final_content
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create note from template"))
2025-11-24 16:11:22 +00:00
# --- Notes Endpoints ---
2026-01-15 10:51:36 +00:00
@api_router.get("/notes", tags=["Notes"])
async def list_notes(
limit: Optional[int] = None,
offset: int = 0
):
"""
List all notes with metadata.
Supports optional pagination for API consumers (MCP, scripts):
- No parameters: Returns all notes (frontend compatibility)
- With limit: Returns paginated results with metadata
Args:
limit: Maximum number of notes to return (optional, no default limit)
offset: Number of notes to skip (default: 0)
Examples:
GET /api/notes -> All notes
GET /api/notes?limit=20 -> First 20 notes
GET /api/notes?limit=20&offset=20 -> Notes 21-40
"""
2025-11-05 16:48:41 +00:00
try:
2026-02-20 18:41:51 +00:00
notes, folders = scan_notes_fast_walk(config['storage']['notes_dir'], include_media=True)
# Apply pagination with consistent sorting by path for stable results
result = paginate(
items=notes,
limit=limit,
offset=offset,
sort_key=lambda x: x.get('path', '').lower()
)
response = {
"notes": result.items,
"folders": folders
}
# Include pagination metadata only when limit is specified
if limit is not None:
response["pagination"] = result.to_dict()
return response
2025-11-05 16:48:41 +00:00
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to list notes"))
2025-11-05 16:48:41 +00:00
2026-01-15 10:51:36 +00:00
@api_router.get("/notes/{note_path:path}", tags=["Notes"])
2026-03-24 09:58:32 +00:00
async def get_note(note_path: str, include_backlinks: bool = True):
"""Get a specific note's content with optional backlinks"""
2025-11-05 16:48:41 +00:00
try:
content = get_note_content(config['storage']['notes_dir'], note_path)
if content is None:
raise HTTPException(status_code=404, detail="Note not found")
2026-03-24 09:58:32 +00:00
2025-11-05 16:48:41 +00:00
# Run on_note_load hook (can transform content, e.g., decrypt)
transformed_content = plugin_manager.run_hook('on_note_load', note_path=note_path, content=content)
if transformed_content is not None:
content = transformed_content
2026-03-24 09:58:32 +00:00
response = {
2025-11-05 16:48:41 +00:00
"path": note_path,
"content": content,
"metadata": create_note_metadata(config['storage']['notes_dir'], note_path)
}
2026-03-24 09:58:32 +00:00
if include_backlinks:
response["backlinks"] = get_backlinks(config['storage']['notes_dir'], note_path)
return response
2025-11-05 16:48:41 +00:00
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load note"))
2025-11-05 16:48:41 +00:00
2026-01-15 10:51:36 +00:00
@api_router.post("/notes/{note_path:path}", tags=["Notes"])
2025-11-25 14:26:58 +00:00
@limiter.limit("60/minute")
async def create_or_update_note(request: Request, note_path: str, content: dict):
2025-11-05 16:48:41 +00:00
"""Create or update a note"""
try:
note_content = content.get('content', '')
# Check if this is a new note (doesn't exist yet)
existing_content = get_note_content(config['storage']['notes_dir'], note_path)
is_new_note = existing_content is None
# If creating a new note, run on_note_create hook to allow plugins to modify initial content
if is_new_note:
note_content = plugin_manager.run_hook_with_return(
'on_note_create',
note_path=note_path,
initial_content=note_content
)
# Run on_note_save hook (can transform content, e.g., encrypt)
transformed_content = plugin_manager.run_hook('on_note_save', note_path=note_path, content=note_content)
if transformed_content is None:
transformed_content = note_content
success = save_note(config['storage']['notes_dir'], note_path, transformed_content)
if not success:
raise HTTPException(status_code=500, detail="Failed to save note")
return {
"success": True,
"path": note_path,
"message": "Note created successfully" if is_new_note else "Note saved successfully",
"content": note_content # Return the (potentially modified) content
}
except HTTPException:
raise
2025-11-05 16:48:41 +00:00
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to save note"))
2025-11-05 16:48:41 +00:00
@api_router.patch("/notes/{note_path:path}", tags=["Notes"])
@limiter.limit("60/minute")
async def append_to_note(request: Request, note_path: str, data: dict):
"""
Append content to an existing note without overwriting.
Perfect for journals, logs, or collecting ideas incrementally.
Args:
note_path: Path to the note
data: Dictionary with 'content' to append and optional 'add_timestamp' boolean
"""
try:
content_to_append = data.get('content', '')
add_timestamp = data.get('add_timestamp', False)
if not content_to_append:
raise HTTPException(status_code=400, detail="Content to append is required")
# Get existing content
existing_content = get_note_content(config['storage']['notes_dir'], note_path)
if existing_content is None:
raise HTTPException(status_code=404, detail="Note not found")
# Build the appended content
if add_timestamp:
from datetime import datetime
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
content_to_append = f"\n\n---\n\n**{timestamp}**\n\n{content_to_append}"
else:
content_to_append = f"\n\n{content_to_append}"
new_content = existing_content + content_to_append
# Run on_note_save hook
transformed_content = plugin_manager.run_hook('on_note_save', note_path=note_path, content=new_content)
if transformed_content is None:
transformed_content = new_content
success = save_note(config['storage']['notes_dir'], note_path, transformed_content)
if not success:
raise HTTPException(status_code=500, detail="Failed to append to note")
return {
"success": True,
"path": note_path,
"message": "Content appended successfully"
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to append to note"))
2026-01-15 10:51:36 +00:00
@api_router.delete("/notes/{note_path:path}", tags=["Notes"])
2025-11-25 14:26:58 +00:00
@limiter.limit("30/minute")
async def remove_note(request: Request, note_path: str):
2025-11-05 16:48:41 +00:00
"""Delete a note"""
try:
success = delete_note(config['storage']['notes_dir'], note_path)
if not success:
raise HTTPException(status_code=404, detail="Note not found")
2026-01-15 09:07:51 +00:00
# Clean up any share token for this note
delete_token_for_note(config['storage']['notes_dir'], note_path)
2025-11-05 16:48:41 +00:00
# Run plugin hooks
plugin_manager.run_hook('on_note_delete', note_path=note_path)
return {
"success": True,
"message": "Note deleted successfully"
}
except HTTPException:
raise
2025-11-05 16:48:41 +00:00
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to delete note"))
2025-11-05 16:48:41 +00:00
@api_router.get("/export/{note_path:path}", tags=["Export"])
@limiter.limit("30/minute")
2026-04-06 10:58:38 +00:00
async def export_note_to_html(request: Request, note_path: str, theme: Optional[str] = None, download: bool = True):
"""
Export a note as a standalone HTML file.
2026-04-06 10:58:38 +00:00
The HTML includes all necessary CSS, MathJax, Mermaid, and syntax highlighting
for offline viewing. Images are embedded as base64.
2026-04-06 10:58:38 +00:00
Query Parameters:
theme: Optional theme name (defaults to current theme or 'light')
2026-04-06 10:58:38 +00:00
download: If true (default), returns as file download. If false, displays in browser with print button.
Returns:
2026-04-06 10:58:38 +00:00
HTML file (download or inline based on download parameter)
"""
try:
notes_dir = Path(config['storage']['notes_dir'])
# Read note content
content = get_note_content(str(notes_dir), note_path)
if content is None:
raise HTTPException(status_code=404, detail="Note not found")
# Run on_note_load hook (can transform content, e.g., decrypt)
transformed_content = plugin_manager.run_hook('on_note_load', note_path=note_path, content=content)
if transformed_content is not None:
content = transformed_content
# 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)
# Get theme CSS
themes_dir = Path(__file__).parent.parent / "themes"
theme_name = theme or 'light'
theme_css = get_theme_css(str(themes_dir), theme_name)
if not theme_css:
theme_css = get_theme_css(str(themes_dir), "light")
theme_name = "light"
# Strip data-theme selector
theme_css = theme_css.replace(f':root[data-theme="{theme_name}"]', ':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_name.lower() or theme_name in ['dracula', 'nord', 'monokai', 'cobalt2', 'gruvbox-dark']
# Get note title
title = Path(note_path).stem
2026-04-06 10:58:38 +00:00
# Generate HTML (show print button only when not downloading)
html_content = generate_export_html(
title=title,
content=content_with_links,
theme_css=theme_css,
2026-04-06 10:58:38 +00:00
is_dark=is_dark,
show_print_button=not download
)
2026-04-06 10:58:38 +00:00
# Return as downloadable file or inline (for print preview)
if download:
filename = f"{title}.html"
return Response(
content=html_content,
media_type="text/html",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
}
)
else:
# Return inline for browser display (print preview)
return HTMLResponse(content=html_content)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to export note"))
2026-01-15 10:51:36 +00:00
@api_router.get("/search", tags=["Search"])
async def search(
q: str,
limit: Optional[int] = None,
offset: int = 0
):
"""
Search notes by content with optional pagination.
Args:
q: Search query string
limit: Maximum number of results to return (optional, no default limit)
offset: Number of results to skip (default: 0)
Examples:
GET /api/search?q=docker -> All matching results
GET /api/search?q=docker&limit=10 -> First 10 results
GET /api/search?q=docker&limit=10&offset=10 -> Results 11-20
"""
2025-11-05 16:48:41 +00:00
try:
if not config['search']['enabled']:
raise HTTPException(status_code=403, detail="Search is disabled")
2026-03-23 16:49:05 +00:00
# Handle empty query gracefully
if not q or not q.strip():
return {
"results": [],
"query": q,
"message": "No search term provided"
}
2025-11-05 16:48:41 +00:00
results = search_notes(config['storage']['notes_dir'], q)
2025-11-05 16:48:41 +00:00
# Run plugin hooks
plugin_manager.run_hook('on_search', query=q, results=results)
# Apply pagination with consistent sorting by path
paginated = paginate(
items=results,
limit=limit,
offset=offset,
sort_key=lambda x: x.get('path', '').lower()
)
response = {
"results": paginated.items,
"query": q
}
# Include pagination metadata only when limit is specified
if limit is not None:
response["pagination"] = paginated.to_dict()
2025-11-05 16:48:41 +00:00
return response
2025-11-05 16:48:41 +00:00
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Search failed"))
2025-11-05 16:48:41 +00:00
2026-01-15 10:51:36 +00:00
@api_router.get("/graph", tags=["Graph"])
2025-11-14 16:53:20 +00:00
async def get_graph():
2026-06-30 09:59:25 +00:00
"""Graph data (nodes + resolved wikilink/markdown edges) for the visualizer."""
2025-11-05 16:48:41 +00:00
try:
2026-06-30 09:59:25 +00:00
if not note_index.get_index().is_built():
scan_notes_fast_walk(config['storage']['notes_dir'], include_media=False)
nodes_paths, edges_tuples = note_index.get_graph_data()
return {
"nodes": [{"id": p, "label": Path(p).stem} for p in nodes_paths],
"edges": [{"source": s, "target": t, "type": et} for (s, t, et) in edges_tuples],
}
2025-11-05 16:48:41 +00:00
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to generate graph data"))
2025-11-05 16:48:41 +00:00
2026-01-15 10:51:36 +00:00
@api_router.get("/plugins", tags=["Plugins"])
2025-11-14 16:53:20 +00:00
async def list_plugins():
2025-11-05 16:48:41 +00:00
"""List all available plugins"""
return {"plugins": plugin_manager.list_plugins()}
2026-01-15 10:51:36 +00:00
@api_router.get("/plugins/note_stats/calculate", tags=["Plugins"])
2025-11-14 16:42:25 +00:00
async def calculate_note_stats(content: str):
2025-11-05 16:48:41 +00:00
"""Calculate statistics for note content (if plugin enabled)"""
try:
plugin = plugin_manager.plugins.get('note_stats')
if not plugin or not plugin.enabled:
return {"enabled": False, "stats": None}
stats = plugin.calculate_stats(content)
return {"enabled": True, "stats": stats}
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to calculate note statistics"))
2025-11-05 16:48:41 +00:00
2026-01-15 10:51:36 +00:00
@api_router.post("/plugins/{plugin_name}/toggle", tags=["Plugins"])
2025-11-25 14:26:58 +00:00
@limiter.limit("10/minute")
async def toggle_plugin(request: Request, plugin_name: str, enabled: dict):
2025-11-05 16:48:41 +00:00
"""Enable or disable a plugin"""
try:
is_enabled = enabled.get('enabled', False)
if is_enabled:
plugin_manager.enable_plugin(plugin_name)
else:
plugin_manager.disable_plugin(plugin_name)
return {
"success": True,
"plugin": plugin_name,
"enabled": is_enabled
}
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to toggle plugin"))
2025-11-05 16:48:41 +00:00
# ============================================================================
# Index observability — internal counters & sizes for debugging
#
# Useful for confirming the index is doing what we expect (build_count >0,
# search_terms not empty, fingerprint_short_circuits incrementing on idle
# warm scans). Not rate-limited — cheap, no I/O, dict lookups only.
# ============================================================================
@api_router.get("/index/stats", tags=["System"])
async def get_index_stats():
return note_index.stats()
2026-03-27 16:02:36 +00:00
# ============================================================================
# Stats Endpoint (for dashboards)
# ============================================================================
@api_router.get("/stats", tags=["Stats"])
@limiter.limit("30/minute")
async def get_stats(request: Request):
2026-06-30 11:47:19 +00:00
"""At-a-glance counts for dashboard widgets (Homepage etc.).
2026-03-27 16:02:36 +00:00
2026-06-30 11:47:19 +00:00
All vault aggregates are read from the in-memory index no file walk on
the request path. Templates / plugins / version are looked up directly."""
2026-03-27 16:02:36 +00:00
try:
notes_dir = config['storage']['notes_dir']
2026-06-30 11:47:19 +00:00
ensure_index_built(notes_dir)
s = note_index.summary()
templates_count = len(get_templates(notes_dir))
2026-03-27 16:02:36 +00:00
enabled_plugins = sum(1 for p in plugin_manager.plugins.values() if p.enabled)
2026-06-30 11:47:19 +00:00
2026-03-27 16:02:36 +00:00
version = "unknown"
version_file = Path(__file__).parent.parent / "VERSION"
if version_file.exists():
version = version_file.read_text().strip()
2026-06-30 11:47:19 +00:00
2026-03-27 16:02:36 +00:00
return {
2026-06-30 11:47:19 +00:00
"notes_count": s["notes_count"],
"folders_count": s["folders_count"],
"tags_count": s["tags_count"],
"templates_count": templates_count,
"media_count": s["media_count"],
"total_size_bytes": s["total_size_bytes"],
"last_modified": s["last_modified"],
2026-03-27 16:02:36 +00:00
"plugins_enabled": enabled_plugins,
2026-06-30 11:47:19 +00:00
"version": version,
2026-03-27 16:02:36 +00:00
}
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get stats"))
2026-01-15 09:07:51 +00:00
# ============================================================================
# Share Token Endpoints (authenticated)
# ============================================================================
2026-01-15 10:51:36 +00:00
@api_router.post("/share/{note_path:path}", tags=["Sharing"])
2026-01-15 09:07:51 +00:00
@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"))
2026-01-15 10:51:36 +00:00
@api_router.get("/share/{note_path:path}", tags=["Sharing"])
2026-01-15 10:31:48 +00:00
@limiter.limit("120/minute")
async def get_share_status(request: Request, note_path: str):
2026-01-15 09:07:51 +00:00
"""
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"))
2026-01-15 10:51:36 +00:00
@api_router.get("/shared-notes", tags=["Sharing"])
2026-01-15 10:31:48 +00:00
@limiter.limit("60/minute")
async def list_shared_notes(request: Request):
2026-01-15 09:07:51 +00:00
"""
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"))
2026-01-15 10:51:36 +00:00
@api_router.delete("/share/{note_path:path}", tags=["Sharing"])
2026-01-15 09:07:51 +00:00
@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)
# ============================================================================
2026-01-15 10:51:36 +00:00
@app.get("/share/{token}", response_class=HTMLResponse, tags=["Sharing"])
2026-01-15 09:07:51 +00:00
@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"))
2026-01-15 10:51:36 +00:00
@app.get("/health", tags=["System"])
2025-11-05 16:48:41 +00:00
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"app": config['app']['name'],
"version": config['app']['version']
}
# Catch-all route for SPA (Single Page Application) routing
# This allows URLs like /folder/note to work for direct navigation
2025-11-14 16:42:25 +00:00
@pages_router.get("/{full_path:path}", response_class=HTMLResponse)
2026-01-05 10:13:54 +00:00
@limiter.limit("120/minute")
2025-11-14 16:42:25 +00:00
async def catch_all(full_path: str, request: Request):
2025-11-05 16:48:41 +00:00
"""
2026-01-05 10:13:54 +00:00
Serve index.html for all non-API routes (including root /).
2025-11-05 16:48:41 +00:00
This enables client-side routing (e.g., /folder/note)
"""
# Skip if it's an API route or static file (shouldn't reach here, but just in case)
if full_path.startswith('api/') or full_path.startswith('static/'):
raise HTTPException(status_code=404, detail="Not found")
2026-01-05 10:13:54 +00:00
# Serve index.html with app name injected
2025-11-05 16:48:41 +00:00
index_path = static_path / "index.html"
async with aiofiles.open(index_path, 'r', encoding='utf-8') as f:
content = await f.read()
2026-01-05 10:05:08 +00:00
app_name = config['app']['name']
2026-01-05 10:13:54 +00:00
return content.replace('<title>NoteDiscovery</title>', f'<title>{app_name}</title>')
2025-11-05 16:48:41 +00:00
2025-11-14 16:42:25 +00:00
# ============================================================================
# Register Routers
# ============================================================================
# Register routers with the main app
# Authentication is applied via router dependencies
app.include_router(api_router)
app.include_router(pages_router)
2026-06-30 14:02:44 +00:00
# ============================================================================
# Startup warmup
# ============================================================================
2026-06-30 14:52:22 +00:00
# Pre-build the note index off the request path. Mid-warmup requests are safe
# (bulk_set is serialized and short-circuits on the fingerprint).
2026-06-30 14:02:44 +00:00
@app.on_event("startup")
def _warmup_note_index() -> None:
import threading
2026-06-30 14:13:01 +00:00
import time
2026-06-30 14:02:44 +00:00
def _build() -> None:
2026-06-30 14:13:01 +00:00
t0 = time.perf_counter()
2026-06-30 14:02:44 +00:00
try:
ensure_index_built(config['storage']['notes_dir'])
2026-06-30 14:13:01 +00:00
logger.info("Note index ready (%.2fs)", time.perf_counter() - t0)
2026-06-30 14:02:44 +00:00
except Exception as exc:
2026-06-30 14:13:01 +00:00
logger.warning("Note index build failed (will retry on first request): %s", exc)
2026-06-30 14:02:44 +00:00
threading.Thread(target=_build, name="note-index-warmup", daemon=True).start()
2025-11-05 16:48:41 +00:00
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"backend.main:app",
host=config['server']['host'],
port=config['server']['port'],
reload=config['server']['reload']
)