diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index d212b03..08e82ac 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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 }} + diff --git a/.gitignore b/.gitignore index 3a1b094..5b26cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,7 @@ env/ .venv # Data directories -search_index/ +data/ *.db *.sqlite diff --git a/Dockerfile b/Dockerfile index 199efe8..2d89598 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index eba1048..38a3d72 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ NoteDiscovery is a **lightweight, self-hosted note-taking application** that put ### Key Benefits - ๐Ÿ”’ **Total Privacy** - Your notes never leave your server +- ๐Ÿ” **Optional Authentication** - Simple password protection for self-hosted deployments - ๐Ÿ’ฐ **Zero Cost** - No subscriptions, no hidden fees - ๐Ÿš€ **Fast & Lightweight** - Instant search and navigation - ๐ŸŽจ **Beautiful Themes** - Multiple themes, easy to customize @@ -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 diff --git a/backend/main.py b/backend/main.py index cc60fcf..f848479 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,10 +3,11 @@ NoteDiscovery - Self-Hosted Markdown Knowledge Base Main FastAPI application """ -from fastapi import FastAPI, HTTPException, UploadFile, File +from fastapi import FastAPI, HTTPException, UploadFile, File, Request, Form, Depends, 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('', 'class="error"') + content = content.replace('', + f'
{error}
') + else: + content = content.replace('', '') + content = content.replace('', '') + + 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( diff --git a/backend/utils.py b/backend/utils.py index 1f1d654..aa361b7 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -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) diff --git a/config.yaml b/config.yaml index 94d4498..0b5c941 100644 --- a/config.yaml +++ b/config.yaml @@ -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 diff --git a/docker-compose.ghcr.yml b/docker-compose.ghcr.yml index de5bcc7..6101c02 100644 --- a/docker-compose.ghcr.yml +++ b/docker-compose.ghcr.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 5d7f5cd..843eb96 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/screenshot.jpg b/docs/screenshot.jpg index e76de85..b327b5d 100644 Binary files a/docs/screenshot.jpg and b/docs/screenshot.jpg differ diff --git a/data/notes/API.md b/documentation/API.md similarity index 94% rename from data/notes/API.md rename to documentation/API.md index 3ab34c8..08a0689 100644 --- a/data/notes/API.md +++ b/documentation/API.md @@ -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! diff --git a/documentation/AUTHENTICATION.md b/documentation/AUTHENTICATION.md new file mode 100644 index 0000000..fc3375a --- /dev/null +++ b/documentation/AUTHENTICATION.md @@ -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. \ No newline at end of file diff --git a/data/notes/FEATURES.md b/documentation/FEATURES.md similarity index 97% rename from data/notes/FEATURES.md rename to documentation/FEATURES.md index 93993f7..65dfaee 100644 --- a/data/notes/FEATURES.md +++ b/documentation/FEATURES.md @@ -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 diff --git a/data/notes/MATHJAX.md b/documentation/MATHJAX.md similarity index 100% rename from data/notes/MATHJAX.md rename to documentation/MATHJAX.md diff --git a/data/notes/PLUGINS.md b/documentation/PLUGINS.md similarity index 100% rename from data/notes/PLUGINS.md rename to documentation/PLUGINS.md diff --git a/data/notes/PLUGINS/Note Statistics.md b/documentation/PLUGIN_NOTE_STATISTICS.md similarity index 100% rename from data/notes/PLUGINS/Note Statistics.md rename to documentation/PLUGIN_NOTE_STATISTICS.md diff --git a/data/notes/THEMES.md b/documentation/THEMES.md similarity index 100% rename from data/notes/THEMES.md rename to documentation/THEMES.md diff --git a/frontend/index.html b/frontend/index.html index 3e8260b..224b155 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -702,7 +702,20 @@ -

+

+ + +
+ + ๐Ÿ”’ + Logout + +
diff --git a/frontend/login.html b/frontend/login.html new file mode 100644 index 0000000..5935307 --- /dev/null +++ b/frontend/login.html @@ -0,0 +1,218 @@ + + + + + + Login - NoteDiscovery + + + + + + + + +
+ +

NoteDiscovery

+

Your Self-Hosted Knowledge Base

+ +
+
+ + > + +
+ +
+ + +
+ + + diff --git a/generate_password.py b/generate_password.py new file mode 100644 index 0000000..8941883 --- /dev/null +++ b/generate_password.py @@ -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}") + diff --git a/requirements.txt b/requirements.txt index 56d3204..6bbe856 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/run.py b/run.py index bacf027..470ae4c 100644 --- a/run.py +++ b/run.py @@ -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")