diff --git a/Dockerfile b/Dockerfile index 0c49b53..6a81c1f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,13 +33,16 @@ COPY generate_password.py . # Create data directory RUN mkdir -p data -# Expose port +# Expose port (default, can be overridden) EXPOSE 8000 -# Health check +# Set default port (can be overridden via environment variable) +ENV PORT=8000 + +# Health check (uses PORT env var) HEALTHCHECK --interval=60s --timeout=3s --start-period=5s --retries=3 \ - CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" + CMD python -c "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.getenv(\"PORT\", \"8000\")}/health')" -# Run the application -CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] +# Run the application (shell form to allow environment variable expansion) +CMD uvicorn backend.main:app --host 0.0.0.0 --port $PORT diff --git a/README.md b/README.md index ea09441..a42a0b9 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,10 @@ Use the pre-built image directly from GHCR - no building required! > # The documentation/ folder has app docs you can optionally mount > ``` -> **š Security Note**: Authentication is **disabled by default** with password `admin`. For testing/local use, this is fine. If exposing to a network, **change the password immediately** - see [AUTHENTICATION.md](documentation/AUTHENTICATION.md) for instructions on how to enable it. +> **š Security Note**: Authentication is **disabled by default** with password `admin`. +> - ā **Local/Testing**: Default credentials are fine +> - ā ļø **Public Network**: Change password immediately - see [AUTHENTICATION.md](documentation/AUTHENTICATION.md) +> - š **Demo Deployment**: Uses default "admin" password **Option 1: Docker Compose (Recommended)** @@ -212,6 +215,7 @@ Want to learn more? - š **[PLUGINS.md](documentation/PLUGINS.md)** - Plugin system and available plugins - š **[API.md](documentation/API.md)** - REST API documentation and examples - š **[AUTHENTICATION.md](documentation/AUTHENTICATION.md)** - Enable password protection for your instance +- š§ **[ENVIRONMENT_VARIABLES.md](documentation/ENVIRONMENT_VARIABLES.md)** - Configure settings via environment variables š” **Pro Tip:** If you clone this repository, you can mount the `documentation/` folder to view these docs inside the app: diff --git a/backend/main.py b/backend/main.py index 06694c0..8b2539e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -16,6 +16,9 @@ from typing import List, Optional import aiofiles from datetime import datetime import bcrypt +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded from .utils import ( get_all_notes, @@ -52,6 +55,25 @@ 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 + print(f"š Authentication {'ENABLED' if auth_enabled else 'DISABLED'} (from AUTHENTICATION_ENABLED env var)") +else: + print(f"š Authentication {'ENABLED' if config.get('authentication', {}).get('enabled', False) else 'DISABLED'} (from config.yaml)") + +# Allow password hash to be set via environment variable (useful for demos) +if 'AUTHENTICATION_PASSWORD_HASH' in os.environ: + config['authentication']['password_hash'] = os.getenv('AUTHENTICATION_PASSWORD_HASH') + print("š Password hash loaded from AUTHENTICATION_PASSWORD_HASH env var") + +# 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') + print("š Secret key loaded from AUTHENTICATION_SECRET_KEY env var") + # Initialize app app = FastAPI( title=config['app']['name'], @@ -59,24 +81,78 @@ app = FastAPI( version=config['app']['version'] ) -# CORS middleware for development +# CORS middleware configuration +# Use config.yaml to control allowed origins (default: ["*"] for self-hosted simplicity) +allowed_origins = config.get('server', {}).get('allowed_origins', ["*"]) app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=allowed_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) +print(f"š CORS allowed origins: {allowed_origins}") + +# ============================================================================ +# 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 + print(f"ā ļø [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 # Session middleware for authentication +# Security: Session ID is regenerated after login to prevent session fixation attacks app.add_middleware( SessionMiddleware, - secret_key=config.get('security', {}).get('secret_key', 'insecure_default_key_change_this'), - max_age=config.get('security', {}).get('session_max_age', 604800), # 7 days default - same_site='lax', - https_only=False # Set to True if using HTTPS + 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 + same_site='lax', # Prevents CSRF attacks + https_only=False # Set to True if using HTTPS in production ) +# 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') + +if DEMO_MODE: + # Enable rate limiting for demo deployments + limiter = Limiter(key_func=get_remote_address, default_limits=["200/hour"]) + app.state.limiter = limiter + app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + print("š DEMO MODE enabled - Rate limiting active") +else: + # Production/self-hosted mode - no restrictions + # 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() + # Ensure required directories exist ensure_directories(config) @@ -128,7 +204,7 @@ async def http_exception_handler(request: Request, exc: HTTPException): def auth_enabled() -> bool: """Check if authentication is enabled in config""" - return config.get('security', {}).get('enabled', False) + return config.get('authentication', {}).get('enabled', False) async def require_auth(request: Request): @@ -143,7 +219,7 @@ async def require_auth(request: Request): def verify_password(password: str) -> bool: """Verify password against stored hash""" - password_hash = config.get('security', {}).get('password_hash', '') + password_hash = config.get('authentication', {}).get('password_hash', '') if not password_hash: return False @@ -193,7 +269,11 @@ async def login(request: Request, password: str = Form(...)): # Verify password if verify_password(password): - # Set session + # 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 request.session['authenticated'] = True return RedirectResponse(url="/", status_code=303) else: @@ -409,8 +489,9 @@ async def get_config(): "tagline": config['app']['tagline'], "version": config['app']['version'], "searchEnabled": config['search']['enabled'], - "security": { - "enabled": config.get('security', {}).get('enabled', False) + "demoMode": DEMO_MODE, # Expose demo mode flag to frontend + "authentication": { + "enabled": config.get('authentication', {}).get('enabled', False) } } @@ -436,7 +517,8 @@ async def get_theme(theme_id: str): @api_router.post("/folders") -async def create_new_folder(data: dict): +@limiter.limit("30/minute") +async def create_new_folder(request: Request, data: dict): """Create a new folder""" try: folder_path = data.get('path', '') @@ -453,8 +535,10 @@ async def create_new_folder(data: dict): "path": folder_path, "message": "Folder created successfully" } + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create folder")) @api_router.get("/images/{image_path:path}") @@ -484,11 +568,12 @@ async def get_image(image_path: str): except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load image")) @api_router.post("/upload-image") -async def upload_image(file: UploadFile = File(...), note_path: str = Form(...)): +@limiter.limit("20/minute") +async def upload_image(request: Request, file: UploadFile = File(...), note_path: str = Form(...)): """ Upload an image file and save it to the attachments directory. Returns the relative path to the image for markdown linking. @@ -539,11 +624,12 @@ async def upload_image(file: UploadFile = File(...), note_path: str = Form(...)) except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to upload image")) @api_router.post("/notes/move") -async def move_note_endpoint(data: dict): +@limiter.limit("30/minute") +async def move_note_endpoint(request: Request, data: dict): """Move a note to a different folder""" try: old_path = data.get('oldPath', '') @@ -566,12 +652,15 @@ async def move_note_endpoint(data: dict): "newPath": new_path, "message": "Note moved successfully" } + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move note")) @api_router.post("/folders/move") -async def move_folder_endpoint(data: dict): +@limiter.limit("20/minute") +async def move_folder_endpoint(request: Request, data: dict): """Move a folder to a different location""" try: old_path = data.get('oldPath', '') @@ -591,12 +680,15 @@ async def move_folder_endpoint(data: dict): "newPath": new_path, "message": "Folder moved successfully" } + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move folder")) @api_router.post("/folders/rename") -async def rename_folder_endpoint(data: dict): +@limiter.limit("30/minute") +async def rename_folder_endpoint(request: Request, data: dict): """Rename a folder""" try: old_path = data.get('oldPath', '') @@ -616,12 +708,15 @@ async def rename_folder_endpoint(data: dict): "newPath": new_path, "message": "Folder renamed successfully" } + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to rename folder")) @api_router.delete("/folders/{folder_path:path}") -async def delete_folder_endpoint(folder_path: str): +@limiter.limit("20/minute") +async def delete_folder_endpoint(request: Request, folder_path: str): """Delete a folder and all its contents""" try: if not folder_path: @@ -637,8 +732,10 @@ async def delete_folder_endpoint(folder_path: str): "path": folder_path, "message": "Folder deleted successfully" } + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to delete folder")) # --- Tags Endpoints --- @@ -655,7 +752,7 @@ async def list_tags(): tags = get_all_tags(config['storage']['notes_dir']) return {"tags": tags} except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load tags")) @api_router.get("/tags/{tag_name}") @@ -677,7 +774,7 @@ async def get_notes_by_tag_endpoint(tag_name: str): "notes": notes } except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get notes by tag")) # --- Notes Endpoints --- @@ -690,7 +787,7 @@ async def list_notes(): folders = get_all_folders(config['storage']['notes_dir']) return {"notes": notes, "folders": folders} except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to list notes")) @api_router.get("/notes/{note_path:path}") @@ -714,11 +811,12 @@ async def get_note(note_path: str): except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load note")) @api_router.post("/notes/{note_path:path}") -async def create_or_update_note(note_path: str, content: dict): +@limiter.limit("60/minute") +async def create_or_update_note(request: Request, note_path: str, content: dict): """Create or update a note""" try: note_content = content.get('content', '') @@ -751,12 +849,15 @@ async def create_or_update_note(note_path: str, content: dict): "message": "Note created successfully" if is_new_note else "Note saved successfully", "content": note_content # Return the (potentially modified) content } + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to save note")) @api_router.delete("/notes/{note_path:path}") -async def remove_note(note_path: str): +@limiter.limit("30/minute") +async def remove_note(request: Request, note_path: str): """Delete a note""" try: success = delete_note(config['storage']['notes_dir'], note_path) @@ -771,8 +872,10 @@ async def remove_note(note_path: str): "success": True, "message": "Note deleted successfully" } + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to delete note")) @api_router.get("/search") @@ -791,7 +894,7 @@ async def search(q: str): except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Search failed")) @api_router.get("/graph") @@ -815,7 +918,7 @@ async def get_graph(): return {"nodes": nodes, "edges": edges} except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to generate graph data")) @api_router.get("/plugins") @@ -835,11 +938,12 @@ async def calculate_note_stats(content: str): stats = plugin.calculate_stats(content) return {"enabled": True, "stats": stats} except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to calculate note statistics")) @api_router.post("/plugins/{plugin_name}/toggle") -async def toggle_plugin(plugin_name: str, enabled: dict): +@limiter.limit("10/minute") +async def toggle_plugin(request: Request, plugin_name: str, enabled: dict): """Enable or disable a plugin""" try: is_enabled = enabled.get('enabled', False) @@ -854,7 +958,7 @@ async def toggle_plugin(plugin_name: str, enabled: dict): "enabled": is_enabled } except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to toggle plugin")) @app.get("/health") diff --git a/config.yaml b/config.yaml index 6d0f215..5e88d3e 100644 --- a/config.yaml +++ b/config.yaml @@ -10,6 +10,14 @@ server: port: 8000 reload: false # Set to true for development + # CORS (Cross-Origin Resource Sharing) configuration + # For self-hosted use, "*" is fine. For production, specify allowed domains. + # Examples: ["http://localhost:8000", "https://yourdomain.com"] + allowed_origins: ["*"] + + # Debug mode - shows detailed error messages (DISABLE in production!) + debug: false + storage: notes_dir: "./data" plugins_dir: "./plugins" @@ -17,16 +25,20 @@ storage: search: enabled: true -security: +authentication: # Authentication settings # Set enabled to true to require login enabled: false + # ā ļø SECURITY WARNING: Change these values before exposing to the internet! + # Default values below are for LOCAL TESTING ONLY + # Session secret key - CHANGE THIS TO A RANDOM STRING! # Generate with: python -c "import secrets; print(secrets.token_hex(32))" secret_key: "change_this_to_a_random_secret_key_in_production" # Password hash - Generate with: python generate_password.py + # ā ļø Default password is "admin" - CHANGE THIS for production! password_hash: "$2b$12$t/6PGExFzdpU2PUta0iVY.eDQwvu63kH.c/d4bEnnHaQ5CspH1yrG" # Default: "admin" # Session expiry in seconds (default: 7 days) diff --git a/documentation/AUTHENTICATION.md b/documentation/AUTHENTICATION.md index fc3375a..dd09be4 100644 --- a/documentation/AUTHENTICATION.md +++ b/documentation/AUTHENTICATION.md @@ -102,10 +102,10 @@ python -c "import secrets; print(secrets.token_hex(32))" ### Step 3: Update `config.yaml` -Edit your `config.yaml` and update the security section: +Edit your `config.yaml` and update the authentication section: ```yaml -security: +authentication: # Enable authentication enabled: true @@ -172,7 +172,7 @@ This is a **simple authentication system** designed for **self-hosted, single-us To disable authentication and allow open access: ```yaml -security: +authentication: enabled: false ``` diff --git a/documentation/ENVIRONMENT_VARIABLES.md b/documentation/ENVIRONMENT_VARIABLES.md new file mode 100644 index 0000000..4b32ef2 --- /dev/null +++ b/documentation/ENVIRONMENT_VARIABLES.md @@ -0,0 +1,92 @@ +# š§ Environment Variables + +NoteDiscovery supports environment variables to override configuration settings, allowing different behavior in different deployment environments (local, staging, production). + +## š Available Environment Variables + +### Core Settings + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `PORT` | integer | `8000` | HTTP port for the application (Docker, run.py) | + +> **Note**: Advanced server settings (CORS origins, debug mode) are configured via `config.yaml` only, not via environment variables. See [config.yaml](#advanced-server-configuration) for details. + +### Authentication + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `AUTHENTICATION_ENABLED` | boolean | `config.yaml` | Enable/disable authentication | +| `AUTHENTICATION_PASSWORD_HASH` | string | `config.yaml` | Bcrypt password hash | +| `AUTHENTICATION_SECRET_KEY` | string | `config.yaml` | Session secret key (for session security) | + +### Demo Mode + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `DEMO_MODE` | boolean | `false` | Enable demo mode (enables rate limiting and other demo restrictions) | + +## šÆ Configuration Priority + +Configuration is loaded in this order (later overrides earlier): + +1. **`config.yaml`** - Default configuration file +2. **Environment Variables** - Runtime overrides +3. **Command Line** - Highest priority (if applicable) + +## š§ Advanced Server Configuration + +The following settings are available in `config.yaml` only (not via environment variables): + +### CORS (Cross-Origin Resource Sharing) + +```yaml +server: + # List of allowed origins for CORS + # Default: ["*"] allows all origins (fine for self-hosted) + # Production: specify your domains + allowed_origins: ["*"] + + # Examples for production: + # allowed_origins: ["http://localhost:8000", "https://yourdomain.com"] + # allowed_origins: ["https://*.yourdomain.com"] # Wildcard subdomain +``` + +**Security Note:** +- `["*"]` is **safe for self-hosted** deployments on private networks +- For **public deployments**, specify exact origins to prevent unauthorized API access +- This prevents CSRF attacks when authentication is enabled + +### Debug Mode + +```yaml +server: + # Enable detailed error messages in API responses + # Default: false (production-safe) + # Set to true for development/troubleshooting + debug: false +``` + +**ā ļø CRITICAL**: Never enable `debug: true` in production! + +When `debug: true`: +- Full error stack traces are returned to users +- Internal paths and system details are exposed +- Security vulnerabilities may be revealed + +When `debug: false` (recommended): +- Generic error messages are returned +- Full error details are logged server-side only +- Production-safe error handling + +--- + +## š Related Documentation + +- **Authentication**: [AUTHENTICATION.md](AUTHENTICATION.md) +- **API Rate Limiting**: [API.md](API.md#rate-limiting) + +--- + +**Pro Tip:** Use environment variables for **deployment-specific** settings, and `config.yaml` for **application defaults**. This keeps your configuration flexible and maintainable! šÆ + diff --git a/frontend/index.html b/frontend/index.html index 685404a..0e890c5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -754,7 +754,7 @@
-