From dfa3710dc609ee208c322df8a9bd96426c428428 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 25 Nov 2025 15:26:58 +0100 Subject: [PATCH 1/6] render hosting --- backend/main.py | 48 +++++++++++++++++++++++++++++++++++++++--------- render.yaml | 29 +++++++++++++++++++++++++++++ requirements.txt | 1 + 3 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 render.yaml diff --git a/backend/main.py b/backend/main.py index 06694c0..b542ed2 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, @@ -77,6 +80,24 @@ app.add_middleware( https_only=False # Set to True if using HTTPS ) +# Rate limiting (enabled via environment variable for demo deployments) +ENABLE_RATE_LIMITING = os.getenv('ENABLE_RATE_LIMITING', 'false').lower() == 'true' + +if ENABLE_RATE_LIMITING: + 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("āš ļø Rate limiting ENABLED (ENABLE_RATE_LIMITING=true)") +else: + # 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() + print("āœ… Rate limiting DISABLED (set ENABLE_RATE_LIMITING=true to enable)") + # Ensure required directories exist ensure_directories(config) @@ -436,7 +457,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', '') @@ -488,7 +510,8 @@ async def get_image(image_path: str): @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. @@ -543,7 +566,8 @@ async def upload_image(file: UploadFile = File(...), note_path: str = Form(...)) @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', '') @@ -571,7 +595,8 @@ async def move_note_endpoint(data: dict): @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', '') @@ -596,7 +621,8 @@ async def move_folder_endpoint(data: dict): @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', '') @@ -621,7 +647,8 @@ async def rename_folder_endpoint(data: dict): @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: @@ -718,7 +745,8 @@ async def get_note(note_path: str): @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', '') @@ -756,7 +784,8 @@ async def create_or_update_note(note_path: str, content: dict): @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) @@ -839,7 +868,8 @@ async def calculate_note_stats(content: str): @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) diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..a8c4d5c --- /dev/null +++ b/render.yaml @@ -0,0 +1,29 @@ +services: + - type: web + name: notediscovery-demo + env: docker + + # OPTION 1: Pull pre-built image from GHCR (Recommended - faster, consistent) + # Uses images built by GitHub Actions and published to ghcr.io + # Deployment: Click "Manual Deploy" in Render Dashboard to pull latest + image: + url: ghcr.io/gamosoft/notediscovery:latest + + # OPTION 2: Build from source (Fallback if GHCR unavailable) + # Comment out 'image' above and uncomment these lines to build on Render: + # dockerfilePath: ./Dockerfile + # dockerContext: . + + plan: free + region: oregon + healthCheckPath: /health + envVars: + - key: PORT + value: 8000 + - key: ENABLE_RATE_LIMITING + value: "true" + - key: DEMO_MODE + value: "true" + + # Manual deployment only + autoDeploy: false \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 6bbe856..d6f0aef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ aiofiles==23.2.1 cryptography==41.0.7 bcrypt==4.1.2 itsdangerous==2.1.2 +slowapi==0.1.9 From e0e4e457da2fd9d517107d60db17ddeec164f1e5 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 25 Nov 2025 16:41:08 +0100 Subject: [PATCH 2/6] use environment variables (breaking change). added CORS. safe exception handler --- Dockerfile | 13 +- README.md | 1 + backend/main.py | 128 ++++++-- config.yaml | 10 +- documentation/AUTHENTICATION.md | 6 +- documentation/ENVIRONMENT_VARIABLES.md | 422 +++++++++++++++++++++++++ frontend/index.html | 2 +- generate_password.py | 2 +- render.yaml | 8 +- run.py | 9 +- 10 files changed, 556 insertions(+), 45 deletions(-) create mode 100644 documentation/ENVIRONMENT_VARIABLES.md 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..79c5913 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,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 b542ed2..6233117 100644 --- a/backend/main.py +++ b/backend/main.py @@ -55,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'], @@ -62,33 +81,69 @@ 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 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 + 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', https_only=False # Set to True if using HTTPS ) -# Rate limiting (enabled via environment variable for demo deployments) -ENABLE_RATE_LIMITING = os.getenv('ENABLE_RATE_LIMITING', 'false').lower() == 'true' +# 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 ENABLE_RATE_LIMITING: +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("āš ļø Rate limiting ENABLED (ENABLE_RATE_LIMITING=true)") + 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): @@ -96,7 +151,6 @@ else: return func return decorator limiter = DummyLimiter() - print("āœ… Rate limiting DISABLED (set ENABLE_RATE_LIMITING=true to enable)") # Ensure required directories exist ensure_directories(config) @@ -149,7 +203,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): @@ -164,7 +218,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 @@ -430,8 +484,8 @@ async def get_config(): "tagline": config['app']['tagline'], "version": config['app']['version'], "searchEnabled": config['search']['enabled'], - "security": { - "enabled": config.get('security', {}).get('enabled', False) + "authentication": { + "enabled": config.get('authentication', {}).get('enabled', False) } } @@ -475,8 +529,10 @@ async def create_new_folder(request: Request, 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}") @@ -506,7 +562,7 @@ 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") @@ -562,7 +618,7 @@ async def upload_image(request: Request, file: UploadFile = File(...), note_path 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") @@ -590,8 +646,10 @@ async def move_note_endpoint(request: Request, 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") @@ -616,8 +674,10 @@ async def move_folder_endpoint(request: Request, 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") @@ -642,8 +702,10 @@ async def rename_folder_endpoint(request: Request, 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}") @@ -664,8 +726,10 @@ async def delete_folder_endpoint(request: Request, 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 --- @@ -682,7 +746,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}") @@ -704,7 +768,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 --- @@ -717,7 +781,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}") @@ -741,7 +805,7 @@ 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}") @@ -779,8 +843,10 @@ async def create_or_update_note(request: Request, 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}") @@ -800,8 +866,10 @@ async def remove_note(request: Request, 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") @@ -820,7 +888,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") @@ -844,7 +912,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") @@ -864,7 +932,7 @@ 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") @@ -884,7 +952,7 @@ async def toggle_plugin(request: Request, 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..6d57cd6 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,7 +25,7 @@ storage: search: enabled: true -security: +authentication: # Authentication settings # Set enabled to true to require login enabled: false 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..8001206 --- /dev/null +++ b/documentation/ENVIRONMENT_VARIABLES.md @@ -0,0 +1,422 @@ +# šŸ”§ 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) + +## šŸ’” Common Use Cases + +### Use Case 1: Local Development (No Auth) + +**Local setup:** +```bash +# No environment variables needed +# Uses config.yaml defaults (auth disabled) +python run.py +``` + +**`config.yaml`:** +```yaml +authentication: + enabled: false +``` + +**Result:** āœ… No authentication, quick local testing + +--- + +### Use Case 2: Public Demo (Auth + Rate Limiting) + +**Render deployment:** +```yaml +# render.yaml +envVars: + - key: DEMO_MODE + value: "true" + - key: AUTHENTICATION_ENABLED + value: "true" +``` + +**`config.yaml`:** +```yaml +authentication: + enabled: false # Overridden by AUTHENTICATION_ENABLED=true +``` + +**Result:** āœ… Demo mode enabled (rate limiting + restrictions), authentication enabled + +--- + +### Use Case 3: Private Demo (Custom Password & Secret Key) + +**Generate password hash and secret key:** +```bash +# Generate password hash +python generate_password.py +# Enter password: mysecurepassword +# Generated hash: $2b$12$... + +# Generate secret key +python -c "import secrets; print(secrets.token_hex(32))" +# Generated key: a1b2c3d4e5f6... +``` + +**Render deployment:** +```yaml +envVars: + - key: AUTHENTICATION_ENABLED + value: "true" + - key: AUTHENTICATION_PASSWORD_HASH + value: "$2b$12$..." # Your generated hash + - key: AUTHENTICATION_SECRET_KEY + value: "a1b2c3d4e5f6..." # Your generated key +``` + +**Result:** āœ… Custom password and secure session key for production demo + +--- + +### Use Case 4: Docker Compose (Local Development) + +**`docker-compose.yml`:** +```yaml +services: + notediscovery: + image: ghcr.io/gamosoft/notediscovery:latest + ports: + - "8000:8000" + environment: + - AUTHENTICATION_ENABLED=false + - DEMO_MODE=false + volumes: + - ./data:/app/data +``` + +**Result:** āœ… Local deployment without restrictions (no auth, no rate limiting) + +--- + +### Use Case 5: Custom Port + +**Run on port 3000 instead of 8000:** + +**Local (run.py):** +```bash +PORT=3000 python run.py +# → Runs on http://localhost:3000 +``` + +**Docker:** +```bash +docker run -p 3000:3000 -e PORT=3000 ghcr.io/gamosoft/notediscovery:latest +``` + +**Docker Compose:** +```yaml +services: + notediscovery: + image: ghcr.io/gamosoft/notediscovery:latest + ports: + - "3000:3000" # Host:Container + environment: + - PORT=3000 +``` + +**Result:** āœ… App runs on custom port + +--- + +## šŸ” Authentication Examples + +### Example 1: Enable Auth via Environment + +**Without environment variables:** +```bash +# Uses config.yaml defaults +šŸ” Authentication DISABLED (from config.yaml) +āœ… Production mode - Rate limiting disabled +``` + +**With environment variables (demo mode):** +```bash +export DEMO_MODE=true +export AUTHENTICATION_ENABLED=true +python run.py + +# Output: +šŸŽ­ DEMO MODE enabled - Rate limiting active +šŸ” Authentication ENABLED (from AUTHENTICATION_ENABLED env var) +``` + +### Example 2: Demo Deployment + +**Public demo with restrictions:** +```bash +export DEMO_MODE=true +export AUTHENTICATION_ENABLED=true +export AUTHENTICATION_PASSWORD_HASH='$2b$12$custom_hash' +export AUTHENTICATION_SECRET_KEY='your_random_secret_key' +python run.py + +# Output: +šŸŽ­ DEMO MODE enabled - Rate limiting active +šŸ” Authentication ENABLED (from AUTHENTICATION_ENABLED env var) +šŸ”‘ Password hash loaded from AUTHENTICATION_PASSWORD_HASH env var +šŸ” Secret key loaded from AUTHENTICATION_SECRET_KEY env var +``` + +**Private instance (no restrictions):** +```bash +# No environment variables needed +python run.py + +# Output: +āœ… Production mode - Rate limiting disabled +šŸ” Authentication DISABLED (from config.yaml) +``` + +## šŸ“ Boolean Values + +Environment variables accept multiple formats for boolean values: + +**True values:** +- `true`, `True`, `TRUE` +- `1` +- `yes`, `Yes`, `YES` + +**False values:** +- `false`, `False`, `FALSE` +- `0` +- `no`, `No`, `NO` +- Not set (uses config.yaml default) + +**Examples:** +```bash +AUTHENTICATION_ENABLED=true # āœ… Enabled +AUTHENTICATION_ENABLED=1 # āœ… Enabled +AUTHENTICATION_ENABLED=yes # āœ… Enabled +AUTHENTICATION_ENABLED=false # āŒ Disabled +AUTHENTICATION_ENABLED=0 # āŒ Disabled +# Not set # Uses config.yaml +``` + +## šŸš€ Deployment Scenarios + +### Scenario 1: Same Codebase, Different Environments + +**Repository structure:** +``` +config.yaml # Default: auth disabled (for local dev) +render.yaml # Render: AUTHENTICATION_ENABLED=true +docker-compose.yml # Docker: AUTHENTICATION_ENABLED=false (or not set) +``` + +**Benefits:** +- āœ… Single codebase +- āœ… No code changes between environments +- āœ… Environment-specific security + +### Scenario 2: Team Collaboration + +**Team workflow:** +```bash +# Developer 1 (local, no auth) +git clone repo +python run.py +# → No auth needed for local testing + +# Developer 2 (local, testing auth) +AUTHENTICATION_ENABLED=true python run.py +# → Tests authentication locally + +# Production (Render) +# → AUTHENTICATION_ENABLED=true in render.yaml +# → Always requires authentication +``` + +### Scenario 3: CI/CD Pipeline + +**GitHub Actions:** +```yaml +- name: Test with auth disabled + run: | + export AUTHENTICATION_ENABLED=false + pytest tests/ + +- name: Test with auth enabled + run: | + export AUTHENTICATION_ENABLED=true + pytest tests/auth/ +``` + +## šŸ” Checking Current Configuration + +The application prints its configuration on startup: + +```bash +python run.py + +# Output: +šŸŽ­ DEMO MODE enabled - Rate limiting active +šŸ” Authentication ENABLED (from AUTHENTICATION_ENABLED env var) +šŸ”‘ Password hash loaded from AUTHENTICATION_PASSWORD_HASH env var +šŸ” Secret key loaded from AUTHENTICATION_SECRET_KEY env var +``` + +## āš™ļø Advanced: All Environment Variables + +**Full demo deployment example:** +```bash +export PORT=8080 +export DEMO_MODE=true +export AUTHENTICATION_ENABLED=true +export AUTHENTICATION_PASSWORD_HASH='$2b$12$...' +export AUTHENTICATION_SECRET_KEY='your_random_secret_key_here' + +python run.py +``` + +**Docker example:** +```dockerfile +ENV PORT=8000 +ENV DEMO_MODE=true +ENV AUTHENTICATION_ENABLED=true +ENV AUTHENTICATION_PASSWORD_HASH='$2b$12$...' +ENV AUTHENTICATION_SECRET_KEY='your_random_secret_key_here' +``` + +## šŸ“Š Quick Reference Table + +| Scenario | `DEMO_MODE` | `AUTHENTICATION_ENABLED` | Behavior | +|----------|-------------|---------------|----------| +| **Local Dev** | Not set | Not set | No restrictions, quick testing | +| **Local Auth Testing** | Not set | `true` | Auth only, no rate limits | +| **Public Demo** | `true` | `true` | Full restrictions + auth | +| **Private Instance** | Not set | `true` | Auth only, self-hosted | +| **Read-Only Demo** | `true` | Not set | Rate limited, no auth | + +## šŸ†˜ Troubleshooting + +### "Authentication not working" + +**Check startup logs:** +``` +šŸ” Authentication DISABLED (from config.yaml) +``` + +**Fix:** Set environment variable +```bash +export AUTHENTICATION_ENABLED=true +``` + +### "Wrong password accepted" + +**Check if password hash is loaded:** +``` +šŸ”‘ Password hash loaded from AUTHENTICATION_PASSWORD_HASH env var +``` + +**If not shown:** Environment variable not set, using `config.yaml` + +### "Can't disable auth in Render" + +**Option 1:** Remove from `render.yaml` +```yaml +# Remove or comment out: +# - key: AUTHENTICATION_ENABLED +# value: "true" +``` + +**Option 2:** Explicitly disable +```yaml +- key: AUTHENTICATION_ENABLED + value: "false" +``` + +## šŸ”§ 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) +- **Render Deployment**: [DEPLOYMENT_RENDER.md](DEPLOYMENT_RENDER.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..c53670d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -754,7 +754,7 @@

-
+
Date: Tue, 25 Nov 2025 17:24:36 +0100 Subject: [PATCH 3/6] doc updates --- documentation/ENVIRONMENT_VARIABLES.md | 330 ------------------------- 1 file changed, 330 deletions(-) diff --git a/documentation/ENVIRONMENT_VARIABLES.md b/documentation/ENVIRONMENT_VARIABLES.md index 8001206..4b32ef2 100644 --- a/documentation/ENVIRONMENT_VARIABLES.md +++ b/documentation/ENVIRONMENT_VARIABLES.md @@ -34,335 +34,6 @@ Configuration is loaded in this order (later overrides earlier): 2. **Environment Variables** - Runtime overrides 3. **Command Line** - Highest priority (if applicable) -## šŸ’” Common Use Cases - -### Use Case 1: Local Development (No Auth) - -**Local setup:** -```bash -# No environment variables needed -# Uses config.yaml defaults (auth disabled) -python run.py -``` - -**`config.yaml`:** -```yaml -authentication: - enabled: false -``` - -**Result:** āœ… No authentication, quick local testing - ---- - -### Use Case 2: Public Demo (Auth + Rate Limiting) - -**Render deployment:** -```yaml -# render.yaml -envVars: - - key: DEMO_MODE - value: "true" - - key: AUTHENTICATION_ENABLED - value: "true" -``` - -**`config.yaml`:** -```yaml -authentication: - enabled: false # Overridden by AUTHENTICATION_ENABLED=true -``` - -**Result:** āœ… Demo mode enabled (rate limiting + restrictions), authentication enabled - ---- - -### Use Case 3: Private Demo (Custom Password & Secret Key) - -**Generate password hash and secret key:** -```bash -# Generate password hash -python generate_password.py -# Enter password: mysecurepassword -# Generated hash: $2b$12$... - -# Generate secret key -python -c "import secrets; print(secrets.token_hex(32))" -# Generated key: a1b2c3d4e5f6... -``` - -**Render deployment:** -```yaml -envVars: - - key: AUTHENTICATION_ENABLED - value: "true" - - key: AUTHENTICATION_PASSWORD_HASH - value: "$2b$12$..." # Your generated hash - - key: AUTHENTICATION_SECRET_KEY - value: "a1b2c3d4e5f6..." # Your generated key -``` - -**Result:** āœ… Custom password and secure session key for production demo - ---- - -### Use Case 4: Docker Compose (Local Development) - -**`docker-compose.yml`:** -```yaml -services: - notediscovery: - image: ghcr.io/gamosoft/notediscovery:latest - ports: - - "8000:8000" - environment: - - AUTHENTICATION_ENABLED=false - - DEMO_MODE=false - volumes: - - ./data:/app/data -``` - -**Result:** āœ… Local deployment without restrictions (no auth, no rate limiting) - ---- - -### Use Case 5: Custom Port - -**Run on port 3000 instead of 8000:** - -**Local (run.py):** -```bash -PORT=3000 python run.py -# → Runs on http://localhost:3000 -``` - -**Docker:** -```bash -docker run -p 3000:3000 -e PORT=3000 ghcr.io/gamosoft/notediscovery:latest -``` - -**Docker Compose:** -```yaml -services: - notediscovery: - image: ghcr.io/gamosoft/notediscovery:latest - ports: - - "3000:3000" # Host:Container - environment: - - PORT=3000 -``` - -**Result:** āœ… App runs on custom port - ---- - -## šŸ” Authentication Examples - -### Example 1: Enable Auth via Environment - -**Without environment variables:** -```bash -# Uses config.yaml defaults -šŸ” Authentication DISABLED (from config.yaml) -āœ… Production mode - Rate limiting disabled -``` - -**With environment variables (demo mode):** -```bash -export DEMO_MODE=true -export AUTHENTICATION_ENABLED=true -python run.py - -# Output: -šŸŽ­ DEMO MODE enabled - Rate limiting active -šŸ” Authentication ENABLED (from AUTHENTICATION_ENABLED env var) -``` - -### Example 2: Demo Deployment - -**Public demo with restrictions:** -```bash -export DEMO_MODE=true -export AUTHENTICATION_ENABLED=true -export AUTHENTICATION_PASSWORD_HASH='$2b$12$custom_hash' -export AUTHENTICATION_SECRET_KEY='your_random_secret_key' -python run.py - -# Output: -šŸŽ­ DEMO MODE enabled - Rate limiting active -šŸ” Authentication ENABLED (from AUTHENTICATION_ENABLED env var) -šŸ”‘ Password hash loaded from AUTHENTICATION_PASSWORD_HASH env var -šŸ” Secret key loaded from AUTHENTICATION_SECRET_KEY env var -``` - -**Private instance (no restrictions):** -```bash -# No environment variables needed -python run.py - -# Output: -āœ… Production mode - Rate limiting disabled -šŸ” Authentication DISABLED (from config.yaml) -``` - -## šŸ“ Boolean Values - -Environment variables accept multiple formats for boolean values: - -**True values:** -- `true`, `True`, `TRUE` -- `1` -- `yes`, `Yes`, `YES` - -**False values:** -- `false`, `False`, `FALSE` -- `0` -- `no`, `No`, `NO` -- Not set (uses config.yaml default) - -**Examples:** -```bash -AUTHENTICATION_ENABLED=true # āœ… Enabled -AUTHENTICATION_ENABLED=1 # āœ… Enabled -AUTHENTICATION_ENABLED=yes # āœ… Enabled -AUTHENTICATION_ENABLED=false # āŒ Disabled -AUTHENTICATION_ENABLED=0 # āŒ Disabled -# Not set # Uses config.yaml -``` - -## šŸš€ Deployment Scenarios - -### Scenario 1: Same Codebase, Different Environments - -**Repository structure:** -``` -config.yaml # Default: auth disabled (for local dev) -render.yaml # Render: AUTHENTICATION_ENABLED=true -docker-compose.yml # Docker: AUTHENTICATION_ENABLED=false (or not set) -``` - -**Benefits:** -- āœ… Single codebase -- āœ… No code changes between environments -- āœ… Environment-specific security - -### Scenario 2: Team Collaboration - -**Team workflow:** -```bash -# Developer 1 (local, no auth) -git clone repo -python run.py -# → No auth needed for local testing - -# Developer 2 (local, testing auth) -AUTHENTICATION_ENABLED=true python run.py -# → Tests authentication locally - -# Production (Render) -# → AUTHENTICATION_ENABLED=true in render.yaml -# → Always requires authentication -``` - -### Scenario 3: CI/CD Pipeline - -**GitHub Actions:** -```yaml -- name: Test with auth disabled - run: | - export AUTHENTICATION_ENABLED=false - pytest tests/ - -- name: Test with auth enabled - run: | - export AUTHENTICATION_ENABLED=true - pytest tests/auth/ -``` - -## šŸ” Checking Current Configuration - -The application prints its configuration on startup: - -```bash -python run.py - -# Output: -šŸŽ­ DEMO MODE enabled - Rate limiting active -šŸ” Authentication ENABLED (from AUTHENTICATION_ENABLED env var) -šŸ”‘ Password hash loaded from AUTHENTICATION_PASSWORD_HASH env var -šŸ” Secret key loaded from AUTHENTICATION_SECRET_KEY env var -``` - -## āš™ļø Advanced: All Environment Variables - -**Full demo deployment example:** -```bash -export PORT=8080 -export DEMO_MODE=true -export AUTHENTICATION_ENABLED=true -export AUTHENTICATION_PASSWORD_HASH='$2b$12$...' -export AUTHENTICATION_SECRET_KEY='your_random_secret_key_here' - -python run.py -``` - -**Docker example:** -```dockerfile -ENV PORT=8000 -ENV DEMO_MODE=true -ENV AUTHENTICATION_ENABLED=true -ENV AUTHENTICATION_PASSWORD_HASH='$2b$12$...' -ENV AUTHENTICATION_SECRET_KEY='your_random_secret_key_here' -``` - -## šŸ“Š Quick Reference Table - -| Scenario | `DEMO_MODE` | `AUTHENTICATION_ENABLED` | Behavior | -|----------|-------------|---------------|----------| -| **Local Dev** | Not set | Not set | No restrictions, quick testing | -| **Local Auth Testing** | Not set | `true` | Auth only, no rate limits | -| **Public Demo** | `true` | `true` | Full restrictions + auth | -| **Private Instance** | Not set | `true` | Auth only, self-hosted | -| **Read-Only Demo** | `true` | Not set | Rate limited, no auth | - -## šŸ†˜ Troubleshooting - -### "Authentication not working" - -**Check startup logs:** -``` -šŸ” Authentication DISABLED (from config.yaml) -``` - -**Fix:** Set environment variable -```bash -export AUTHENTICATION_ENABLED=true -``` - -### "Wrong password accepted" - -**Check if password hash is loaded:** -``` -šŸ”‘ Password hash loaded from AUTHENTICATION_PASSWORD_HASH env var -``` - -**If not shown:** Environment variable not set, using `config.yaml` - -### "Can't disable auth in Render" - -**Option 1:** Remove from `render.yaml` -```yaml -# Remove or comment out: -# - key: AUTHENTICATION_ENABLED -# value: "true" -``` - -**Option 2:** Explicitly disable -```yaml -- key: AUTHENTICATION_ENABLED - value: "false" -``` - ## šŸ”§ Advanced Server Configuration The following settings are available in `config.yaml` only (not via environment variables): @@ -413,7 +84,6 @@ When `debug: false` (recommended): ## šŸ“š Related Documentation - **Authentication**: [AUTHENTICATION.md](AUTHENTICATION.md) -- **Render Deployment**: [DEPLOYMENT_RENDER.md](DEPLOYMENT_RENDER.md) - **API Rate Limiting**: [API.md](API.md#rate-limiting) --- From 6b7febd418152454503762fae3a9359220cf9699 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 25 Nov 2025 17:39:43 +0100 Subject: [PATCH 4/6] added info --- README.md | 5 ++++- config.yaml | 4 ++++ render.yaml | 10 +++++++++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 79c5913..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)** diff --git a/config.yaml b/config.yaml index 6d57cd6..5e88d3e 100644 --- a/config.yaml +++ b/config.yaml @@ -30,11 +30,15 @@ authentication: # 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/render.yaml b/render.yaml index fb6cabd..2a43236 100644 --- a/render.yaml +++ b/render.yaml @@ -24,8 +24,16 @@ services: value: "true" - key: AUTHENTICATION_ENABLED value: "true" + + # āš ļø DEMO CREDENTIALS - DO NOT USE IN PRODUCTION! āš ļø + # These are public demo credentials for testing only. + # Password: "admin" - key: AUTHENTICATION_PASSWORD_HASH - value: "$2b$12$t/6PGExFzdpU2PUta0iVY.eDQwvu63kH.c/d4bEnnHaQ5CspH1yrG" # admin + value: "$2b$12$t/6PGExFzdpU2PUta0iVY.eDQwvu63kH.c/d4bEnnHaQ5CspH1yrG" + + # āš ļø PUBLIC SECRET KEY - DEMO ONLY! āš ļø + # For production, generate a new key with: + # python -c "import secrets; print(secrets.token_hex(32))" - key: AUTHENTICATION_SECRET_KEY value: "4f36da5af76627301dcdc0347c4b111bdc6c86ae830444af852de935198c3210" From 3459e4c9a19058e44ed5f00467ca936e5c9b7c4d Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 25 Nov 2025 17:56:11 +0100 Subject: [PATCH 5/6] added session regeneration --- backend/main.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/main.py b/backend/main.py index 6233117..215d330 100644 --- a/backend/main.py +++ b/backend/main.py @@ -123,12 +123,13 @@ def safe_error_message(error: Exception, user_message: str = "An error occurred" 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('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', - https_only=False # Set to True if using HTTPS + same_site='lax', # Prevents CSRF attacks + https_only=False # Set to True if using HTTPS in production ) # Demo mode - Centralizes all demo-specific restrictions @@ -268,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: From 96d0c2f834da3ac97d18db3cea1a9d41e1266e01 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 25 Nov 2025 21:52:22 +0100 Subject: [PATCH 6/6] added demo mode banners for online demo --- backend/main.py | 1 + frontend/index.html | 15 +++++++++++++++ frontend/login.html | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/backend/main.py b/backend/main.py index 215d330..8b2539e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -489,6 +489,7 @@ async def get_config(): "tagline": config['app']['tagline'], "version": config['app']['version'], "searchEnabled": config['search']['enabled'], + "demoMode": DEMO_MODE, # Expose demo mode flag to frontend "authentication": { "enabled": config.get('authentication', {}).get('enabled', False) } diff --git a/frontend/index.html b/frontend/index.html index c53670d..0e890c5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1168,6 +1168,13 @@

+ +
+ + + + DEMO MODE: Multiple users may edit simultaneously. Changes might be lost if others save at the same time. +
@@ -1374,6 +1381,14 @@ āœ“ Saved
+ +
+ + + + DEMO MODE: Multiple users may edit simultaneously. Changes might be lost if others save at the same time. +
+