added basic authentication
This commit is contained in:
parent
c184b41a5b
commit
e5e34152a0
|
|
@ -0,0 +1,311 @@
|
|||
# 🔒 NoteDiscovery Authentication Guide
|
||||
|
||||
## Overview
|
||||
|
||||
NoteDiscovery includes a simple, secure authentication system for single-user deployments. When enabled, users must log in with a password before accessing the application.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- ✅ **Single User** - Perfect for personal/self-hosted use
|
||||
- ✅ **Secure** - Passwords hashed with bcrypt
|
||||
- ✅ **Session-based** - Stay logged in for 7 days (configurable)
|
||||
- ✅ **Theme-aware** - Login page matches your selected theme
|
||||
- ✅ **Simple Setup** - Just 3 steps to enable
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Setup
|
||||
|
||||
**⚠️ IMPORTANT:** Authentication is **disabled by default**. Follow these steps to enable it:
|
||||
|
||||
### Step 1: Generate a Password Hash
|
||||
|
||||
Choose one of these methods:
|
||||
|
||||
**Option A: Using Docker (Recommended - No local Python needed)**
|
||||
|
||||
If you're running NoteDiscovery in Docker:
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
docker-compose exec notediscovery /app/generate_password_hash.sh
|
||||
|
||||
# Or with docker run
|
||||
docker exec -it notediscovery /app/generate_password_hash.sh
|
||||
```
|
||||
|
||||
The script will prompt you for your password and output the hash.
|
||||
|
||||
**Option B: Using Local Python**
|
||||
|
||||
If you're running NoteDiscovery locally:
|
||||
|
||||
```bash
|
||||
# Install bcrypt if not already installed
|
||||
pip install bcrypt
|
||||
|
||||
# Run the password generator
|
||||
python generate_password.py
|
||||
```
|
||||
|
||||
Enter your desired password when prompted. The script will output a bcrypt hash.
|
||||
|
||||
**Copy the hash** - you'll need it for the next step.
|
||||
|
||||
### Step 2: Generate a Secret Key
|
||||
|
||||
Generate a random secret key for session encryption:
|
||||
|
||||
**Using Docker:**
|
||||
```bash
|
||||
docker-compose exec notediscovery python -c "import secrets; print(secrets.token_hex(32))"
|
||||
|
||||
# Or with docker run
|
||||
docker exec -it notediscovery python -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
**Using Local Python:**
|
||||
```bash
|
||||
python -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
**Copy the key** - you'll need it for the next step.
|
||||
|
||||
### Step 3: Update `config.yaml`
|
||||
|
||||
Edit your `config.yaml` and update the security section:
|
||||
|
||||
```yaml
|
||||
security:
|
||||
# Enable authentication
|
||||
enabled: true
|
||||
|
||||
# Session secret key (paste the output from Step 3)
|
||||
secret_key: "your_generated_secret_key_here"
|
||||
|
||||
# Password hash (paste the output from Step 2)
|
||||
password_hash: "$2b$12$..."
|
||||
|
||||
# Session expiry in seconds (7 days by default)
|
||||
session_max_age: 604800
|
||||
```
|
||||
|
||||
### Step 4: Restart the Application
|
||||
|
||||
```bash
|
||||
# If running locally
|
||||
uvicorn backend.main:app --reload
|
||||
|
||||
# If using Docker Compose
|
||||
docker-compose restart
|
||||
|
||||
# Or with docker run
|
||||
docker restart notediscovery
|
||||
```
|
||||
|
||||
### Step 5: Test Login
|
||||
|
||||
Navigate to `http://localhost:8000` and you'll be redirected to the login page.
|
||||
|
||||
Enter the password you chose in Step 2.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Usage
|
||||
|
||||
### Logging In
|
||||
|
||||
1. Navigate to `http://localhost:8000`
|
||||
2. You'll be **automatically redirected** to the login page if not authenticated
|
||||
3. Enter your password (the one you generated in setup)
|
||||
4. Click "🔓 Unlock"
|
||||
5. You'll be redirected back to the main app
|
||||
|
||||
**Note:** The login page **automatically inherits the theme** you selected in the main app. It uses the same theme loading system as the main app (fetches CSS from `/api/themes` and stores preference in localStorage as `noteDiscoveryTheme`).
|
||||
|
||||
**What happens if I'm not logged in?**
|
||||
- 🌐 **Page requests** (like `/`, `/MATHJAX`, etc.) → **Automatically redirected to login page**
|
||||
- 🔌 **API requests** (like `/api/notes`) → **Return JSON error** `{"detail": "Not authenticated"}`
|
||||
|
||||
This smart routing ensures:
|
||||
- ✅ Users always see the login page (never a JSON error)
|
||||
- ✅ API calls get proper JSON errors (for programmatic access)
|
||||
- ✅ No broken page loads or error messages
|
||||
|
||||
### Logging Out
|
||||
|
||||
**Option 1: Use the built-in logout button**
|
||||
|
||||
A logout button (🔒 Logout) automatically appears in the sidebar header when authentication is enabled. Simply click it to log out.
|
||||
|
||||
**Option 2: Navigate directly**
|
||||
|
||||
Visit `http://localhost:8000/logout` in your browser.
|
||||
|
||||
### Changing Password
|
||||
|
||||
1. Generate a new password hash:
|
||||
- **Docker**: `docker-compose exec notediscovery /app/generate_password_hash.sh`
|
||||
- **Local**: `python generate_password.py`
|
||||
2. Update `password_hash` in `config.yaml` with the new hash
|
||||
3. Restart the application:
|
||||
- **Docker**: `docker-compose restart`
|
||||
- **Local**: Restart uvicorn
|
||||
4. All existing sessions will remain valid until they expire
|
||||
|
||||
### Session Expiry
|
||||
|
||||
- **Default**: 7 days (604800 seconds)
|
||||
- **Custom**: Change `session_max_age` in `config.yaml`
|
||||
- Sessions are cleared when you log out
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Considerations
|
||||
|
||||
### ✅ What This Protects
|
||||
|
||||
- Unauthorized access to your notes
|
||||
- Viewing, creating, editing, and deleting notes
|
||||
- All API endpoints
|
||||
|
||||
### ⚠️ What This Doesn't Protect
|
||||
|
||||
This is a **simple authentication system** designed for **self-hosted, single-user** deployments. It is **NOT** suitable for:
|
||||
|
||||
- ❌ Multi-user environments
|
||||
- ❌ Public internet exposure without HTTPS
|
||||
- ❌ Production SaaS applications
|
||||
- ❌ Compliance requirements (HIPAA, GDPR, etc.)
|
||||
|
||||
### 🛡️ Best Practices
|
||||
|
||||
1. **Use HTTPS** - Always run behind a reverse proxy (nginx, Caddy) with SSL/TLS
|
||||
2. **Strong Password** - Use at least 12 characters with mixed case, numbers, and symbols
|
||||
3. **Unique Secret Key** - Never reuse secret keys across applications
|
||||
4. **Keep Config Secure** - Don't commit `config.yaml` with real credentials to version control
|
||||
5. **VPN/Private Network** - Keep NoteDiscovery on a private network or behind a VPN
|
||||
|
||||
---
|
||||
|
||||
## 🚫 Disabling Authentication
|
||||
|
||||
To disable authentication and allow open access:
|
||||
|
||||
```yaml
|
||||
security:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
Restart the application, and authentication will be bypassed.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### "Invalid Password" Error
|
||||
|
||||
- **Check password hash**: Ensure the hash in `config.yaml` matches your password
|
||||
- **Regenerate hash**: Run `python generate_password.py` again
|
||||
- **Check encoding**: Password must be UTF-8 encoded
|
||||
|
||||
### "Not authenticated" Error
|
||||
|
||||
- **Check session**: Your session may have expired (default: 7 days)
|
||||
- **Clear cookies**: Clear your browser cookies and log in again
|
||||
- **Check config**: Ensure `enabled: true` in `config.yaml`
|
||||
|
||||
### Login Page Not Showing
|
||||
|
||||
- **Check config**: Verify `enabled: true` in `config.yaml`
|
||||
- **Check routes**: Visit `/login` directly
|
||||
- **Check logs**: Look for errors in the console
|
||||
|
||||
### Can't Access `/logout`
|
||||
|
||||
- You must be logged in to access `/logout`
|
||||
- Clear your browser cookies manually if needed
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Customizing Login Page
|
||||
|
||||
The login page (`frontend/login.html`) can be customized:
|
||||
|
||||
- **Theme**: Automatically inherits the theme you selected in the main app (from localStorage)
|
||||
- **Logo**: Uses `/static/logo.svg`
|
||||
- **Styling**: Edit the CSS in the `<style>` section
|
||||
- **Content**: Modify the HTML directly
|
||||
- **Error Messages**: Displayed inline in red when login fails (no separate error page)
|
||||
|
||||
**Note:** The theme selector is intentionally hidden on the login page to keep it clean. Users will see the theme they selected in the main app.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Docker Deployment
|
||||
|
||||
When using Docker, mount your `config.yaml` with credentials:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
notediscovery:
|
||||
image: ghcr.io/yourusername/notediscovery:latest
|
||||
volumes:
|
||||
- ./config.yaml:/app/config.yaml
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "8000:8000"
|
||||
```
|
||||
|
||||
**Security Note**: Don't build credentials into the Docker image. Always mount them as a volume.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Multi-User Support
|
||||
|
||||
This authentication system is designed for **single-user** deployments. If you need multi-user support:
|
||||
|
||||
1. Run separate instances (one per user)
|
||||
2. Use Docker containers with different ports
|
||||
3. Use a reverse proxy for routing
|
||||
|
||||
For enterprise/multi-user needs, consider:
|
||||
- OAuth 2.0 / OpenID Connect
|
||||
- Database-backed user management
|
||||
- Role-based access control (RBAC)
|
||||
|
||||
---
|
||||
|
||||
## 📝 Technical Details
|
||||
|
||||
### Password Hashing
|
||||
|
||||
- **Algorithm**: bcrypt
|
||||
- **Rounds**: 12 (default)
|
||||
- **Salt**: Automatically generated per password
|
||||
|
||||
### Session Management
|
||||
|
||||
- **Storage**: Server-side session cookies
|
||||
- **Signing**: HMAC with secret key
|
||||
- **Security**: HttpOnly, SameSite=Lax
|
||||
- **Expiry**: Configurable (default 7 days)
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `bcrypt` - Password hashing
|
||||
- `itsdangerous` - Session signing
|
||||
- `starlette` - Session middleware
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- [bcrypt Documentation](https://github.com/pyca/bcrypt/)
|
||||
- [FastAPI Security](https://fastapi.tiangolo.com/tutorial/security/)
|
||||
- [OWASP Authentication Cheatsheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
|
||||
|
||||
---
|
||||
|
||||
**Need Help?** Open an issue on [GitHub](https://github.com/yourusername/notediscovery/issues) or consult the [README.md](README.md).
|
||||
|
||||
|
|
@ -27,9 +27,11 @@ COPY frontend ./frontend
|
|||
COPY config.yaml .
|
||||
COPY plugins ./plugins
|
||||
COPY themes ./themes
|
||||
COPY generate_password_hash.sh .
|
||||
|
||||
# Create data directories
|
||||
RUN mkdir -p data/notes data/search_index
|
||||
# Create data directories and make password script executable
|
||||
RUN mkdir -p data/notes data/search_index && \
|
||||
chmod +x generate_password_hash.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -32,6 +32,7 @@ NoteDiscovery is a **lightweight, self-hosted note-taking application** that put
|
|||
### Key Benefits
|
||||
|
||||
- 🔒 **Total Privacy** - Your notes never leave your server
|
||||
- 🔐 **Optional Authentication** - Simple password protection for self-hosted deployments
|
||||
- 💰 **Zero Cost** - No subscriptions, no hidden fees
|
||||
- 🚀 **Fast & Lightweight** - Instant search and navigation
|
||||
- 🎨 **Beautiful Themes** - Multiple themes, easy to customize
|
||||
|
|
@ -202,6 +203,7 @@ Once you've started NoteDiscovery, you'll find comprehensive guides on:
|
|||
- 🧮 **MATHJAX.md** - LaTeX/Math notation examples and syntax reference
|
||||
- 🔌 **PLUGINS.md** - Plugin system and available plugins
|
||||
- 🌐 **API.md** - REST API documentation and examples
|
||||
- 🔐 **AUTHENTICATION.md** - Enable password protection for your instance
|
||||
|
||||
**Can't wait to start the app?** Browse the documentation notes directly on GitHub in the [`data/notes/`](data/notes/) folder!
|
||||
|
||||
|
|
@ -217,15 +219,16 @@ NoteDiscovery is designed for **self-hosted, private use**. Please keep these se
|
|||
|
||||
### Network Security
|
||||
- ⚠️ **Do NOT expose directly to the internet** without additional security measures
|
||||
- Run behind a reverse proxy (nginx, Caddy) with HTTPS and authentication if needed
|
||||
- Run behind a reverse proxy (nginx, Caddy) with HTTPS for production use
|
||||
- Keep it on your local network or use a VPN for remote access
|
||||
- By default, the app listens on `0.0.0.0:8000` (all network interfaces)
|
||||
|
||||
### No Built-in Authentication
|
||||
- The app has **no authentication by design** (single-user, self-hosted)
|
||||
- Anyone with network access can read and modify your notes
|
||||
- Use network-level security (firewall, VPN) for access control
|
||||
- Consider adding authentication via reverse proxy if needed
|
||||
### Authentication
|
||||
- **Optional password protection** is available (disabled by default)
|
||||
- See **[AUTHENTICATION.md](AUTHENTICATION.md)** for setup instructions
|
||||
- Simple to enable with Docker: `docker-compose exec notediscovery /app/generate_password_hash.sh`
|
||||
- Perfect for single-user or small team deployments
|
||||
- For multi-user setups, consider a reverse proxy with OAuth/SSO
|
||||
|
||||
### Data Privacy
|
||||
- Your notes are stored as **plain text markdown files** in `data/notes/`
|
||||
|
|
@ -239,7 +242,7 @@ NoteDiscovery is designed for **self-hosted, private use**. Please keep these se
|
|||
- Review and audit any plugins you install
|
||||
- Set appropriate file permissions on the `data/` directory
|
||||
|
||||
**TL;DR**: Perfect for personal use on your local machine or home network. Add a reverse proxy with authentication if exposing to wider networks.
|
||||
**TL;DR**: Perfect for personal use on your local machine or home network. Enable built-in password protection if needed, or use a reverse proxy with authentication if exposing to wider networks.
|
||||
|
||||
## 📄 License
|
||||
|
||||
|
|
|
|||
175
backend/main.py
175
backend/main.py
|
|
@ -3,10 +3,11 @@ NoteDiscovery - Self-Hosted Markdown Knowledge Base
|
|||
Main FastAPI application
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, HTTPException, UploadFile, File
|
||||
from fastapi import FastAPI, HTTPException, UploadFile, File, Request, Form, Depends
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
import os
|
||||
import yaml
|
||||
import json
|
||||
|
|
@ -14,6 +15,7 @@ from pathlib import Path
|
|||
from typing import List, Optional
|
||||
import aiofiles
|
||||
from datetime import datetime
|
||||
import bcrypt
|
||||
|
||||
from .utils import (
|
||||
get_all_notes,
|
||||
|
|
@ -55,6 +57,15 @@ app.add_middleware(
|
|||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 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
|
||||
same_site='lax',
|
||||
https_only=False # Set to True if using HTTPS
|
||||
)
|
||||
|
||||
# Ensure required directories exist
|
||||
ensure_directories(config)
|
||||
|
||||
|
|
@ -69,8 +80,131 @@ static_path = Path(__file__).parent.parent / "frontend"
|
|||
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Custom Exception Handlers
|
||||
# ============================================================================
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
"""
|
||||
Custom exception handler for HTTP exceptions.
|
||||
Handles 401 errors specially:
|
||||
- For API requests: return JSON error
|
||||
- For page requests: redirect to login
|
||||
"""
|
||||
# Only handle 401 errors specially
|
||||
if exc.status_code == 401:
|
||||
# Check if this is an API request
|
||||
if request.url.path.startswith('/api/'):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": exc.detail}
|
||||
)
|
||||
|
||||
# For page requests, redirect to login
|
||||
return RedirectResponse(url='/login', status_code=303)
|
||||
|
||||
# For all other HTTP exceptions, return default JSON response
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail}
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Authentication Helpers
|
||||
# ============================================================================
|
||||
|
||||
def auth_enabled() -> bool:
|
||||
"""Check if authentication is enabled in config"""
|
||||
return config.get('security', {}).get('enabled', False)
|
||||
|
||||
|
||||
async def require_auth(request: Request):
|
||||
"""Dependency to require authentication on protected routes"""
|
||||
if not auth_enabled():
|
||||
return # Auth disabled, allow all
|
||||
|
||||
if not request.session.get('authenticated'):
|
||||
# Always raise exception - route handlers will catch and redirect as needed
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
|
||||
def verify_password(password: str) -> bool:
|
||||
"""Verify password against stored hash"""
|
||||
password_hash = config.get('security', {}).get('password_hash', '')
|
||||
if not password_hash:
|
||||
return False
|
||||
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
|
||||
except Exception as e:
|
||||
print(f"Password verification error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Authentication Routes
|
||||
# ============================================================================
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, error: str = None):
|
||||
"""Serve the login page"""
|
||||
if not auth_enabled():
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
# If already authenticated, redirect to home
|
||||
if request.session.get('authenticated'):
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
# Serve login page
|
||||
login_path = static_path / "login.html"
|
||||
async with aiofiles.open(login_path, 'r', encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
|
||||
# Inject error message if present
|
||||
if error:
|
||||
content = content.replace('<!-- ERROR_PLACEHOLDER -->',
|
||||
f'<div class="error-message">❌ {error}</div>')
|
||||
|
||||
return content
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
async def login(request: Request, password: str = Form(...)):
|
||||
"""Handle login form submission"""
|
||||
if not auth_enabled():
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
# Verify password
|
||||
if verify_password(password):
|
||||
# Set session
|
||||
request.session['authenticated'] = True
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
else:
|
||||
# Redirect back to login with error message
|
||||
return RedirectResponse(url="/login?error=Invalid+password.+Please+try+again.", status_code=303)
|
||||
|
||||
|
||||
@app.get("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Log out the current user"""
|
||||
request.session.clear()
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
|
||||
@app.get("/favicon.ico")
|
||||
async def favicon():
|
||||
"""Serve favicon (redirect to SVG)"""
|
||||
return RedirectResponse(url="/static/favicon.svg", status_code=301)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Application Routes
|
||||
# ============================================================================
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def root():
|
||||
async def root(request: Request, _auth: None = Depends(require_auth)):
|
||||
"""Serve the main application page"""
|
||||
index_path = static_path / "index.html"
|
||||
async with aiofiles.open(index_path, 'r', encoding='utf-8') as f:
|
||||
|
|
@ -237,7 +371,10 @@ async def get_config():
|
|||
"name": config['app']['name'],
|
||||
"tagline": config['app']['tagline'],
|
||||
"version": config['app']['version'],
|
||||
"searchEnabled": config['search']['enabled']
|
||||
"searchEnabled": config['search']['enabled'],
|
||||
"security": {
|
||||
"enabled": config.get('security', {}).get('enabled', False)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -262,7 +399,7 @@ async def get_theme(theme_id: str):
|
|||
|
||||
|
||||
@app.post("/api/folders")
|
||||
async def create_new_folder(data: dict):
|
||||
async def create_new_folder(data: dict, _auth: None = Depends(require_auth)):
|
||||
"""Create a new folder"""
|
||||
try:
|
||||
folder_path = data.get('path', '')
|
||||
|
|
@ -284,7 +421,7 @@ async def create_new_folder(data: dict):
|
|||
|
||||
|
||||
@app.post("/api/notes/move")
|
||||
async def move_note_endpoint(data: dict):
|
||||
async def move_note_endpoint(data: dict, _auth: None = Depends(require_auth)):
|
||||
"""Move a note to a different folder"""
|
||||
try:
|
||||
old_path = data.get('oldPath', '')
|
||||
|
|
@ -312,7 +449,7 @@ async def move_note_endpoint(data: dict):
|
|||
|
||||
|
||||
@app.post("/api/folders/move")
|
||||
async def move_folder_endpoint(data: dict):
|
||||
async def move_folder_endpoint(data: dict, _auth: None = Depends(require_auth)):
|
||||
"""Move a folder to a different location"""
|
||||
try:
|
||||
old_path = data.get('oldPath', '')
|
||||
|
|
@ -337,7 +474,7 @@ async def move_folder_endpoint(data: dict):
|
|||
|
||||
|
||||
@app.post("/api/folders/rename")
|
||||
async def rename_folder_endpoint(data: dict):
|
||||
async def rename_folder_endpoint(data: dict, _auth: None = Depends(require_auth)):
|
||||
"""Rename a folder"""
|
||||
try:
|
||||
old_path = data.get('oldPath', '')
|
||||
|
|
@ -362,7 +499,7 @@ async def rename_folder_endpoint(data: dict):
|
|||
|
||||
|
||||
@app.delete("/api/folders/{folder_path:path}")
|
||||
async def delete_folder_endpoint(folder_path: str):
|
||||
async def delete_folder_endpoint(folder_path: str, _auth: None = Depends(require_auth)):
|
||||
"""Delete a folder and all its contents"""
|
||||
try:
|
||||
if not folder_path:
|
||||
|
|
@ -383,7 +520,7 @@ async def delete_folder_endpoint(folder_path: str):
|
|||
|
||||
|
||||
@app.get("/api/notes")
|
||||
async def list_notes():
|
||||
async def list_notes(_auth: None = Depends(require_auth)):
|
||||
"""List all notes with metadata"""
|
||||
try:
|
||||
notes = get_all_notes(config['storage']['notes_dir'])
|
||||
|
|
@ -394,7 +531,7 @@ async def list_notes():
|
|||
|
||||
|
||||
@app.get("/api/notes/{note_path:path}")
|
||||
async def get_note(note_path: str):
|
||||
async def get_note(note_path: str, _auth: None = Depends(require_auth)):
|
||||
"""Get a specific note's content"""
|
||||
try:
|
||||
content = get_note_content(config['storage']['notes_dir'], note_path)
|
||||
|
|
@ -422,7 +559,7 @@ async def get_note(note_path: str):
|
|||
|
||||
|
||||
@app.post("/api/notes/{note_path:path}")
|
||||
async def create_or_update_note(note_path: str, content: dict):
|
||||
async def create_or_update_note(note_path: str, content: dict, _auth: None = Depends(require_auth)):
|
||||
"""Create or update a note"""
|
||||
try:
|
||||
note_content = content.get('content', '')
|
||||
|
|
@ -460,7 +597,7 @@ async def create_or_update_note(note_path: str, content: dict):
|
|||
|
||||
|
||||
@app.delete("/api/notes/{note_path:path}")
|
||||
async def remove_note(note_path: str):
|
||||
async def remove_note(note_path: str, _auth: None = Depends(require_auth)):
|
||||
"""Delete a note"""
|
||||
try:
|
||||
success = delete_note(config['storage']['notes_dir'], note_path)
|
||||
|
|
@ -480,7 +617,7 @@ async def remove_note(note_path: str):
|
|||
|
||||
|
||||
@app.get("/api/search")
|
||||
async def search(q: str):
|
||||
async def search(q: str, _auth: None = Depends(require_auth)):
|
||||
"""Search notes by content"""
|
||||
try:
|
||||
if not config['search']['enabled']:
|
||||
|
|
@ -499,7 +636,7 @@ async def search(q: str):
|
|||
|
||||
|
||||
@app.get("/api/graph")
|
||||
async def get_graph():
|
||||
async def get_graph(_auth: None = Depends(require_auth)):
|
||||
"""Get graph data for visualization"""
|
||||
try:
|
||||
notes = get_all_notes(config['storage']['notes_dir'])
|
||||
|
|
@ -529,13 +666,13 @@ async def get_graph():
|
|||
|
||||
|
||||
@app.get("/api/plugins")
|
||||
async def list_plugins():
|
||||
async def list_plugins(_auth: None = Depends(require_auth)):
|
||||
"""List all available plugins"""
|
||||
return {"plugins": plugin_manager.list_plugins()}
|
||||
|
||||
|
||||
@app.get("/api/plugins/note_stats/calculate")
|
||||
async def calculate_note_stats(content: str):
|
||||
async def calculate_note_stats(content: str, _auth: None = Depends(require_auth)):
|
||||
"""Calculate statistics for note content (if plugin enabled)"""
|
||||
try:
|
||||
plugin = plugin_manager.plugins.get('note_stats')
|
||||
|
|
@ -549,7 +686,7 @@ async def calculate_note_stats(content: str):
|
|||
|
||||
|
||||
@app.post("/api/plugins/{plugin_name}/toggle")
|
||||
async def toggle_plugin(plugin_name: str, enabled: dict):
|
||||
async def toggle_plugin(plugin_name: str, enabled: dict, _auth: None = Depends(require_auth)):
|
||||
"""Enable or disable a plugin"""
|
||||
try:
|
||||
is_enabled = enabled.get('enabled', False)
|
||||
|
|
@ -580,7 +717,7 @@ async def health_check():
|
|||
# Catch-all route for SPA (Single Page Application) routing
|
||||
# This allows URLs like /folder/note to work for direct navigation
|
||||
@app.get("/{full_path:path}", response_class=HTMLResponse)
|
||||
async def catch_all(full_path: str):
|
||||
async def catch_all(full_path: str, request: Request, _auth: None = Depends(require_auth)):
|
||||
"""
|
||||
Serve index.html for all non-API routes.
|
||||
This enables client-side routing (e.g., /folder/note)
|
||||
|
|
|
|||
22
config.yaml
22
config.yaml
|
|
@ -20,6 +20,24 @@ search:
|
|||
index_dir: "./data/search_index"
|
||||
|
||||
security:
|
||||
# Add authentication later if needed
|
||||
require_auth: false
|
||||
# Authentication settings
|
||||
# Set enabled to true to require login
|
||||
enabled: true
|
||||
|
||||
# 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
|
||||
# INSTRUCTIONS:
|
||||
# 1. Run: pip install bcrypt
|
||||
# 2. Run: python generate_password.py
|
||||
# 3. Enter your desired password
|
||||
# 4. Copy the generated hash below
|
||||
# 5. Set enabled: true above
|
||||
# 6. Restart the application
|
||||
password_hash: "$2b$12$t/6PGExFzdpU2PUta0iVY.eDQwvu63kH.c/d4bEnnHaQ5CspH1yrG"
|
||||
|
||||
# Session expiry in seconds (default: 7 days)
|
||||
session_max_age: 604800
|
||||
|
||||
|
|
|
|||
|
|
@ -702,7 +702,20 @@
|
|||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-xs" 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) -->
|
||||
<div x-data="{ authEnabled: false }" x-init="fetch('/api/config').then(r => r.json()).then(d => authEnabled = d.security?.enabled || false)">
|
||||
<a x-show="authEnabled"
|
||||
href="/logout"
|
||||
class="flex items-center justify-center gap-2 px-3 py-2 text-sm rounded transition-colors"
|
||||
style="background-color: var(--bg-secondary); color: var(--text-secondary); border: 1px solid var(--border-primary);"
|
||||
onmouseover="this.style.backgroundColor='var(--bg-hover)'; this.style.color='var(--text-primary)'"
|
||||
onmouseout="this.style.backgroundColor='var(--bg-secondary)'; this.style.color='var(--text-secondary)'">
|
||||
<span>🔒</span>
|
||||
<span>Logout</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - NoteDiscovery</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
|
||||
<!-- Theme styles will be loaded dynamically -->
|
||||
<script>
|
||||
// Load theme immediately (same approach as index.html)
|
||||
(async function() {
|
||||
const savedTheme = localStorage.getItem('noteDiscoveryTheme') || 'light';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/themes/${savedTheme}`);
|
||||
const data = await response.json();
|
||||
|
||||
// Create style element with theme CSS
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.id = 'dynamic-theme';
|
||||
styleEl.textContent = data.css;
|
||||
document.head.appendChild(styleEl);
|
||||
|
||||
// Set data attribute for theme-specific selectors
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
} catch (error) {
|
||||
console.error('Failed to load theme:', error);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 3rem 2rem;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 2px solid var(--border-primary);
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="password"]:focus {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px var(--accent-light, rgba(102, 126, 234, 0.1));
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s, background-color 0.3s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--accent-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px var(--accent-light);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #fee;
|
||||
color: #c53030;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid #fcc;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 2rem;
|
||||
color: var(--text-tertiary, var(--text-secondary));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 480px) {
|
||||
.login-container {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<img src="/static/logo.svg" alt="NoteDiscovery" class="logo">
|
||||
<h1>NoteDiscovery</h1>
|
||||
<p class="tagline">Your Self-Hosted Knowledge Base</p>
|
||||
|
||||
<!-- ERROR_PLACEHOLDER -->
|
||||
|
||||
<form method="post" action="/login">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autofocus
|
||||
>
|
||||
<button type="submit">🔓 Unlock</button>
|
||||
</form>
|
||||
|
||||
<div class="footer">
|
||||
🔒 Secure & Self-Hosted
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Helper script to generate a password hash for NoteDiscovery authentication.
|
||||
Usage: python generate_password.py
|
||||
"""
|
||||
|
||||
import getpass
|
||||
import bcrypt
|
||||
|
||||
def generate_password_hash():
|
||||
"""Generate a bcrypt password hash"""
|
||||
print("=== NoteDiscovery Password Hash Generator ===\n")
|
||||
|
||||
# Get password from user (hidden input)
|
||||
password = getpass.getpass("Enter your password: ")
|
||||
password_confirm = getpass.getpass("Confirm your password: ")
|
||||
|
||||
# Check if passwords match
|
||||
if password != password_confirm:
|
||||
print("\n❌ Error: Passwords do not match!")
|
||||
return
|
||||
|
||||
if len(password) < 4:
|
||||
print("\n⚠️ Warning: Password is too short! Use at least 8 characters for better security.")
|
||||
proceed = input("Continue anyway? (y/N): ")
|
||||
if proceed.lower() != 'y':
|
||||
return
|
||||
|
||||
# Generate hash
|
||||
print("\n🔐 Generating password hash...")
|
||||
password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
print("\n✅ Password hash generated successfully!")
|
||||
print("\n" + "="*60)
|
||||
print("Copy this hash to your config.yaml:")
|
||||
print("="*60)
|
||||
print(f"\npassword_hash: \"{password_hash}\"")
|
||||
print("\n" + "="*60)
|
||||
print("\nExample config.yaml:")
|
||||
print("="*60)
|
||||
print("""
|
||||
security:
|
||||
enabled: true
|
||||
secret_key: "your_secret_key_here"
|
||||
password_hash: "{}"
|
||||
session_max_age: 604800
|
||||
""".format(password_hash))
|
||||
print("="*60)
|
||||
|
||||
print("\n💡 Don't forget to also set a secret key!")
|
||||
print(" Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\"")
|
||||
print("\n🔒 Keep your password and secret key secure!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
generate_password_hash()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n❌ Cancelled.")
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}")
|
||||
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
#!/bin/bash
|
||||
# Generate password hash inside Docker container
|
||||
# Usage: docker exec -it notediscovery /app/generate_password_hash.sh
|
||||
|
||||
echo "=========================================="
|
||||
echo " NoteDiscovery Password Hash Generator"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "This will generate a bcrypt hash for your password."
|
||||
echo ""
|
||||
|
||||
# Prompt for password
|
||||
read -s -p "Enter your password: " PASSWORD
|
||||
echo ""
|
||||
read -s -p "Confirm password: " PASSWORD2
|
||||
echo ""
|
||||
|
||||
# Check if passwords match
|
||||
if [ "$PASSWORD" != "$PASSWORD2" ]; then
|
||||
echo ""
|
||||
echo "❌ Passwords do not match!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if password is empty
|
||||
if [ -z "$PASSWORD" ]; then
|
||||
echo ""
|
||||
echo "❌ Password cannot be empty!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Generating hash..."
|
||||
echo ""
|
||||
|
||||
# Generate hash using Python
|
||||
HASH=$(python3 -c "import bcrypt; print(bcrypt.hashpw(b'$PASSWORD', bcrypt.gensalt()).decode())")
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "=========================================="
|
||||
echo " ✅ Password hash generated!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Copy this hash to your config.yaml:"
|
||||
echo ""
|
||||
echo "password_hash: \"$HASH\""
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Update config.yaml with the hash above"
|
||||
echo "2. Set enabled: true in the security section"
|
||||
echo "3. Restart the container: docker-compose restart"
|
||||
echo "=========================================="
|
||||
else
|
||||
echo "❌ Failed to generate hash"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -5,4 +5,6 @@ markdown==3.5.1
|
|||
pyyaml==6.0.1
|
||||
aiofiles==23.2.1
|
||||
cryptography==41.0.7
|
||||
bcrypt==4.1.2
|
||||
itsdangerous==2.1.2
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue