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:
commit
36a6b86326
13
Dockerfile
13
Dockerfile
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
178
backend/main.py
178
backend/main.py
|
|
@ -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")
|
||||||
|
|
|
||||||
14
config.yaml
14
config.yaml
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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! 🎯
|
||||||
|
|
||||||
|
|
@ -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);">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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: "{}"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
9
run.py
|
|
@ -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__":
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue