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:
Gamosoft 2025-11-15 18:45:19 +01:00 committed by GitHub
commit 7afa65e802
22 changed files with 793 additions and 88 deletions

View File

@ -1,12 +1,6 @@
name: Build and Push Docker Image to GHCR
on:
pull_request:
types:
- closed
branches:
- main
- master
push:
tags:
- 'v*.*.*'
@ -18,19 +12,16 @@ env:
jobs:
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
permissions:
contents: read
contents: write
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for release notes
- name: Set up QEMU for multi-architecture builds
uses: docker/setup-qemu-action@v3
@ -73,3 +64,70 @@ jobs:
- name: Image 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 }}

2
.gitignore vendored
View File

@ -28,7 +28,7 @@ env/
.venv
# Data directories
search_index/
data/
*.db
*.sqlite

View File

@ -27,9 +27,10 @@ COPY frontend ./frontend
COPY config.yaml .
COPY plugins ./plugins
COPY themes ./themes
COPY generate_password.py .
# Create data directories
RUN mkdir -p data/notes data/search_index
# Create data directory
RUN mkdir -p data
# Expose port
EXPOSE 8000

View File

@ -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
@ -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!
> **💡 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:
> - **Required**: `data` folder (your notes will be stored here)
> - **Required**: `themes` folder with theme `.css` files (at least light.css and dark.css)
> - **Required**: `data` folder - **Your personal notes** will be stored here (create an empty folder)
> - **Required**: `themes` folder with theme `.css` files (at least a single theme must exist)
> - **Required**: `plugins` folder (can be empty for basic functionality)
> - **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:**
>
> 1. **Minimal** (quick test - download just the essentials):
> ```bash
> # 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
> # Download at least light and dark themes
> 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
> # 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
> # 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/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
> git clone https://github.com/gamosoft/notediscovery.git
> 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)**
> 💡 **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
# Access at http://localhost:8000
# Login with default password: admin
# View logs
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!**
Once you've started NoteDiscovery, you'll find comprehensive guides on:
- 🎨 **THEMES.md** - Theme customization and creating custom themes
- ✨ **FEATURES.md** - Complete feature list and keyboard shortcuts
- 🧮 **MATHJAX.md** - LaTeX/Math notation examples and syntax reference
- 🔌 **PLUGINS.md** - Plugin system and available plugins
- 🌐 **API.md** - REST API documentation and examples
- 🎨 **[THEMES.md](documentation/THEMES.md)** - Theme customization and creating custom themes
- ✨ **[FEATURES.md](documentation/FEATURES.md)** - Complete feature list and keyboard shortcuts
- 🧮 **[MATHJAX.md](documentation/MATHJAX.md)** - LaTeX/Math notation examples and syntax reference
- 🔌 **[PLUGINS.md](documentation/PLUGINS.md)** - Plugin system and available plugins
- 🌐 **[API.md](documentation/API.md)** - REST API documentation and examples
- 🔐 **[AUTHENTICATION.md](documentation/AUTHENTICATION.md)** - Enable password protection for your instance
**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
@ -217,18 +233,21 @@ 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
- **Password protection is ENABLED by default** with password: `admin`
- ⚠️ **CHANGE THE DEFAULT PASSWORD IMMEDIATELY** if exposing to a network!
- See **[AUTHENTICATION.md](documentation/AUTHENTICATION.md)** for complete setup instructions
- 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
- 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
- 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
- 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

View File

@ -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, APIRouter
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,144 @@ static_path = Path(__file__).parent.parent / "frontend"
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"""
index_path = static_path / "index.html"
async with aiofiles.open(index_path, 'r', encoding='utf-8') as f:
@ -78,7 +225,7 @@ async def root():
return content
@app.get("/api")
@api_router.get("")
async def api_documentation():
"""API Documentation - List all available endpoints"""
return {
@ -230,18 +377,21 @@ async def api_documentation():
}
@app.get("/api/config")
@api_router.get("/config")
async def get_config():
"""Get app configuration for frontend"""
return {
"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)
}
}
@app.get("/api/themes")
@api_router.get("/themes")
async def list_themes():
"""Get all available themes"""
themes_dir = Path(__file__).parent.parent / "themes"
@ -249,7 +399,7 @@ async def list_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):
"""Get CSS for a specific theme"""
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}
@app.post("/api/folders")
@api_router.post("/folders")
async def create_new_folder(data: dict):
"""Create a new folder"""
try:
@ -283,7 +433,7 @@ async def create_new_folder(data: dict):
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):
"""Move a note to a different folder"""
try:
@ -311,7 +461,7 @@ async def move_note_endpoint(data: dict):
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):
"""Move a folder to a different location"""
try:
@ -336,7 +486,7 @@ async def move_folder_endpoint(data: dict):
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):
"""Rename a folder"""
try:
@ -361,7 +511,7 @@ async def rename_folder_endpoint(data: dict):
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):
"""Delete a folder and all its contents"""
try:
@ -382,7 +532,7 @@ async def delete_folder_endpoint(folder_path: str):
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/notes")
@api_router.get("/notes")
async def list_notes():
"""List all notes with metadata"""
try:
@ -393,7 +543,7 @@ async def list_notes():
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):
"""Get a specific note's content"""
try:
@ -421,7 +571,7 @@ async def get_note(note_path: str):
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):
"""Create or update a note"""
try:
@ -459,7 +609,7 @@ async def create_or_update_note(note_path: str, content: dict):
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):
"""Delete a note"""
try:
@ -479,7 +629,7 @@ async def remove_note(note_path: str):
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/search")
@api_router.get("/search")
async def search(q: str):
"""Search notes by content"""
try:
@ -498,7 +648,7 @@ async def search(q: str):
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/graph")
@api_router.get("/graph")
async def get_graph():
"""Get graph data for visualization"""
try:
@ -528,13 +678,13 @@ async def get_graph():
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/plugins")
@api_router.get("/plugins")
async def list_plugins():
"""List all available 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):
"""Calculate statistics for note content (if plugin enabled)"""
try:
@ -548,7 +698,7 @@ async def calculate_note_stats(content: str):
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):
"""Enable or disable a plugin"""
try:
@ -579,8 +729,8 @@ 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):
@pages_router.get("/{full_path:path}", response_class=HTMLResponse)
async def catch_all(full_path: str, request: Request):
"""
Serve index.html for all non-API routes.
This enables client-side routing (e.g., /folder/note)
@ -596,6 +746,16 @@ async def catch_all(full_path: str):
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__":
import uvicorn
uvicorn.run(

View File

@ -35,8 +35,6 @@ def ensure_directories(config: dict):
config['storage']['notes_dir'],
config['storage']['plugins_dir'],
]
if config['search']['enabled']:
dirs.append(config['search']['index_dir'])
for dir_path in dirs:
Path(dir_path).mkdir(parents=True, exist_ok=True)

View File

@ -12,14 +12,24 @@ server:
reload: false # Set to true for development
storage:
notes_dir: "./data/notes"
notes_dir: "./data"
plugins_dir: "./plugins"
search:
enabled: true
index_dir: "./data/search_index"
security:
# Add authentication later if needed
require_auth: false
# Authentication settings
# 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

View File

@ -5,7 +5,7 @@ services:
ports:
- "8000:8000"
volumes:
# Persist notes and data (inside 'notes' folder)
# Persist notes and data
- ./data:/app/data
# Custom plugins
- ./plugins:/app/plugins

View File

@ -5,7 +5,7 @@ services:
ports:
- "8000:8000"
volumes:
# Persist notes and data (inside 'notes' folder)
# Persist notes and data
- ./data:/app/data
# Custom plugins
- ./plugins:/app/plugins

Binary file not shown.

Before

Width:  |  Height:  |  Size: 363 KiB

After

Width:  |  Height:  |  Size: 338 KiB

View File

@ -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:**
```bash
@ -241,11 +241,6 @@ All endpoints return JSON responses:
"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!

View File

@ -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.

View File

@ -67,14 +67,6 @@
- **Event hooks** - React to note saves, deletes, searches
- **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
- **Full-text search** - Find notes by content

View File

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

218
frontend/login.html Normal file
View File

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

61
generate_password.py Normal file
View File

@ -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}")

View File

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

5
run.py
View File

@ -20,8 +20,7 @@ def main():
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"])
# Create data directories
Path("data/notes").mkdir(parents=True, exist_ok=True)
Path("data/search_index").mkdir(parents=True, exist_ok=True)
Path("data").mkdir(parents=True, exist_ok=True)
Path("plugins").mkdir(parents=True, exist_ok=True)
print("✓ Dependencies installed")
@ -32,7 +31,7 @@ def main():
print("\n📝 Open your browser to: http://localhost:8000")
print("\n💡 Tips:")
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("\n" + "="*50 + "\n")