Merge pull request #15 from gamosoft/features/authentication
Features/authentication - added authentication - possible breaking change. simplified data folder - creation of releases on tag - removed search_index for now
This commit is contained in:
commit
7afa65e802
|
|
@ -1,12 +1,6 @@
|
||||||
name: Build and Push Docker Image to GHCR
|
name: Build and Push Docker Image to GHCR
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- closed
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- master
|
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- 'v*.*.*'
|
||||||
|
|
@ -18,19 +12,16 @@ env:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
# Only run if PR was merged (not just closed) or if triggered by tag/manual
|
|
||||||
if: |
|
|
||||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true) ||
|
|
||||||
github.event_name == 'push' ||
|
|
||||||
github.event_name == 'workflow_dispatch'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Fetch all history for release notes
|
||||||
|
|
||||||
- name: Set up QEMU for multi-architecture builds
|
- name: Set up QEMU for multi-architecture builds
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
@ -73,3 +64,70 @@ jobs:
|
||||||
- name: Image digest
|
- name: Image digest
|
||||||
run: echo "Image pushed with digest ${{ steps.build-push.outputs.digest }}"
|
run: echo "Image pushed with digest ${{ steps.build-push.outputs.digest }}"
|
||||||
|
|
||||||
|
- name: Generate release notes
|
||||||
|
id: release_notes
|
||||||
|
run: |
|
||||||
|
# Get the previous tag
|
||||||
|
PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
# Generate commit log - extract PR descriptions from merge commits
|
||||||
|
if [ -z "$PREVIOUS_TAG" ]; then
|
||||||
|
# First release, get only merge commits (PR merges)
|
||||||
|
RAW_COMMITS=$(git log --merges --pretty=format:"%s|||%b|||%h" --first-parent)
|
||||||
|
else
|
||||||
|
# Get merge commits since last tag
|
||||||
|
RAW_COMMITS=$(git log ${PREVIOUS_TAG}..HEAD --merges --pretty=format:"%s|||%b|||%h" --first-parent)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Process commits to extract meaningful messages
|
||||||
|
COMMITS=""
|
||||||
|
while IFS= read -r line; do
|
||||||
|
if [ -n "$line" ]; then
|
||||||
|
SUBJECT=$(echo "$line" | cut -d'|' -f1)
|
||||||
|
BODY=$(echo "$line" | cut -d'|' -f4 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||||
|
HASH=$(echo "$line" | cut -d'|' -f7)
|
||||||
|
|
||||||
|
# If body exists and is not empty, use it; otherwise use subject
|
||||||
|
if [ -n "$BODY" ] && [ "$BODY" != "" ]; then
|
||||||
|
# Capitalize first letter of body
|
||||||
|
MESSAGE=$(echo "$BODY" | sed 's/^./\U&/')
|
||||||
|
COMMITS="${COMMITS}- ${MESSAGE} (${HASH})"$'\n'
|
||||||
|
else
|
||||||
|
COMMITS="${COMMITS}- ${SUBJECT} (${HASH})"$'\n'
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done <<< "$RAW_COMMITS"
|
||||||
|
|
||||||
|
# Create release notes
|
||||||
|
echo "## What's Changed" > release_notes.md
|
||||||
|
echo "" >> release_notes.md
|
||||||
|
if [ -z "$COMMITS" ]; then
|
||||||
|
echo "- Minor updates and improvements" >> release_notes.md
|
||||||
|
else
|
||||||
|
echo "$COMMITS" >> release_notes.md
|
||||||
|
fi
|
||||||
|
echo "" >> release_notes.md
|
||||||
|
echo "## Docker Images" >> release_notes.md
|
||||||
|
echo "" >> release_notes.md
|
||||||
|
echo "This release is available as a Docker image:" >> release_notes.md
|
||||||
|
echo '```bash' >> release_notes.md
|
||||||
|
echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${GITHUB_REF_NAME#v}" >> release_notes.md
|
||||||
|
echo '```' >> release_notes.md
|
||||||
|
echo "" >> release_notes.md
|
||||||
|
if [ -z "$PREVIOUS_TAG" ]; then
|
||||||
|
echo "**Full Changelog**: https://github.com/${{ github.repository }}/commits/${GITHUB_REF_NAME}" >> release_notes.md
|
||||||
|
else
|
||||||
|
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREVIOUS_TAG}...${GITHUB_REF_NAME}" >> release_notes.md
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat release_notes.md
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
body_path: release_notes.md
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ env/
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
# Data directories
|
# Data directories
|
||||||
search_index/
|
data/
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,10 @@ COPY frontend ./frontend
|
||||||
COPY config.yaml .
|
COPY config.yaml .
|
||||||
COPY plugins ./plugins
|
COPY plugins ./plugins
|
||||||
COPY themes ./themes
|
COPY themes ./themes
|
||||||
|
COPY generate_password.py .
|
||||||
|
|
||||||
# Create data directories
|
# Create data directory
|
||||||
RUN mkdir -p data/notes data/search_index
|
RUN mkdir -p data
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
|
||||||
63
README.md
63
README.md
|
|
@ -32,6 +32,7 @@ NoteDiscovery is a **lightweight, self-hosted note-taking application** that put
|
||||||
### Key Benefits
|
### Key Benefits
|
||||||
|
|
||||||
- 🔒 **Total Privacy** - Your notes never leave your server
|
- 🔒 **Total Privacy** - Your notes never leave your server
|
||||||
|
- 🔐 **Optional Authentication** - Simple password protection for self-hosted deployments
|
||||||
- 💰 **Zero Cost** - No subscriptions, no hidden fees
|
- 💰 **Zero Cost** - No subscriptions, no hidden fees
|
||||||
- 🚀 **Fast & Lightweight** - Instant search and navigation
|
- 🚀 **Fast & Lightweight** - Instant search and navigation
|
||||||
- 🎨 **Beautiful Themes** - Multiple themes, easy to customize
|
- 🎨 **Beautiful Themes** - Multiple themes, easy to customize
|
||||||
|
|
@ -47,20 +48,21 @@ NoteDiscovery is a **lightweight, self-hosted note-taking application** that put
|
||||||
|
|
||||||
Use the pre-built image directly from GHCR - no building required!
|
Use the pre-built image directly from GHCR - no building required!
|
||||||
|
|
||||||
> **💡 Tip**: Always use `ghcr.io/gamosoft/notediscovery:latest` to get the newest features and fixes. Images are automatically built when PRs are merged to main.
|
> **💡 Tip**: Always use `ghcr.io/gamosoft/notediscovery:latest` to get the newest features and fixes.
|
||||||
|
|
||||||
> **📁 Important - Volume Mapping**: The container needs local folders/files to work:
|
> **📁 Important - Volume Mapping**: The container needs local folders/files to work:
|
||||||
> - **Required**: `data` folder (your notes will be stored here)
|
> - **Required**: `data` folder - **Your personal notes** will be stored here (create an empty folder)
|
||||||
> - **Required**: `themes` folder with theme `.css` files (at least light.css and dark.css)
|
> - **Required**: `themes` folder with theme `.css` files (at least a single theme must exist)
|
||||||
> - **Required**: `plugins` folder (can be empty for basic functionality)
|
> - **Required**: `plugins` folder (can be empty for basic functionality)
|
||||||
> - **Required**: `config.yaml` file (needed for the app to run)
|
> - **Required**: `config.yaml` file (needed for the app to run)
|
||||||
|
> - **Optional**: `documentation` folder - If you cloned the repo, mount this to view app docs inside NoteDiscovery
|
||||||
>
|
>
|
||||||
> **Setup Options:**
|
> **Setup Options:**
|
||||||
>
|
>
|
||||||
> 1. **Minimal** (quick test - download just the essentials):
|
> 1. **Minimal** (quick test - download just the essentials):
|
||||||
> ```bash
|
> ```bash
|
||||||
> # Linux/macOS
|
> # Linux/macOS
|
||||||
> mkdir -p data plugins themes
|
> mkdir -p data plugins themes # data/ is for YOUR notes
|
||||||
> curl -O https://raw.githubusercontent.com/gamosoft/notediscovery/main/config.yaml
|
> curl -O https://raw.githubusercontent.com/gamosoft/notediscovery/main/config.yaml
|
||||||
> # Download at least light and dark themes
|
> # Download at least light and dark themes
|
||||||
> curl -o themes/light.css https://raw.githubusercontent.com/gamosoft/notediscovery/main/themes/light.css
|
> curl -o themes/light.css https://raw.githubusercontent.com/gamosoft/notediscovery/main/themes/light.css
|
||||||
|
|
@ -69,20 +71,23 @@ Use the pre-built image directly from GHCR - no building required!
|
||||||
>
|
>
|
||||||
> ```powershell
|
> ```powershell
|
||||||
> # Windows PowerShell
|
> # Windows PowerShell
|
||||||
> mkdir data, plugins, themes -Force
|
> mkdir data, plugins, themes -Force # data\ is for YOUR notes
|
||||||
> Invoke-WebRequest -Uri https://raw.githubusercontent.com/gamosoft/notediscovery/main/config.yaml -OutFile config.yaml
|
> Invoke-WebRequest -Uri https://raw.githubusercontent.com/gamosoft/notediscovery/main/config.yaml -OutFile config.yaml
|
||||||
> # Download at least light and dark themes
|
> # Download at least light and dark themes
|
||||||
> Invoke-WebRequest -Uri https://raw.githubusercontent.com/gamosoft/notediscovery/main/themes/light.css -OutFile themes/light.css
|
> Invoke-WebRequest -Uri https://raw.githubusercontent.com/gamosoft/notediscovery/main/themes/light.css -OutFile themes/light.css
|
||||||
> Invoke-WebRequest -Uri https://raw.githubusercontent.com/gamosoft/notediscovery/main/themes/dark.css -OutFile themes/dark.css
|
> Invoke-WebRequest -Uri https://raw.githubusercontent.com/gamosoft/notediscovery/main/themes/dark.css -OutFile themes/dark.css
|
||||||
> ```
|
> ```
|
||||||
>
|
>
|
||||||
> 2. **Full Setup** (recommended - includes all themes, plugins, sample notes):
|
> 2. **Full Setup** (recommended - includes all themes, plugins, and documentation):
|
||||||
> ```bash
|
> ```bash
|
||||||
> git clone https://github.com/gamosoft/notediscovery.git
|
> git clone https://github.com/gamosoft/notediscovery.git
|
||||||
> cd notediscovery
|
> cd notediscovery
|
||||||
> # Now you have everything - run docker-compose below
|
> # The data/ folder is empty - for your personal notes
|
||||||
|
> # 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.
|
||||||
|
|
||||||
**Option 1: Docker Compose (Recommended)**
|
**Option 1: Docker Compose (Recommended)**
|
||||||
|
|
||||||
> 💡 **Multi-Architecture Support**: Docker images are available for both `x86_64` and `ARM64` (Raspberry Pi, Apple Silicon, etc.)
|
> 💡 **Multi-Architecture Support**: Docker images are available for both `x86_64` and `ARM64` (Raspberry Pi, Apple Silicon, etc.)
|
||||||
|
|
@ -95,6 +100,7 @@ curl -O https://raw.githubusercontent.com/gamosoft/notediscovery/main/docker-com
|
||||||
docker-compose -f docker-compose.ghcr.yml up -d
|
docker-compose -f docker-compose.ghcr.yml up -d
|
||||||
|
|
||||||
# Access at http://localhost:8000
|
# Access at http://localhost:8000
|
||||||
|
# Login with default password: admin
|
||||||
|
|
||||||
# View logs
|
# View logs
|
||||||
docker-compose -f docker-compose.ghcr.yml logs -f
|
docker-compose -f docker-compose.ghcr.yml logs -f
|
||||||
|
|
@ -197,15 +203,25 @@ python run.py
|
||||||
Want to learn more? **The full documentation lives inside the app as interactive notes!**
|
Want to learn more? **The full documentation lives inside the app as interactive notes!**
|
||||||
|
|
||||||
Once you've started NoteDiscovery, you'll find comprehensive guides on:
|
Once you've started NoteDiscovery, you'll find comprehensive guides on:
|
||||||
- 🎨 **THEMES.md** - Theme customization and creating custom themes
|
- 🎨 **[THEMES.md](documentation/THEMES.md)** - Theme customization and creating custom themes
|
||||||
- ✨ **FEATURES.md** - Complete feature list and keyboard shortcuts
|
- ✨ **[FEATURES.md](documentation/FEATURES.md)** - Complete feature list and keyboard shortcuts
|
||||||
- 🧮 **MATHJAX.md** - LaTeX/Math notation examples and syntax reference
|
- 🧮 **[MATHJAX.md](documentation/MATHJAX.md)** - LaTeX/Math notation examples and syntax reference
|
||||||
- 🔌 **PLUGINS.md** - Plugin system and available plugins
|
- 🔌 **[PLUGINS.md](documentation/PLUGINS.md)** - Plugin system and available plugins
|
||||||
- 🌐 **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
|
||||||
|
|
||||||
**Can't wait to start the app?** Browse the documentation notes directly on GitHub in the [`data/notes/`](data/notes/) folder!
|
**Can't wait to start the app?** Browse the documentation notes directly on GitHub in the [`documentation/`](documentation/) folder!
|
||||||
|
|
||||||
💡 **Tip:** These documentation files are regular markdown notes—edit them, add your own notes, or use them as templates. It's your knowledge base!
|
💡 **Pro Tip:** If you clone this repository, you can mount the `documentation/` folder to view these docs inside the app:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# In your docker-compose.yml
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data # Your personal notes
|
||||||
|
- ./documentation:/app/data/docs:ro # Mount docs subfolder inside the data folder (read-only)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then access them at `http://localhost:8000` - the docs will appear as a `docs/` folder in the file browser!
|
||||||
|
|
||||||
## 💖 Support Development
|
## 💖 Support Development
|
||||||
|
|
||||||
|
|
@ -217,18 +233,21 @@ NoteDiscovery is designed for **self-hosted, private use**. Please keep these se
|
||||||
|
|
||||||
### Network Security
|
### Network Security
|
||||||
- ⚠️ **Do NOT expose directly to the internet** without additional security measures
|
- ⚠️ **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
|
- 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)
|
- By default, the app listens on `0.0.0.0:8000` (all network interfaces)
|
||||||
|
|
||||||
### No Built-in Authentication
|
### Authentication
|
||||||
- The app has **no authentication by design** (single-user, self-hosted)
|
- **Password protection is ENABLED by default** with password: `admin`
|
||||||
- Anyone with network access can read and modify your notes
|
- ⚠️ **CHANGE THE DEFAULT PASSWORD IMMEDIATELY** if exposing to a network!
|
||||||
- Use network-level security (firewall, VPN) for access control
|
- See **[AUTHENTICATION.md](documentation/AUTHENTICATION.md)** for complete setup instructions
|
||||||
- Consider adding authentication via reverse proxy if needed
|
- To disable auth, set `security.enabled: false` in `config.yaml`
|
||||||
|
- Change password with Docker: `docker-compose exec notediscovery python generate_password.py`
|
||||||
|
- Perfect for single-user or small team deployments
|
||||||
|
- For multi-user setups, consider a reverse proxy with OAuth/SSO
|
||||||
|
|
||||||
### Data Privacy
|
### Data Privacy
|
||||||
- Your notes are stored as **plain text markdown files** in `data/notes/`
|
- Your notes are stored as **plain text markdown files** in the `data/` folder
|
||||||
- No data is sent to external services
|
- No data is sent to external services
|
||||||
- Regular backups are recommended
|
- Regular backups are recommended
|
||||||
|
|
||||||
|
|
@ -239,7 +258,7 @@ NoteDiscovery is designed for **self-hosted, private use**. Please keep these se
|
||||||
- Review and audit any plugins you install
|
- Review and audit any plugins you install
|
||||||
- Set appropriate file permissions on the `data/` directory
|
- 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
|
## 📄 License
|
||||||
|
|
||||||
|
|
|
||||||
210
backend/main.py
210
backend/main.py
|
|
@ -3,10 +3,11 @@ NoteDiscovery - Self-Hosted Markdown Knowledge Base
|
||||||
Main FastAPI application
|
Main FastAPI application
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, UploadFile, File
|
from fastapi import FastAPI, HTTPException, UploadFile, File, Request, Form, Depends, APIRouter
|
||||||
from fastapi.staticfiles import StaticFiles
|
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 fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
import os
|
import os
|
||||||
import yaml
|
import yaml
|
||||||
import json
|
import json
|
||||||
|
|
@ -14,6 +15,7 @@ from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import aiofiles
|
import aiofiles
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
get_all_notes,
|
get_all_notes,
|
||||||
|
|
@ -55,6 +57,15 @@ app.add_middleware(
|
||||||
allow_headers=["*"],
|
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 required directories exist
|
||||||
ensure_directories(config)
|
ensure_directories(config)
|
||||||
|
|
||||||
|
|
@ -69,8 +80,144 @@ static_path = Path(__file__).parent.parent / "frontend"
|
||||||
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
# ============================================================================
|
||||||
async def root():
|
# 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_CLASS_PLACEHOLDER -->', 'class="error"')
|
||||||
|
content = content.replace('<!-- ERROR_MESSAGE_PLACEHOLDER -->',
|
||||||
|
f'<div class="error-message">{error}</div>')
|
||||||
|
else:
|
||||||
|
content = content.replace('<!-- ERROR_CLASS_PLACEHOLDER -->', '')
|
||||||
|
content = content.replace('<!-- ERROR_MESSAGE_PLACEHOLDER -->', '')
|
||||||
|
|
||||||
|
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=Incorrect+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)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Routers with Authentication
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Create API router with authentication dependency applied globally
|
||||||
|
api_router = APIRouter(
|
||||||
|
prefix="/api",
|
||||||
|
dependencies=[Depends(require_auth)] # Apply auth to ALL routes in this router
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create pages router with authentication dependency applied globally
|
||||||
|
pages_router = APIRouter(
|
||||||
|
dependencies=[Depends(require_auth)] # Apply auth to ALL routes in this router
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Application Routes (with auth via router dependencies)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@pages_router.get("/", response_class=HTMLResponse)
|
||||||
|
async def root(request: Request):
|
||||||
"""Serve the main application page"""
|
"""Serve the main application page"""
|
||||||
index_path = static_path / "index.html"
|
index_path = static_path / "index.html"
|
||||||
async with aiofiles.open(index_path, 'r', encoding='utf-8') as f:
|
async with aiofiles.open(index_path, 'r', encoding='utf-8') as f:
|
||||||
|
|
@ -78,7 +225,7 @@ async def root():
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api")
|
@api_router.get("")
|
||||||
async def api_documentation():
|
async def api_documentation():
|
||||||
"""API Documentation - List all available endpoints"""
|
"""API Documentation - List all available endpoints"""
|
||||||
return {
|
return {
|
||||||
|
|
@ -230,18 +377,21 @@ async def api_documentation():
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/config")
|
@api_router.get("/config")
|
||||||
async def get_config():
|
async def get_config():
|
||||||
"""Get app configuration for frontend"""
|
"""Get app configuration for frontend"""
|
||||||
return {
|
return {
|
||||||
"name": config['app']['name'],
|
"name": config['app']['name'],
|
||||||
"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": {
|
||||||
|
"enabled": config.get('security', {}).get('enabled', False)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/themes")
|
@api_router.get("/themes")
|
||||||
async def list_themes():
|
async def list_themes():
|
||||||
"""Get all available themes"""
|
"""Get all available themes"""
|
||||||
themes_dir = Path(__file__).parent.parent / "themes"
|
themes_dir = Path(__file__).parent.parent / "themes"
|
||||||
|
|
@ -249,7 +399,7 @@ async def list_themes():
|
||||||
return {"themes": themes}
|
return {"themes": themes}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/themes/{theme_id}")
|
@app.get("/api/themes/{theme_id}") # Don't use the router here, as we want this route unsecured
|
||||||
async def get_theme(theme_id: str):
|
async def get_theme(theme_id: str):
|
||||||
"""Get CSS for a specific theme"""
|
"""Get CSS for a specific theme"""
|
||||||
themes_dir = Path(__file__).parent.parent / "themes"
|
themes_dir = Path(__file__).parent.parent / "themes"
|
||||||
|
|
@ -261,7 +411,7 @@ async def get_theme(theme_id: str):
|
||||||
return {"css": css, "theme_id": theme_id}
|
return {"css": css, "theme_id": theme_id}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/folders")
|
@api_router.post("/folders")
|
||||||
async def create_new_folder(data: dict):
|
async def create_new_folder(data: dict):
|
||||||
"""Create a new folder"""
|
"""Create a new folder"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -283,7 +433,7 @@ async def create_new_folder(data: dict):
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/notes/move")
|
@api_router.post("/notes/move")
|
||||||
async def move_note_endpoint(data: dict):
|
async def move_note_endpoint(data: dict):
|
||||||
"""Move a note to a different folder"""
|
"""Move a note to a different folder"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -311,7 +461,7 @@ async def move_note_endpoint(data: dict):
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/folders/move")
|
@api_router.post("/folders/move")
|
||||||
async def move_folder_endpoint(data: dict):
|
async def move_folder_endpoint(data: dict):
|
||||||
"""Move a folder to a different location"""
|
"""Move a folder to a different location"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -336,7 +486,7 @@ async def move_folder_endpoint(data: dict):
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/folders/rename")
|
@api_router.post("/folders/rename")
|
||||||
async def rename_folder_endpoint(data: dict):
|
async def rename_folder_endpoint(data: dict):
|
||||||
"""Rename a folder"""
|
"""Rename a folder"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -361,7 +511,7 @@ async def rename_folder_endpoint(data: dict):
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/folders/{folder_path:path}")
|
@api_router.delete("/folders/{folder_path:path}")
|
||||||
async def delete_folder_endpoint(folder_path: str):
|
async def delete_folder_endpoint(folder_path: str):
|
||||||
"""Delete a folder and all its contents"""
|
"""Delete a folder and all its contents"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -382,7 +532,7 @@ async def delete_folder_endpoint(folder_path: str):
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/notes")
|
@api_router.get("/notes")
|
||||||
async def list_notes():
|
async def list_notes():
|
||||||
"""List all notes with metadata"""
|
"""List all notes with metadata"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -393,7 +543,7 @@ async def list_notes():
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/notes/{note_path:path}")
|
@api_router.get("/notes/{note_path:path}")
|
||||||
async def get_note(note_path: str):
|
async def get_note(note_path: str):
|
||||||
"""Get a specific note's content"""
|
"""Get a specific note's content"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -421,7 +571,7 @@ async def get_note(note_path: str):
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/notes/{note_path:path}")
|
@api_router.post("/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):
|
||||||
"""Create or update a note"""
|
"""Create or update a note"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -459,7 +609,7 @@ async def create_or_update_note(note_path: str, content: dict):
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/notes/{note_path:path}")
|
@api_router.delete("/notes/{note_path:path}")
|
||||||
async def remove_note(note_path: str):
|
async def remove_note(note_path: str):
|
||||||
"""Delete a note"""
|
"""Delete a note"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -479,7 +629,7 @@ async def remove_note(note_path: str):
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/search")
|
@api_router.get("/search")
|
||||||
async def search(q: str):
|
async def search(q: str):
|
||||||
"""Search notes by content"""
|
"""Search notes by content"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -498,7 +648,7 @@ async def search(q: str):
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/graph")
|
@api_router.get("/graph")
|
||||||
async def get_graph():
|
async def get_graph():
|
||||||
"""Get graph data for visualization"""
|
"""Get graph data for visualization"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -528,13 +678,13 @@ async def get_graph():
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/plugins")
|
@api_router.get("/plugins")
|
||||||
async def list_plugins():
|
async def list_plugins():
|
||||||
"""List all available plugins"""
|
"""List all available plugins"""
|
||||||
return {"plugins": plugin_manager.list_plugins()}
|
return {"plugins": plugin_manager.list_plugins()}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/plugins/note_stats/calculate")
|
@api_router.get("/plugins/note_stats/calculate")
|
||||||
async def calculate_note_stats(content: str):
|
async def calculate_note_stats(content: str):
|
||||||
"""Calculate statistics for note content (if plugin enabled)"""
|
"""Calculate statistics for note content (if plugin enabled)"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -548,7 +698,7 @@ async def calculate_note_stats(content: str):
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/plugins/{plugin_name}/toggle")
|
@api_router.post("/plugins/{plugin_name}/toggle")
|
||||||
async def toggle_plugin(plugin_name: str, enabled: dict):
|
async def toggle_plugin(plugin_name: str, enabled: dict):
|
||||||
"""Enable or disable a plugin"""
|
"""Enable or disable a plugin"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -579,8 +729,8 @@ async def health_check():
|
||||||
|
|
||||||
# Catch-all route for SPA (Single Page Application) routing
|
# Catch-all route for SPA (Single Page Application) routing
|
||||||
# This allows URLs like /folder/note to work for direct navigation
|
# This allows URLs like /folder/note to work for direct navigation
|
||||||
@app.get("/{full_path:path}", response_class=HTMLResponse)
|
@pages_router.get("/{full_path:path}", response_class=HTMLResponse)
|
||||||
async def catch_all(full_path: str):
|
async def catch_all(full_path: str, request: Request):
|
||||||
"""
|
"""
|
||||||
Serve index.html for all non-API routes.
|
Serve index.html for all non-API routes.
|
||||||
This enables client-side routing (e.g., /folder/note)
|
This enables client-side routing (e.g., /folder/note)
|
||||||
|
|
@ -596,6 +746,16 @@ async def catch_all(full_path: str):
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Register Routers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Register routers with the main app
|
||||||
|
# Authentication is applied via router dependencies
|
||||||
|
app.include_router(api_router)
|
||||||
|
app.include_router(pages_router)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,6 @@ def ensure_directories(config: dict):
|
||||||
config['storage']['notes_dir'],
|
config['storage']['notes_dir'],
|
||||||
config['storage']['plugins_dir'],
|
config['storage']['plugins_dir'],
|
||||||
]
|
]
|
||||||
if config['search']['enabled']:
|
|
||||||
dirs.append(config['search']['index_dir'])
|
|
||||||
|
|
||||||
for dir_path in dirs:
|
for dir_path in dirs:
|
||||||
Path(dir_path).mkdir(parents=True, exist_ok=True)
|
Path(dir_path).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
|
||||||
18
config.yaml
18
config.yaml
|
|
@ -12,14 +12,24 @@ server:
|
||||||
reload: false # Set to true for development
|
reload: false # Set to true for development
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
notes_dir: "./data/notes"
|
notes_dir: "./data"
|
||||||
plugins_dir: "./plugins"
|
plugins_dir: "./plugins"
|
||||||
|
|
||||||
search:
|
search:
|
||||||
enabled: true
|
enabled: true
|
||||||
index_dir: "./data/search_index"
|
|
||||||
|
|
||||||
security:
|
security:
|
||||||
# Add authentication later if needed
|
# Authentication settings
|
||||||
require_auth: false
|
# Set enabled to true to require login
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# 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
|
||||||
|
password_hash: "$2b$12$t/6PGExFzdpU2PUta0iVY.eDQwvu63kH.c/d4bEnnHaQ5CspH1yrG" # Default: "admin"
|
||||||
|
|
||||||
|
# Session expiry in seconds (default: 7 days)
|
||||||
|
session_max_age: 604800
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
# Persist notes and data (inside 'notes' folder)
|
# Persist notes and data
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
# Custom plugins
|
# Custom plugins
|
||||||
- ./plugins:/app/plugins
|
- ./plugins:/app/plugins
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
# Persist notes and data (inside 'notes' folder)
|
# Persist notes and data
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
# Custom plugins
|
# Custom plugins
|
||||||
- ./plugins:/app/plugins
|
- ./plugins:/app/plugins
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 363 KiB After Width: | Height: | Size: 338 KiB |
|
|
@ -46,7 +46,7 @@ Content-Type: application/json
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** When creating a new note, the `on_note_create` hook is triggered, allowing plugins (like Template Generator) to modify the initial content. The response includes the potentially modified content.
|
**Note:** When creating a new note, the `on_note_create` hook is triggered, allowing plugins to modify the initial content. The response includes the potentially modified content.
|
||||||
|
|
||||||
**Linux/Mac:**
|
**Linux/Mac:**
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -241,11 +241,6 @@ All endpoints return JSON responses:
|
||||||
"detail": "Error message"
|
"detail": "Error message"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔑 Authentication
|
|
||||||
|
|
||||||
Currently, no authentication is required. Suitable for self-hosted local environments.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
💡 **Tip:** Use the `/api` endpoint to get a live, self-documented list of all available endpoints!
|
💡 **Tip:** Use the `/api` endpoint to get a live, self-documented list of all available endpoints!
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
# 🔒 NoteDiscovery Authentication Guide
|
||||||
|
|
||||||
|
## ⚠️ **IMPORTANT: Default Password Warning**
|
||||||
|
|
||||||
|
> **The default `config.yaml` includes authentication disabled by default, with password: `admin`**
|
||||||
|
>
|
||||||
|
> 🔴 **CHANGE THIS if you're exposing NoteDiscovery to a network!**
|
||||||
|
>
|
||||||
|
> The default configuration is provided for **quick testing only**. Follow the setup guide below to set your own secure password and secret key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Setup
|
||||||
|
|
||||||
|
**Default Configuration:**
|
||||||
|
- Authentication is **enabled by default**
|
||||||
|
- Default password is `admin`
|
||||||
|
- Default secret key is insecure
|
||||||
|
|
||||||
|
**⚠️ IMPORTANT:** For production or network-exposed deployments, **change both the password and secret key immediately**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🧪 **Quick Test (Use Default Password)**
|
||||||
|
|
||||||
|
For **local testing only**, you can use the default configuration:
|
||||||
|
|
||||||
|
1. Start NoteDiscovery (Docker or locally)
|
||||||
|
2. Navigate to `http://localhost:8000`
|
||||||
|
3. Log in with password: `admin`
|
||||||
|
|
||||||
|
**⚠️ Only use this for local testing on your own machine!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔒 **Production Setup (Change Password & Secret Key)**
|
||||||
|
|
||||||
|
For any deployment exposed to a network, follow these steps:
|
||||||
|
|
||||||
|
### Step 1: Generate a Password Hash
|
||||||
|
|
||||||
|
Choose your environment:
|
||||||
|
|
||||||
|
**Docker Users:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker Compose
|
||||||
|
docker-compose exec notediscovery python generate_password.py
|
||||||
|
|
||||||
|
# Or with docker run
|
||||||
|
docker exec -it notediscovery python generate_password.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Local Users:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install bcrypt if not already installed
|
||||||
|
pip install bcrypt
|
||||||
|
|
||||||
|
# Run the password generator
|
||||||
|
python generate_password.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The script will:
|
||||||
|
1. Prompt you for your password (input is hidden)
|
||||||
|
2. Ask you to confirm it
|
||||||
|
3. Generate a bcrypt hash
|
||||||
|
4. Display the hash with instructions
|
||||||
|
|
||||||
|
**Copy the hash** - you'll need it for Step 3.
|
||||||
|
|
||||||
|
### Step 2: Generate a Secret Key
|
||||||
|
|
||||||
|
Generate a random secret key for session encryption:
|
||||||
|
|
||||||
|
**Docker Users:**
|
||||||
|
```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))"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Local Users:**
|
||||||
|
```bash
|
||||||
|
python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Copy the key** - you'll need it for Step 3.
|
||||||
|
|
||||||
|
### 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 2)
|
||||||
|
secret_key: "your_generated_secret_key_here"
|
||||||
|
|
||||||
|
# Password hash (paste the output from Step 1)
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 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 (Traefik, 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.
|
||||||
|
|
@ -67,14 +67,6 @@
|
||||||
- **Event hooks** - React to note saves, deletes, searches
|
- **Event hooks** - React to note saves, deletes, searches
|
||||||
- **API access** - Full access to backend functionality
|
- **API access** - Full access to backend functionality
|
||||||
|
|
||||||
### Example Use Cases
|
|
||||||
- Git auto-sync
|
|
||||||
- Note encryption
|
|
||||||
- Export to PDF
|
|
||||||
- Daily note creation
|
|
||||||
- Backlinks tracking
|
|
||||||
- Tag management
|
|
||||||
|
|
||||||
## 🔍 Search
|
## 🔍 Search
|
||||||
|
|
||||||
- **Full-text search** - Find notes by content
|
- **Full-text search** - Find notes by content
|
||||||
|
|
@ -702,7 +702,20 @@
|
||||||
</template>
|
</template>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
<!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;
|
||||||
|
}
|
||||||
|
|
||||||
|
form > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
color: var(--error, #c53030);
|
||||||
|
font-size: 0.813rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
animation: fadeInDown 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message::before {
|
||||||
|
content: "⚠️";
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shake animation for password input on error */
|
||||||
|
input.error {
|
||||||
|
animation: shake 0.4s ease-in-out;
|
||||||
|
border-color: var(--error, #c53030);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
10%, 30%, 50%, 70%, 90% { transform: translateX(-8px); }
|
||||||
|
20%, 40%, 60%, 80% { transform: translateX(8px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
|
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
id="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
<!-- ERROR_CLASS_PLACEHOLDER -->
|
||||||
|
>
|
||||||
|
<!-- ERROR_MESSAGE_PLACEHOLDER -->
|
||||||
|
</div>
|
||||||
|
<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}")
|
||||||
|
|
||||||
|
|
@ -5,4 +5,6 @@ markdown==3.5.1
|
||||||
pyyaml==6.0.1
|
pyyaml==6.0.1
|
||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
cryptography==41.0.7
|
cryptography==41.0.7
|
||||||
|
bcrypt==4.1.2
|
||||||
|
itsdangerous==2.1.2
|
||||||
|
|
||||||
|
|
|
||||||
5
run.py
5
run.py
|
|
@ -20,8 +20,7 @@ def main():
|
||||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"])
|
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"])
|
||||||
|
|
||||||
# Create data directories
|
# Create data directories
|
||||||
Path("data/notes").mkdir(parents=True, exist_ok=True)
|
Path("data").mkdir(parents=True, exist_ok=True)
|
||||||
Path("data/search_index").mkdir(parents=True, exist_ok=True)
|
|
||||||
Path("plugins").mkdir(parents=True, exist_ok=True)
|
Path("plugins").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
print("✓ Dependencies installed")
|
print("✓ Dependencies installed")
|
||||||
|
|
@ -32,7 +31,7 @@ def main():
|
||||||
print("\n📝 Open your browser to: http://localhost:8000")
|
print("\n📝 Open your browser to: http://localhost:8000")
|
||||||
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/notes/")
|
print(" - Your notes are in ./data/")
|
||||||
print(" - Plugins go in ./plugins/")
|
print(" - Plugins go in ./plugins/")
|
||||||
print("\n" + "="*50 + "\n")
|
print("\n" + "="*50 + "\n")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue