Merge pull request #42 from gamosoft/features/render-hosting

- use environment variables (breaking change in config!)
- added session regeneration for security
- added demo mode banners
- added basics for render.com hosting (for online demo)
- added new themes by @sudo-Harshk
This commit is contained in:
Gamosoft 2025-11-26 09:24:58 +01:00 committed by GitHub
commit 36a6b86326
12 changed files with 336 additions and 51 deletions

View File

@ -33,13 +33,16 @@ COPY generate_password.py .
# Create data directory # Create data directory
RUN mkdir -p data RUN mkdir -p data
# Expose port # Expose port (default, can be overridden)
EXPOSE 8000 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 \ 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 # Run the application (shell form to allow environment variable expansion)
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD uvicorn backend.main:app --host 0.0.0.0 --port $PORT

View File

@ -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 > # 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)** **Option 1: Docker Compose (Recommended)**
@ -212,6 +215,7 @@ Want to learn more?
- 🔌 **[PLUGINS.md](documentation/PLUGINS.md)** - Plugin system and available plugins - 🔌 **[PLUGINS.md](documentation/PLUGINS.md)** - Plugin system and available plugins
- 🌐 **[API.md](documentation/API.md)** - REST API documentation and examples - 🌐 **[API.md](documentation/API.md)** - REST API documentation and examples
- 🔐 **[AUTHENTICATION.md](documentation/AUTHENTICATION.md)** - Enable password protection for your instance - 🔐 **[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: 💡 **Pro Tip:** If you clone this repository, you can mount the `documentation/` folder to view these docs inside the app:

View File

@ -16,6 +16,9 @@ from typing import List, Optional
import aiofiles import aiofiles
from datetime import datetime from datetime import datetime
import bcrypt 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 ( from .utils import (
get_all_notes, get_all_notes,
@ -52,6 +55,25 @@ with open(version_path, 'r', encoding='utf-8') as f:
version = f.read().strip() version = f.read().strip()
config['app']['version'] = version 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 # Initialize app
app = FastAPI( app = FastAPI(
title=config['app']['name'], title=config['app']['name'],
@ -59,24 +81,78 @@ app = FastAPI(
version=config['app']['version'] 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=allowed_origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], 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 # Session middleware for authentication
# Security: Session ID is regenerated after login to prevent session fixation attacks
app.add_middleware( app.add_middleware(
SessionMiddleware, SessionMiddleware,
secret_key=config.get('security', {}).get('secret_key', 'insecure_default_key_change_this'), secret_key=config.get('authentication', {}).get('secret_key', 'insecure_default_key_change_this'),
max_age=config.get('security', {}).get('session_max_age', 604800), # 7 days default max_age=config.get('authentication', {}).get('session_max_age', 604800), # 7 days default
same_site='lax', same_site='lax', # Prevents CSRF attacks
https_only=False # Set to True if using HTTPS 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 required directories exist
ensure_directories(config) ensure_directories(config)
@ -128,7 +204,7 @@ async def http_exception_handler(request: Request, exc: HTTPException):
def auth_enabled() -> bool: def auth_enabled() -> bool:
"""Check if authentication is enabled in config""" """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): async def require_auth(request: Request):
@ -143,7 +219,7 @@ async def require_auth(request: Request):
def verify_password(password: str) -> bool: def verify_password(password: str) -> bool:
"""Verify password against stored hash""" """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: if not password_hash:
return False return False
@ -193,7 +269,11 @@ async def login(request: Request, password: str = Form(...)):
# Verify password # Verify password
if verify_password(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 request.session['authenticated'] = True
return RedirectResponse(url="/", status_code=303) return RedirectResponse(url="/", status_code=303)
else: else:
@ -409,8 +489,9 @@ async def get_config():
"tagline": config['app']['tagline'], "tagline": config['app']['tagline'],
"version": config['app']['version'], "version": config['app']['version'],
"searchEnabled": config['search']['enabled'], "searchEnabled": config['search']['enabled'],
"security": { "demoMode": DEMO_MODE, # Expose demo mode flag to frontend
"enabled": config.get('security', {}).get('enabled', False) "authentication": {
"enabled": config.get('authentication', {}).get('enabled', False)
} }
} }
@ -436,7 +517,8 @@ async def get_theme(theme_id: str):
@api_router.post("/folders") @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""" """Create a new folder"""
try: try:
folder_path = data.get('path', '') folder_path = data.get('path', '')
@ -453,8 +535,10 @@ async def create_new_folder(data: dict):
"path": folder_path, "path": folder_path,
"message": "Folder created successfully" "message": "Folder created successfully"
} }
except HTTPException:
raise
except Exception as e: 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}") @api_router.get("/images/{image_path:path}")
@ -484,11 +568,12 @@ async def get_image(image_path: str):
except HTTPException: except HTTPException:
raise raise
except Exception as e: 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") @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. Upload an image file and save it to the attachments directory.
Returns the relative path to the image for markdown linking. 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: except HTTPException:
raise raise
except Exception as e: 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") @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""" """Move a note to a different folder"""
try: try:
old_path = data.get('oldPath', '') old_path = data.get('oldPath', '')
@ -566,12 +652,15 @@ async def move_note_endpoint(data: dict):
"newPath": new_path, "newPath": new_path,
"message": "Note moved successfully" "message": "Note moved successfully"
} }
except HTTPException:
raise
except Exception as e: 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") @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""" """Move a folder to a different location"""
try: try:
old_path = data.get('oldPath', '') old_path = data.get('oldPath', '')
@ -591,12 +680,15 @@ async def move_folder_endpoint(data: dict):
"newPath": new_path, "newPath": new_path,
"message": "Folder moved successfully" "message": "Folder moved successfully"
} }
except HTTPException:
raise
except Exception as e: 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") @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""" """Rename a folder"""
try: try:
old_path = data.get('oldPath', '') old_path = data.get('oldPath', '')
@ -616,12 +708,15 @@ async def rename_folder_endpoint(data: dict):
"newPath": new_path, "newPath": new_path,
"message": "Folder renamed successfully" "message": "Folder renamed successfully"
} }
except HTTPException:
raise
except Exception as e: 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}") @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""" """Delete a folder and all its contents"""
try: try:
if not folder_path: if not folder_path:
@ -637,8 +732,10 @@ async def delete_folder_endpoint(folder_path: str):
"path": folder_path, "path": folder_path,
"message": "Folder deleted successfully" "message": "Folder deleted successfully"
} }
except HTTPException:
raise
except Exception as e: 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 --- # --- Tags Endpoints ---
@ -655,7 +752,7 @@ async def list_tags():
tags = get_all_tags(config['storage']['notes_dir']) tags = get_all_tags(config['storage']['notes_dir'])
return {"tags": tags} return {"tags": tags}
except Exception as e: 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}") @api_router.get("/tags/{tag_name}")
@ -677,7 +774,7 @@ async def get_notes_by_tag_endpoint(tag_name: str):
"notes": notes "notes": notes
} }
except Exception as e: 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 --- # --- Notes Endpoints ---
@ -690,7 +787,7 @@ async def list_notes():
folders = get_all_folders(config['storage']['notes_dir']) folders = get_all_folders(config['storage']['notes_dir'])
return {"notes": notes, "folders": folders} return {"notes": notes, "folders": folders}
except Exception as e: 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}") @api_router.get("/notes/{note_path:path}")
@ -714,11 +811,12 @@ async def get_note(note_path: str):
except HTTPException: except HTTPException:
raise raise
except Exception as e: 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}") @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""" """Create or update a note"""
try: try:
note_content = content.get('content', '') 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", "message": "Note created successfully" if is_new_note else "Note saved successfully",
"content": note_content # Return the (potentially modified) content "content": note_content # Return the (potentially modified) content
} }
except HTTPException:
raise
except Exception as e: 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}") @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""" """Delete a note"""
try: try:
success = delete_note(config['storage']['notes_dir'], note_path) success = delete_note(config['storage']['notes_dir'], note_path)
@ -771,8 +872,10 @@ async def remove_note(note_path: str):
"success": True, "success": True,
"message": "Note deleted successfully" "message": "Note deleted successfully"
} }
except HTTPException:
raise
except Exception as e: 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") @api_router.get("/search")
@ -791,7 +894,7 @@ async def search(q: str):
except HTTPException: except HTTPException:
raise raise
except Exception as e: 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") @api_router.get("/graph")
@ -815,7 +918,7 @@ async def get_graph():
return {"nodes": nodes, "edges": edges} return {"nodes": nodes, "edges": edges}
except Exception as e: 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") @api_router.get("/plugins")
@ -835,11 +938,12 @@ async def calculate_note_stats(content: str):
stats = plugin.calculate_stats(content) stats = plugin.calculate_stats(content)
return {"enabled": True, "stats": stats} return {"enabled": True, "stats": stats}
except Exception as e: 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") @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""" """Enable or disable a plugin"""
try: try:
is_enabled = enabled.get('enabled', False) is_enabled = enabled.get('enabled', False)
@ -854,7 +958,7 @@ async def toggle_plugin(plugin_name: str, enabled: dict):
"enabled": is_enabled "enabled": is_enabled
} }
except Exception as e: 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") @app.get("/health")

View File

@ -10,6 +10,14 @@ server:
port: 8000 port: 8000
reload: false # Set to true for development 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: storage:
notes_dir: "./data" notes_dir: "./data"
plugins_dir: "./plugins" plugins_dir: "./plugins"
@ -17,16 +25,20 @@ storage:
search: search:
enabled: true enabled: true
security: authentication:
# Authentication settings # Authentication settings
# Set enabled to true to require login # Set enabled to true to require login
enabled: false 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! # Session secret key - CHANGE THIS TO A RANDOM STRING!
# Generate with: python -c "import secrets; print(secrets.token_hex(32))" # Generate with: python -c "import secrets; print(secrets.token_hex(32))"
secret_key: "change_this_to_a_random_secret_key_in_production" secret_key: "change_this_to_a_random_secret_key_in_production"
# Password hash - Generate with: python generate_password.py # 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" password_hash: "$2b$12$t/6PGExFzdpU2PUta0iVY.eDQwvu63kH.c/d4bEnnHaQ5CspH1yrG" # Default: "admin"
# Session expiry in seconds (default: 7 days) # Session expiry in seconds (default: 7 days)

View File

@ -102,10 +102,10 @@ python -c "import secrets; print(secrets.token_hex(32))"
### Step 3: Update `config.yaml` ### Step 3: Update `config.yaml`
Edit your `config.yaml` and update the security section: Edit your `config.yaml` and update the authentication section:
```yaml ```yaml
security: authentication:
# Enable authentication # Enable authentication
enabled: true 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: To disable authentication and allow open access:
```yaml ```yaml
security: authentication:
enabled: false enabled: false
``` ```

View File

@ -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! 🎯

View File

@ -754,7 +754,7 @@
</div> </div>
<p class="text-xs mb-3" style="color: var(--text-secondary);" x-text="appTagline"></p> <p class="text-xs mb-3" style="color: var(--text-secondary);" x-text="appTagline"></p>
<!-- Logout Button (only show if auth is enabled) --> <!-- Logout Button (only show if auth is enabled) -->
<div x-data="{ authEnabled: false }" x-init="fetch('/api/config').then(r => r.json()).then(d => authEnabled = d.security?.enabled || false)"> <div x-data="{ authEnabled: false }" x-init="fetch('/api/config').then(r => r.json()).then(d => authEnabled = d.authentication?.enabled || false)">
<a x-show="authEnabled" <a x-show="authEnabled"
href="/logout" href="/logout"
class="flex items-center justify-center gap-2 px-3 py-2 text-sm rounded transition-colors" class="flex items-center justify-center gap-2 px-3 py-2 text-sm rounded transition-colors"
@ -1168,6 +1168,13 @@
<div class="mb-6"> <div class="mb-6">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary);" x-text="appName"></h1> <h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary);" x-text="appName"></h1>
<p class="text-lg" style="color: var(--text-secondary);" x-text="appTagline"></p> <p class="text-lg" style="color: var(--text-secondary);" x-text="appTagline"></p>
<!-- Demo Mode Warning Banner -->
<div x-data="{ showDemoBanner: false }" x-init="fetch('/api/config').then(r => r.json()).then(d => showDemoBanner = d.demoMode || false)" x-show="showDemoBanner" class="mt-4 flex items-center px-4 py-2.5 text-sm rounded-lg" style="background: linear-gradient(135deg, #ff6b6b 0%, #ff8e53 100%); color: white; font-weight: 500;">
<svg class="w-5 h-5 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<span><strong>DEMO MODE:</strong> Multiple users may edit simultaneously. Changes might be lost if others save at the same time.</span>
</div>
</div> </div>
<!-- Breadcrumb Navigation --> <!-- Breadcrumb Navigation -->
@ -1374,6 +1381,14 @@
<span x-show="!isSaving && lastSaved" class="text-xs" style="color: var(--success);">✓ Saved</span> <span x-show="!isSaving && lastSaved" class="text-xs" style="color: var(--success);">✓ Saved</span>
</div> </div>
<!-- Demo Mode Warning Banner -->
<div x-data="{ showDemoBanner: false }" x-init="fetch('/api/config').then(r => r.json()).then(d => showDemoBanner = d.demoMode || false)" x-show="showDemoBanner" class="flex items-center px-3 py-1.5 text-xs rounded-lg" style="background: linear-gradient(135deg, #ff6b6b 0%, #ff8e53 100%); color: white; font-weight: 500;">
<svg class="w-4 h-4 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<span><strong>DEMO MODE:</strong> Multiple users may edit simultaneously. Changes might be lost if others save at the same time.</span>
</div>
<div class="flex items-center space-x-2" x-show="currentNote"> <div class="flex items-center space-x-2" x-show="currentNote">
<!-- View Toggle (only for notes) --> <!-- View Toggle (only for notes) -->
<div class="flex rounded-lg p-1" style="background-color: var(--bg-tertiary);"> <div class="flex rounded-lg p-1" style="background-color: var(--bg-tertiary);">

View File

@ -211,6 +211,14 @@
<div class="footer"> <div class="footer">
🔒 Secure & Self-Hosted 🔒 Secure & Self-Hosted
<div style="margin-top: 8px; font-size: 12px; opacity: 0.6;">
<a href="https://www.notediscovery.com" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
<svg style="width: 14px; height: 14px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
</svg>
notediscovery.com
</a>
</div>
</div> </div>
</div> </div>
</body> </body>

View File

@ -39,7 +39,7 @@ def generate_password_hash():
print("\nExample config.yaml:") print("\nExample config.yaml:")
print("="*60) print("="*60)
print(""" print("""
security: authentication:
enabled: true enabled: true
secret_key: "your_secret_key_here" secret_key: "your_secret_key_here"
password_hash: "{}" password_hash: "{}"

41
render.yaml Normal file
View File

@ -0,0 +1,41 @@
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: DEMO_MODE
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"
# ⚠️ 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"
# Manual deployment only
autoDeploy: false

View File

@ -7,4 +7,5 @@ aiofiles==23.2.1
cryptography==41.0.7 cryptography==41.0.7
bcrypt==4.1.2 bcrypt==4.1.2
itsdangerous==2.1.2 itsdangerous==2.1.2
slowapi==0.1.9

9
run.py
View File

@ -5,6 +5,7 @@ Run this to start the application without Docker
""" """
import sys import sys
import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@ -23,16 +24,20 @@ def main():
Path("data").mkdir(parents=True, exist_ok=True) Path("data").mkdir(parents=True, exist_ok=True)
Path("plugins").mkdir(parents=True, exist_ok=True) Path("plugins").mkdir(parents=True, exist_ok=True)
# Get port from environment variable or use default
port = os.getenv("PORT", "8000")
print("✓ Dependencies installed") print("✓ Dependencies installed")
print("✓ Directories created") print("✓ Directories created")
print("\n" + "="*50) print("\n" + "="*50)
print("🎉 NoteDiscovery is running!") print("🎉 NoteDiscovery is running!")
print("="*50) print("="*50)
print("\n📝 Open your browser to: http://localhost:8000") print(f"\n📝 Open your browser to: http://localhost:{port}")
print("\n💡 Tips:") print("\n💡 Tips:")
print(" - Press Ctrl+C to stop the server") print(" - Press Ctrl+C to stop the server")
print(" - Your notes are in ./data/") print(" - Your notes are in ./data/")
print(" - Plugins go in ./plugins/") print(" - Plugins go in ./plugins/")
print(f" - Change port with: PORT={port} python run.py")
print("\n" + "="*50 + "\n") print("\n" + "="*50 + "\n")
# Run the application # Run the application
@ -41,7 +46,7 @@ def main():
"backend.main:app", "backend.main:app",
"--reload", "--reload",
"--host", "0.0.0.0", "--host", "0.0.0.0",
"--port", "8000" "--port", port
]) ])
if __name__ == "__main__": if __name__ == "__main__":