Spaces:
Sleeping
Sleeping
Doc-MCP Application
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +64 -0
- Dockerfile +39 -0
- backend/.env +13 -0
- backend/.python-version +1 -0
- backend/README.md +0 -0
- backend/__pycache__/main.cpython-311.pyc +0 -0
- backend/main.py +11 -0
- backend/pyproject.toml +23 -0
- backend/requirements.txt +12 -0
- backend/src/api/__init__.py +1 -0
- backend/src/api/__pycache__/__init__.cpython-311.pyc +0 -0
- backend/src/api/__pycache__/__init__.cpython-313.pyc +0 -0
- backend/src/api/__pycache__/main.cpython-311.pyc +0 -0
- backend/src/api/__pycache__/main.cpython-313.pyc +0 -0
- backend/src/api/main.py +196 -0
- backend/src/api/middleware/__init__.py +19 -0
- backend/src/api/middleware/__pycache__/__init__.cpython-311.pyc +0 -0
- backend/src/api/middleware/__pycache__/__init__.cpython-313.pyc +0 -0
- backend/src/api/middleware/__pycache__/auth_middleware.cpython-311.pyc +0 -0
- backend/src/api/middleware/__pycache__/auth_middleware.cpython-313.pyc +0 -0
- backend/src/api/middleware/__pycache__/error_handlers.cpython-311.pyc +0 -0
- backend/src/api/middleware/__pycache__/error_handlers.cpython-313.pyc +0 -0
- backend/src/api/middleware/auth_middleware.py +65 -0
- backend/src/api/middleware/error_handlers.py +113 -0
- backend/src/api/routes/__init__.py +5 -0
- backend/src/api/routes/__pycache__/__init__.cpython-311.pyc +0 -0
- backend/src/api/routes/__pycache__/__init__.cpython-313.pyc +0 -0
- backend/src/api/routes/__pycache__/auth.cpython-311.pyc +0 -0
- backend/src/api/routes/__pycache__/auth.cpython-313.pyc +0 -0
- backend/src/api/routes/__pycache__/index.cpython-311.pyc +0 -0
- backend/src/api/routes/__pycache__/index.cpython-313.pyc +0 -0
- backend/src/api/routes/__pycache__/notes.cpython-311.pyc +0 -0
- backend/src/api/routes/__pycache__/notes.cpython-313.pyc +0 -0
- backend/src/api/routes/__pycache__/search.cpython-311.pyc +0 -0
- backend/src/api/routes/__pycache__/search.cpython-313.pyc +0 -0
- backend/src/api/routes/auth.py +315 -0
- backend/src/api/routes/index.py +135 -0
- backend/src/api/routes/notes.py +288 -0
- backend/src/api/routes/search.py +107 -0
- backend/src/mcp/__init__.py +5 -0
- backend/src/mcp/__pycache__/__init__.cpython-311.pyc +0 -0
- backend/src/mcp/__pycache__/__init__.cpython-313.pyc +0 -0
- backend/src/mcp/__pycache__/server.cpython-311.pyc +0 -0
- backend/src/mcp/__pycache__/server.cpython-313.pyc +0 -0
- backend/src/mcp/server.py +284 -0
- backend/src/models/__init__.py +25 -0
- backend/src/models/__pycache__/__init__.cpython-311.pyc +0 -0
- backend/src/models/__pycache__/__init__.cpython-313.pyc +0 -0
- backend/src/models/__pycache__/auth.cpython-311.pyc +0 -0
- backend/src/models/__pycache__/auth.cpython-313.pyc +0 -0
.dockerignore
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
**/__pycache__
|
| 3 |
+
**/*.pyc
|
| 4 |
+
**/*.pyo
|
| 5 |
+
**/*.pyd
|
| 6 |
+
**/.Python
|
| 7 |
+
**/env
|
| 8 |
+
**/venv
|
| 9 |
+
**/.venv
|
| 10 |
+
**/ENV
|
| 11 |
+
**/env.bak/
|
| 12 |
+
**/venv.bak/
|
| 13 |
+
**/*.egg-info/
|
| 14 |
+
**/.pytest_cache/
|
| 15 |
+
**/.mypy_cache/
|
| 16 |
+
**/.ruff_cache/
|
| 17 |
+
|
| 18 |
+
# Node.js
|
| 19 |
+
**/node_modules
|
| 20 |
+
**/npm-debug.log*
|
| 21 |
+
**/yarn-debug.log*
|
| 22 |
+
**/yarn-error.log*
|
| 23 |
+
**/pnpm-debug.log*
|
| 24 |
+
**/lerna-debug.log*
|
| 25 |
+
frontend/dist
|
| 26 |
+
frontend/.vite
|
| 27 |
+
|
| 28 |
+
# Git
|
| 29 |
+
.git
|
| 30 |
+
.gitignore
|
| 31 |
+
.gitattributes
|
| 32 |
+
|
| 33 |
+
# IDE
|
| 34 |
+
**/.vscode
|
| 35 |
+
**/.idea
|
| 36 |
+
**/*.swp
|
| 37 |
+
**/*.swo
|
| 38 |
+
**/*~
|
| 39 |
+
|
| 40 |
+
# OS
|
| 41 |
+
.DS_Store
|
| 42 |
+
Thumbs.db
|
| 43 |
+
|
| 44 |
+
# Project specific
|
| 45 |
+
*.log
|
| 46 |
+
.backend.pid
|
| 47 |
+
.frontend.pid
|
| 48 |
+
backend.log
|
| 49 |
+
frontend.log
|
| 50 |
+
data/index.db
|
| 51 |
+
data/vaults/*
|
| 52 |
+
!data/vaults/.gitkeep
|
| 53 |
+
|
| 54 |
+
# Documentation (not needed in container)
|
| 55 |
+
*.md
|
| 56 |
+
!backend/README.md
|
| 57 |
+
specs/
|
| 58 |
+
ai-notes/
|
| 59 |
+
|
| 60 |
+
# Scripts
|
| 61 |
+
start-dev.sh
|
| 62 |
+
stop-dev.sh
|
| 63 |
+
status-dev.sh
|
| 64 |
+
|
Dockerfile
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile for Hugging Face Space deployment
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Install Node.js for frontend build
|
| 7 |
+
RUN apt-get update && \
|
| 8 |
+
apt-get install -y nodejs npm curl && \
|
| 9 |
+
rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
# Copy and install frontend dependencies
|
| 12 |
+
COPY frontend/package*.json frontend/
|
| 13 |
+
RUN cd frontend && npm ci
|
| 14 |
+
|
| 15 |
+
# Copy frontend source and build
|
| 16 |
+
COPY frontend/ frontend/
|
| 17 |
+
RUN cd frontend && npm run build
|
| 18 |
+
|
| 19 |
+
# Install Python dependencies
|
| 20 |
+
COPY backend/pyproject.toml backend/README.md backend/
|
| 21 |
+
RUN pip install --no-cache-dir -e backend/
|
| 22 |
+
|
| 23 |
+
# Copy backend source
|
| 24 |
+
COPY backend/ backend/
|
| 25 |
+
|
| 26 |
+
# Create data directory for vaults and database
|
| 27 |
+
RUN mkdir -p /app/data/vaults
|
| 28 |
+
|
| 29 |
+
# Expose port 7860 (required by Hugging Face Spaces)
|
| 30 |
+
EXPOSE 7860
|
| 31 |
+
|
| 32 |
+
# Set environment variables for production
|
| 33 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 34 |
+
VAULT_BASE_PATH=/app/data/vaults \
|
| 35 |
+
DATABASE_PATH=/app/data/index.db
|
| 36 |
+
|
| 37 |
+
# Start the FastAPI server
|
| 38 |
+
CMD ["uvicorn", "backend.src.api.main:app", "--host", "0.0.0.0", "--port", "7860", "--log-level", "info"]
|
| 39 |
+
|
backend/.env
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
JWT_SECRET_KEY=61012f17c920190990c90bfc9c810e24bbed86382df3cc573e78fd82e8809661
|
| 2 |
+
# JWT_SECRET_KEY="local-dev-secret-key-123"
|
| 3 |
+
HF_OAUTH_CLIENT_ID=9a79980a-74ce-4e06-9fce-ed787b2242c8
|
| 4 |
+
HF_OAUTH_CLIENT_SECRET=05a8a57e-6940-4bd4-a7f8-7f0a6db1460
|
| 5 |
+
HF_SPACE_URL=https://huggingface.co/spaces/Wothmag07/Document-MCP
|
| 6 |
+
MCP_HOST=0.0.0.0
|
| 7 |
+
MCP_PORT=8001
|
| 8 |
+
|
| 9 |
+
ENABLE_LOCAL_MODE=False
|
| 10 |
+
LOCAL_DEV_TOKEN=local-dev-token
|
| 11 |
+
MCP_TRANSPORT = "http"
|
| 12 |
+
|
| 13 |
+
VAULT_BASE_PATH=../data/vaults
|
backend/.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.11
|
backend/README.md
ADDED
|
File without changes
|
backend/__pycache__/main.cpython-311.pyc
ADDED
|
Binary file (451 Bytes). View file
|
|
|
backend/main.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entry point for running the FastAPI application."""
|
| 2 |
+
|
| 3 |
+
import uvicorn
|
| 4 |
+
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
uvicorn.run(
|
| 7 |
+
"src.api.main:app",
|
| 8 |
+
host="0.0.0.0",
|
| 9 |
+
port=8000,
|
| 10 |
+
reload=True,
|
| 11 |
+
)
|
backend/pyproject.toml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "Documentation-MCP"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Add your description here"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.11"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"fastapi>=0.121.2",
|
| 9 |
+
"fastmcp>=2.13.1",
|
| 10 |
+
"huggingface-hub>=1.1.4",
|
| 11 |
+
"mcp>=1.21.0",
|
| 12 |
+
"pyjwt>=2.10.1",
|
| 13 |
+
"python-dotenv>=1.0.0",
|
| 14 |
+
"python-frontmatter>=1.1.0",
|
| 15 |
+
"uvicorn[standard]>=0.38.0",
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
[dependency-groups]
|
| 19 |
+
dev = [
|
| 20 |
+
"httpx>=0.28.1",
|
| 21 |
+
"pytest>=9.0.1",
|
| 22 |
+
"pytest-asyncio>=1.3.0",
|
| 23 |
+
]
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
fastmcp
|
| 3 |
+
python-frontmatter
|
| 4 |
+
pyjwt
|
| 5 |
+
huggingface_hub
|
| 6 |
+
uvicorn
|
| 7 |
+
httpx
|
| 8 |
+
pytest
|
| 9 |
+
pytest-asyncio
|
| 10 |
+
python-dotenv
|
| 11 |
+
requests
|
| 12 |
+
sqlite
|
backend/src/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application and routing."""
|
backend/src/api/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (218 Bytes). View file
|
|
|
backend/src/api/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (207 Bytes). View file
|
|
|
backend/src/api/__pycache__/main.cpython-311.pyc
ADDED
|
Binary file (8.95 kB). View file
|
|
|
backend/src/api/__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (7.77 kB). View file
|
|
|
backend/src/api/main.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application main entry point."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import logging
|
| 7 |
+
from contextlib import asynccontextmanager
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
from fastapi import Depends, FastAPI, HTTPException, Request
|
| 12 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
+
from fastapi.responses import JSONResponse
|
| 14 |
+
from fastapi.staticfiles import StaticFiles
|
| 15 |
+
from fastmcp.server.http import StreamableHTTPSessionManager
|
| 16 |
+
from starlette.responses import FileResponse, Response
|
| 17 |
+
|
| 18 |
+
from .middleware import AuthContext, get_auth_context
|
| 19 |
+
from .routes import auth, index, notes, search
|
| 20 |
+
from ..mcp.server import mcp
|
| 21 |
+
from ..services.seed import init_and_seed
|
| 22 |
+
|
| 23 |
+
load_dotenv(dotenv_path="backend/.env")
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
session_manager = StreamableHTTPSessionManager(
|
| 29 |
+
app=mcp._mcp_server,
|
| 30 |
+
event_store=None,
|
| 31 |
+
json_response=False,
|
| 32 |
+
stateless=False,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@asynccontextmanager
|
| 37 |
+
async def lifespan(app: FastAPI):
|
| 38 |
+
"""Start and stop the Streamable HTTP session manager."""
|
| 39 |
+
async with session_manager.run():
|
| 40 |
+
yield
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
app = FastAPI(
|
| 44 |
+
title="Document Viewer API",
|
| 45 |
+
description="Multi-tenant Obsidian-like documentation system",
|
| 46 |
+
version="0.1.0",
|
| 47 |
+
lifespan=lifespan,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
# CORS middleware
|
| 51 |
+
app.add_middleware(
|
| 52 |
+
CORSMiddleware,
|
| 53 |
+
allow_origins=[
|
| 54 |
+
"http://localhost:5173",
|
| 55 |
+
"http://localhost:3000",
|
| 56 |
+
"https://huggingface.co",
|
| 57 |
+
],
|
| 58 |
+
allow_credentials=True,
|
| 59 |
+
allow_methods=["*"],
|
| 60 |
+
allow_headers=["*"],
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# Startup event: Initialize database and seed demo data
|
| 65 |
+
@app.on_event("startup")
|
| 66 |
+
async def startup_event():
|
| 67 |
+
"""Initialize database schema and seed demo vault on startup."""
|
| 68 |
+
logger.info("Running startup: initializing database and seeding demo vault...")
|
| 69 |
+
try:
|
| 70 |
+
init_and_seed(user_id="demo-user")
|
| 71 |
+
logger.info("Startup complete: database and demo vault ready")
|
| 72 |
+
except Exception as e:
|
| 73 |
+
logger.exception(f"Startup failed: {e}")
|
| 74 |
+
# Don't crash the app, but log the error
|
| 75 |
+
logger.error("App starting without demo data due to initialization error")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# Error handlers
|
| 79 |
+
@app.exception_handler(404)
|
| 80 |
+
async def not_found_handler(request: Request, exc: Exception):
|
| 81 |
+
"""Handle 404 errors."""
|
| 82 |
+
return JSONResponse(
|
| 83 |
+
status_code=404,
|
| 84 |
+
content={"error": "Not found", "detail": str(exc)},
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@app.exception_handler(409)
|
| 89 |
+
async def conflict_handler(request: Request, exc: Exception):
|
| 90 |
+
"""Handle 409 Conflict errors."""
|
| 91 |
+
return JSONResponse(
|
| 92 |
+
status_code=409,
|
| 93 |
+
content={"error": "Conflict", "detail": str(exc)},
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@app.exception_handler(500)
|
| 98 |
+
async def internal_error_handler(request: Request, exc: Exception):
|
| 99 |
+
"""Handle 500 errors."""
|
| 100 |
+
return JSONResponse(
|
| 101 |
+
status_code=500,
|
| 102 |
+
content={"error": "Internal server error", "detail": str(exc)},
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# Mount routers (auth must come first for /auth/login and /auth/callback)
|
| 107 |
+
app.include_router(auth.router, tags=["auth"])
|
| 108 |
+
app.include_router(notes.router, tags=["notes"])
|
| 109 |
+
app.include_router(search.router, tags=["search"])
|
| 110 |
+
app.include_router(index.router, tags=["index"])
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
|
| 114 |
+
async def mcp_http_bridge(
|
| 115 |
+
request: Request, auth: AuthContext = Depends(get_auth_context)
|
| 116 |
+
) -> Response:
|
| 117 |
+
"""Forward HTTP requests to the FastMCP streamable HTTP session manager."""
|
| 118 |
+
|
| 119 |
+
send_queue: asyncio.Queue = asyncio.Queue()
|
| 120 |
+
|
| 121 |
+
async def send(message):
|
| 122 |
+
await send_queue.put(message)
|
| 123 |
+
|
| 124 |
+
await session_manager.handle_request(request.scope, request.receive, send)
|
| 125 |
+
await send_queue.put(None)
|
| 126 |
+
|
| 127 |
+
result_body = b""
|
| 128 |
+
headers = {}
|
| 129 |
+
status = 200
|
| 130 |
+
|
| 131 |
+
while True:
|
| 132 |
+
message = await send_queue.get()
|
| 133 |
+
if message is None:
|
| 134 |
+
break
|
| 135 |
+
msg_type = message["type"]
|
| 136 |
+
if msg_type == "http.response.start":
|
| 137 |
+
status = message.get("status", 200)
|
| 138 |
+
raw_headers = message.get("headers", [])
|
| 139 |
+
headers = {key.decode(): value.decode() for key, value in raw_headers}
|
| 140 |
+
elif msg_type == "http.response.body":
|
| 141 |
+
result_body += message.get("body", b"")
|
| 142 |
+
if not message.get("more_body"):
|
| 143 |
+
break
|
| 144 |
+
|
| 145 |
+
return Response(content=result_body, status_code=status, headers=headers)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
logger.info("MCP HTTP endpoint mounted at /mcp via StreamableHTTPSessionManager")
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@app.get("/health")
|
| 152 |
+
async def health():
|
| 153 |
+
"""Health check endpoint for HF Spaces."""
|
| 154 |
+
return {"status": "healthy"}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
frontend_dist = Path(__file__).resolve().parents[3] / "frontend" / "dist"
|
| 158 |
+
if frontend_dist.exists():
|
| 159 |
+
# Mount static assets
|
| 160 |
+
app.mount(
|
| 161 |
+
"/assets", StaticFiles(directory=str(frontend_dist / "assets")), name="assets"
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
# Catch-all route for SPA - serve index.html for all non-API routes
|
| 165 |
+
@app.get("/{full_path:path}")
|
| 166 |
+
async def serve_spa(full_path: str):
|
| 167 |
+
"""Serve the SPA for all non-API routes."""
|
| 168 |
+
# Don't intercept API or auth routes
|
| 169 |
+
if (
|
| 170 |
+
full_path.startswith(("api/", "auth/"))
|
| 171 |
+
or full_path == "health"
|
| 172 |
+
or full_path.startswith("mcp/")
|
| 173 |
+
or full_path == "mcp"
|
| 174 |
+
):
|
| 175 |
+
# Let FastAPI's 404 handler take over
|
| 176 |
+
raise HTTPException(status_code=404, detail="Not found")
|
| 177 |
+
|
| 178 |
+
# If the path looks like a file (has extension), try to serve it
|
| 179 |
+
file_path = frontend_dist / full_path
|
| 180 |
+
if file_path.is_file():
|
| 181 |
+
return FileResponse(file_path)
|
| 182 |
+
# Otherwise serve index.html for SPA routing
|
| 183 |
+
return FileResponse(frontend_dist / "index.html")
|
| 184 |
+
|
| 185 |
+
logger.info(f"Serving frontend SPA from: {frontend_dist}")
|
| 186 |
+
else:
|
| 187 |
+
logger.warning(f"Frontend dist not found at: {frontend_dist}")
|
| 188 |
+
|
| 189 |
+
# Fallback health endpoint if no frontend
|
| 190 |
+
@app.get("/")
|
| 191 |
+
async def root():
|
| 192 |
+
"""API health check endpoint."""
|
| 193 |
+
return {"status": "ok", "service": "Document Viewer API"}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
__all__ = ["app"]
|
backend/src/api/middleware/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI middleware for authentication and error handling."""
|
| 2 |
+
|
| 3 |
+
from .auth_middleware import AuthContext, extract_user_id_from_jwt, get_auth_context
|
| 4 |
+
from .error_handlers import (
|
| 5 |
+
http_exception_handler,
|
| 6 |
+
internal_exception_handler,
|
| 7 |
+
register_error_handlers,
|
| 8 |
+
validation_exception_handler,
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
__all__ = [
|
| 12 |
+
"AuthContext",
|
| 13 |
+
"extract_user_id_from_jwt",
|
| 14 |
+
"get_auth_context",
|
| 15 |
+
"register_error_handlers",
|
| 16 |
+
"validation_exception_handler",
|
| 17 |
+
"http_exception_handler",
|
| 18 |
+
"internal_exception_handler",
|
| 19 |
+
]
|
backend/src/api/middleware/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (698 Bytes). View file
|
|
|
backend/src/api/middleware/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (605 Bytes). View file
|
|
|
backend/src/api/middleware/__pycache__/auth_middleware.cpython-311.pyc
ADDED
|
Binary file (3.16 kB). View file
|
|
|
backend/src/api/middleware/__pycache__/auth_middleware.cpython-313.pyc
ADDED
|
Binary file (2.87 kB). View file
|
|
|
backend/src/api/middleware/__pycache__/error_handlers.cpython-311.pyc
ADDED
|
Binary file (6.12 kB). View file
|
|
|
backend/src/api/middleware/__pycache__/error_handlers.cpython-313.pyc
ADDED
|
Binary file (5.66 kB). View file
|
|
|
backend/src/api/middleware/auth_middleware.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication dependency helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Annotated, Optional
|
| 7 |
+
|
| 8 |
+
from fastapi import Header, HTTPException, status
|
| 9 |
+
|
| 10 |
+
from ...models.auth import JWTPayload
|
| 11 |
+
from ...services.auth import AuthError, AuthService
|
| 12 |
+
|
| 13 |
+
auth_service = AuthService()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _unauthorized(message: str, error: str = "unauthorized") -> HTTPException:
|
| 17 |
+
return HTTPException(
|
| 18 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 19 |
+
detail={"error": error, "message": message},
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class AuthContext:
|
| 25 |
+
"""Context extracted from a bearer token."""
|
| 26 |
+
|
| 27 |
+
user_id: str
|
| 28 |
+
token: str
|
| 29 |
+
payload: JWTPayload
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def get_auth_context(
|
| 33 |
+
authorization: Annotated[Optional[str], Header(alias="Authorization")] = None,
|
| 34 |
+
) -> AuthContext:
|
| 35 |
+
"""
|
| 36 |
+
Extract and validate the user_id from a Bearer token.
|
| 37 |
+
|
| 38 |
+
Raises HTTPException if the header is missing/invalid.
|
| 39 |
+
"""
|
| 40 |
+
if not authorization:
|
| 41 |
+
raise _unauthorized("Authorization header required")
|
| 42 |
+
|
| 43 |
+
scheme, _, token = authorization.partition(" ")
|
| 44 |
+
if scheme.lower() != "bearer" or not token:
|
| 45 |
+
raise _unauthorized("Authorization header must be in format: Bearer <token>")
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
payload = auth_service.validate_jwt(token)
|
| 49 |
+
except AuthError as exc:
|
| 50 |
+
raise HTTPException(
|
| 51 |
+
status_code=exc.status_code,
|
| 52 |
+
detail={"error": exc.error, "message": exc.message, "detail": exc.detail},
|
| 53 |
+
) from exc
|
| 54 |
+
|
| 55 |
+
return AuthContext(user_id=payload.sub, token=token, payload=payload)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def extract_user_id_from_jwt(
|
| 59 |
+
authorization: Annotated[Optional[str], Header(alias="Authorization")] = None,
|
| 60 |
+
) -> str:
|
| 61 |
+
"""Compatibility helper that returns only the user_id."""
|
| 62 |
+
return get_auth_context(authorization).user_id
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
__all__ = ["AuthContext", "extract_user_id_from_jwt", "get_auth_context"]
|
backend/src/api/middleware/error_handlers.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI exception handlers aligned with HTTP API contract."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Any, Dict, Optional, Tuple
|
| 7 |
+
|
| 8 |
+
from fastapi import FastAPI, Request, status
|
| 9 |
+
from fastapi.exceptions import RequestValidationError
|
| 10 |
+
from fastapi.responses import JSONResponse
|
| 11 |
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
DEFAULT_ERRORS: Dict[int, Tuple[str, str]] = {
|
| 16 |
+
status.HTTP_400_BAD_REQUEST: ("validation_error", "Invalid request payload"),
|
| 17 |
+
status.HTTP_401_UNAUTHORIZED: ("unauthorized", "Authorization required"),
|
| 18 |
+
status.HTTP_403_FORBIDDEN: ("forbidden", "Forbidden"),
|
| 19 |
+
status.HTTP_404_NOT_FOUND: ("not_found", "Resource not found"),
|
| 20 |
+
status.HTTP_409_CONFLICT: ("version_conflict", "Resource version conflict"),
|
| 21 |
+
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE: (
|
| 22 |
+
"payload_too_large",
|
| 23 |
+
"Payload exceeds allowed size",
|
| 24 |
+
),
|
| 25 |
+
status.HTTP_500_INTERNAL_SERVER_ERROR: ("internal_error", "Internal server error"),
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _normalize_error(
|
| 30 |
+
status_code: int, detail: Any
|
| 31 |
+
) -> Tuple[str, str, Optional[Dict[str, Any]]]:
|
| 32 |
+
default_error, default_message = DEFAULT_ERRORS.get(
|
| 33 |
+
status_code, DEFAULT_ERRORS[status.HTTP_500_INTERNAL_SERVER_ERROR]
|
| 34 |
+
)
|
| 35 |
+
if isinstance(detail, dict):
|
| 36 |
+
error = detail.get("error", default_error)
|
| 37 |
+
message = detail.get("message", default_message)
|
| 38 |
+
detail_payload = detail.get("detail")
|
| 39 |
+
if detail_payload is None:
|
| 40 |
+
remainder = {
|
| 41 |
+
k: v for k, v in detail.items() if k not in {"error", "message", "detail"}
|
| 42 |
+
}
|
| 43 |
+
detail_payload = remainder or None
|
| 44 |
+
return error, message, detail_payload
|
| 45 |
+
if isinstance(detail, str) and detail:
|
| 46 |
+
return default_error, detail, None
|
| 47 |
+
return default_error, default_message, None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _response(status_code: int, detail: Any) -> JSONResponse:
|
| 51 |
+
error, message, extra = _normalize_error(status_code, detail)
|
| 52 |
+
return JSONResponse(
|
| 53 |
+
status_code=status_code, content={"error": error, "message": message, "detail": extra}
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
async def validation_exception_handler(
|
| 58 |
+
request: Request, exc: RequestValidationError
|
| 59 |
+
) -> JSONResponse:
|
| 60 |
+
# Transform pydantic errors into more user-friendly format
|
| 61 |
+
errors = []
|
| 62 |
+
for error in exc.errors():
|
| 63 |
+
field = ".".join(str(loc) for loc in error["loc"] if loc != "body")
|
| 64 |
+
errors.append({
|
| 65 |
+
"field": field or "request",
|
| 66 |
+
"reason": error["msg"],
|
| 67 |
+
"type": error["type"]
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
detail = {
|
| 71 |
+
"error": "validation_error",
|
| 72 |
+
"message": "Request validation failed",
|
| 73 |
+
"detail": {"fields": errors}
|
| 74 |
+
}
|
| 75 |
+
logger.warning(
|
| 76 |
+
"Validation error",
|
| 77 |
+
extra={"url": str(request.url), "errors": errors}
|
| 78 |
+
)
|
| 79 |
+
return _response(status.HTTP_400_BAD_REQUEST, detail)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
async def http_exception_handler(
|
| 83 |
+
request: Request, exc: StarletteHTTPException
|
| 84 |
+
) -> JSONResponse:
|
| 85 |
+
logger.warning(
|
| 86 |
+
"HTTP exception",
|
| 87 |
+
extra={
|
| 88 |
+
"url": str(request.url),
|
| 89 |
+
"status_code": exc.status_code,
|
| 90 |
+
"detail": exc.detail
|
| 91 |
+
}
|
| 92 |
+
)
|
| 93 |
+
return _response(exc.status_code, exc.detail)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
async def internal_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
| 97 |
+
logger.exception("Unhandled exception: %s", exc)
|
| 98 |
+
return _response(status.HTTP_500_INTERNAL_SERVER_ERROR, exc.args[0] if exc.args else None)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def register_error_handlers(app: FastAPI) -> None:
|
| 102 |
+
"""Attach shared exception handlers to the FastAPI application."""
|
| 103 |
+
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
| 104 |
+
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
|
| 105 |
+
app.add_exception_handler(Exception, internal_exception_handler)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
__all__ = [
|
| 109 |
+
"register_error_handlers",
|
| 110 |
+
"validation_exception_handler",
|
| 111 |
+
"http_exception_handler",
|
| 112 |
+
"internal_exception_handler",
|
| 113 |
+
]
|
backend/src/api/routes/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP API route handlers."""
|
| 2 |
+
|
| 3 |
+
from . import auth, index, notes, search
|
| 4 |
+
|
| 5 |
+
__all__ = ["auth", "notes", "search", "index"]
|
backend/src/api/routes/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (382 Bytes). View file
|
|
|
backend/src/api/routes/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (337 Bytes). View file
|
|
|
backend/src/api/routes/__pycache__/auth.cpython-311.pyc
ADDED
|
Binary file (13.4 kB). View file
|
|
|
backend/src/api/routes/__pycache__/auth.cpython-313.pyc
ADDED
|
Binary file (11.7 kB). View file
|
|
|
backend/src/api/routes/__pycache__/index.cpython-311.pyc
ADDED
|
Binary file (6.82 kB). View file
|
|
|
backend/src/api/routes/__pycache__/index.cpython-313.pyc
ADDED
|
Binary file (5.83 kB). View file
|
|
|
backend/src/api/routes/__pycache__/notes.cpython-311.pyc
ADDED
|
Binary file (13.4 kB). View file
|
|
|
backend/src/api/routes/__pycache__/notes.cpython-313.pyc
ADDED
|
Binary file (11.6 kB). View file
|
|
|
backend/src/api/routes/__pycache__/search.cpython-311.pyc
ADDED
|
Binary file (5.55 kB). View file
|
|
|
backend/src/api/routes/__pycache__/search.cpython-313.pyc
ADDED
|
Binary file (4.61 kB). View file
|
|
|
backend/src/api/routes/auth.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OAuth and authentication routes for Hugging Face integration."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import secrets
|
| 7 |
+
import time
|
| 8 |
+
from datetime import datetime, timezone
|
| 9 |
+
from typing import Optional
|
| 10 |
+
from urllib.parse import urlencode
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
| 14 |
+
from fastapi.responses import RedirectResponse
|
| 15 |
+
|
| 16 |
+
from ...models.auth import TokenResponse
|
| 17 |
+
from ...models.user import HFProfile, User
|
| 18 |
+
from ...services.auth import AuthError, AuthService
|
| 19 |
+
from ...services.config import get_config
|
| 20 |
+
from ...services.seed import ensure_welcome_note
|
| 21 |
+
from ...services.vault import VaultService
|
| 22 |
+
from ..middleware import AuthContext, get_auth_context
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
router = APIRouter()
|
| 27 |
+
|
| 28 |
+
OAUTH_STATE_TTL_SECONDS = 300
|
| 29 |
+
oauth_states: dict[str, float] = {}
|
| 30 |
+
|
| 31 |
+
auth_service = AuthService()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _create_oauth_state() -> str:
|
| 35 |
+
"""Generate a state token and store it with a timestamp."""
|
| 36 |
+
now = time.time()
|
| 37 |
+
# Garbage collect expired states
|
| 38 |
+
expired = [
|
| 39 |
+
state
|
| 40 |
+
for state, ts in oauth_states.items()
|
| 41 |
+
if now - ts > OAUTH_STATE_TTL_SECONDS
|
| 42 |
+
]
|
| 43 |
+
for state in expired:
|
| 44 |
+
oauth_states.pop(state, None)
|
| 45 |
+
|
| 46 |
+
state = secrets.token_urlsafe(32)
|
| 47 |
+
oauth_states[state] = now
|
| 48 |
+
return state
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _consume_oauth_state(state: str | None) -> None:
|
| 52 |
+
"""Validate and remove the state token; raise if invalid."""
|
| 53 |
+
if not state or state not in oauth_states:
|
| 54 |
+
raise HTTPException(status_code=400, detail="Invalid or expired OAuth state.")
|
| 55 |
+
# Remove to prevent reuse
|
| 56 |
+
del oauth_states[state]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def get_base_url(request: Request) -> str:
|
| 60 |
+
"""
|
| 61 |
+
Get the base URL for OAuth redirects.
|
| 62 |
+
|
| 63 |
+
Uses the actual request URL scheme and hostname from FastAPI's request.url.
|
| 64 |
+
HF Spaces doesn't set X-Forwarded-Host, but the 'host' header is correct.
|
| 65 |
+
"""
|
| 66 |
+
# Get scheme from X-Forwarded-Proto or request
|
| 67 |
+
forwarded_proto = request.headers.get("x-forwarded-proto")
|
| 68 |
+
scheme = forwarded_proto if forwarded_proto else str(request.url.scheme)
|
| 69 |
+
|
| 70 |
+
# Get hostname from request URL (this comes from the 'host' header)
|
| 71 |
+
hostname = str(request.url.hostname)
|
| 72 |
+
|
| 73 |
+
# Check for port (but HF Spaces uses standard 443 for HTTPS)
|
| 74 |
+
port = request.url.port
|
| 75 |
+
if port and port not in (80, 443):
|
| 76 |
+
base_url = f"{scheme}://{hostname}:{port}"
|
| 77 |
+
else:
|
| 78 |
+
base_url = f"{scheme}://{hostname}"
|
| 79 |
+
|
| 80 |
+
logger.info(
|
| 81 |
+
f"OAuth base URL detected: {base_url}",
|
| 82 |
+
extra={
|
| 83 |
+
"scheme": scheme,
|
| 84 |
+
"hostname": hostname,
|
| 85 |
+
"port": port,
|
| 86 |
+
"request_url": str(request.url),
|
| 87 |
+
},
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
return base_url
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@router.get("/auth/login")
|
| 94 |
+
async def login(request: Request):
|
| 95 |
+
"""Redirect to Hugging Face OAuth authorization page."""
|
| 96 |
+
config = get_config()
|
| 97 |
+
|
| 98 |
+
if not config.hf_oauth_client_id:
|
| 99 |
+
raise HTTPException(
|
| 100 |
+
status_code=501,
|
| 101 |
+
detail="OAuth not configured. Set HF_OAUTH_CLIENT_ID and HF_OAUTH_CLIENT_SECRET environment variables.",
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
# Get base URL from request (handles HF Spaces proxy)
|
| 105 |
+
base_url = get_base_url(request)
|
| 106 |
+
redirect_uri = f"{base_url}/auth/callback"
|
| 107 |
+
|
| 108 |
+
state = _create_oauth_state()
|
| 109 |
+
|
| 110 |
+
# Construct HF OAuth URL
|
| 111 |
+
oauth_base = "https://huggingface.co/oauth/authorize"
|
| 112 |
+
params = {
|
| 113 |
+
"client_id": config.hf_oauth_client_id,
|
| 114 |
+
"redirect_uri": redirect_uri,
|
| 115 |
+
"scope": "openid profile email",
|
| 116 |
+
"response_type": "code",
|
| 117 |
+
"state": state,
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
auth_url = f"{oauth_base}?{urlencode(params)}"
|
| 121 |
+
logger.info(
|
| 122 |
+
"Initiating OAuth flow",
|
| 123 |
+
extra={
|
| 124 |
+
"redirect_uri": redirect_uri,
|
| 125 |
+
"auth_url": auth_url,
|
| 126 |
+
"client_id": config.hf_oauth_client_id[:8] + "...",
|
| 127 |
+
"state": state,
|
| 128 |
+
},
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
return RedirectResponse(url=auth_url, status_code=302)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@router.get("/auth/callback")
|
| 135 |
+
async def callback(
|
| 136 |
+
request: Request,
|
| 137 |
+
code: str = Query(..., description="OAuth authorization code"),
|
| 138 |
+
state: Optional[str] = Query(
|
| 139 |
+
None, description="State parameter for CSRF protection"
|
| 140 |
+
),
|
| 141 |
+
):
|
| 142 |
+
"""Handle OAuth callback from Hugging Face."""
|
| 143 |
+
config = get_config()
|
| 144 |
+
|
| 145 |
+
if not config.hf_oauth_client_id or not config.hf_oauth_client_secret:
|
| 146 |
+
raise HTTPException(status_code=501, detail="OAuth not configured")
|
| 147 |
+
|
| 148 |
+
# Get base URL from request (must match the one sent to HF)
|
| 149 |
+
base_url = get_base_url(request)
|
| 150 |
+
redirect_uri = f"{base_url}/auth/callback"
|
| 151 |
+
|
| 152 |
+
# Validate state token to prevent CSRF and replay attacks
|
| 153 |
+
_consume_oauth_state(state)
|
| 154 |
+
|
| 155 |
+
logger.info(
|
| 156 |
+
"OAuth callback received",
|
| 157 |
+
extra={
|
| 158 |
+
"redirect_uri": redirect_uri,
|
| 159 |
+
"state": state,
|
| 160 |
+
"code_length": len(code) if code else 0,
|
| 161 |
+
},
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
# Exchange authorization code for access token
|
| 166 |
+
async with httpx.AsyncClient() as client:
|
| 167 |
+
token_response = await client.post(
|
| 168 |
+
"https://huggingface.co/oauth/token",
|
| 169 |
+
data={
|
| 170 |
+
"grant_type": "authorization_code",
|
| 171 |
+
"code": code,
|
| 172 |
+
"redirect_uri": redirect_uri,
|
| 173 |
+
"client_id": config.hf_oauth_client_id,
|
| 174 |
+
"client_secret": config.hf_oauth_client_secret,
|
| 175 |
+
},
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
if token_response.status_code != 200:
|
| 179 |
+
logger.error(f"Token exchange failed: {token_response.text}")
|
| 180 |
+
raise HTTPException(
|
| 181 |
+
status_code=400,
|
| 182 |
+
detail="Failed to exchange authorization code for token",
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
token_data = token_response.json()
|
| 186 |
+
access_token = token_data.get("access_token")
|
| 187 |
+
|
| 188 |
+
if not access_token:
|
| 189 |
+
raise HTTPException(
|
| 190 |
+
status_code=400, detail="No access token in response"
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
# Get user profile from HF
|
| 194 |
+
user_response = await client.get(
|
| 195 |
+
"https://huggingface.co/api/whoami-v2",
|
| 196 |
+
headers={"Authorization": f"Bearer {access_token}"},
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
if user_response.status_code != 200:
|
| 200 |
+
logger.error(f"User profile fetch failed: {user_response.text}")
|
| 201 |
+
raise HTTPException(
|
| 202 |
+
status_code=400, detail="Failed to fetch user profile"
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
user_data = user_response.json()
|
| 206 |
+
username = user_data.get("name")
|
| 207 |
+
email = user_data.get("email")
|
| 208 |
+
hf_id = user_data.get("id") or user_data.get("uid")
|
| 209 |
+
|
| 210 |
+
if not username and not hf_id:
|
| 211 |
+
raise HTTPException(
|
| 212 |
+
status_code=400, detail="No user identity in user profile"
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
# Prefer stable HF account id; fall back to username if missing
|
| 216 |
+
user_id = str(hf_id or username)
|
| 217 |
+
|
| 218 |
+
# Create JWT for our application
|
| 219 |
+
import jwt
|
| 220 |
+
from datetime import datetime, timedelta, timezone
|
| 221 |
+
|
| 222 |
+
# Ensure the user has an initialized vault with a welcome note
|
| 223 |
+
try:
|
| 224 |
+
created = ensure_welcome_note(user_id)
|
| 225 |
+
logger.info(
|
| 226 |
+
"Ensured welcome note for user",
|
| 227 |
+
extra={"user_id": user_id, "created": created},
|
| 228 |
+
)
|
| 229 |
+
except Exception as seed_exc:
|
| 230 |
+
logger.exception(
|
| 231 |
+
"Failed to seed welcome note for user",
|
| 232 |
+
extra={"user_id": user_id},
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
payload = {
|
| 236 |
+
"sub": user_id,
|
| 237 |
+
"username": username,
|
| 238 |
+
"email": email,
|
| 239 |
+
"exp": datetime.now(timezone.utc) + timedelta(days=7),
|
| 240 |
+
"iat": datetime.now(timezone.utc),
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
try:
|
| 244 |
+
jwt_secret = auth_service._require_secret()
|
| 245 |
+
except AuthError as exc:
|
| 246 |
+
raise HTTPException(status_code=exc.status_code, detail=exc.message)
|
| 247 |
+
|
| 248 |
+
jwt_token = jwt.encode(payload, jwt_secret, algorithm="HS256")
|
| 249 |
+
|
| 250 |
+
logger.info(
|
| 251 |
+
"OAuth successful",
|
| 252 |
+
extra={
|
| 253 |
+
"username": username,
|
| 254 |
+
"user_id": user_id,
|
| 255 |
+
"email": email,
|
| 256 |
+
},
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
# Redirect to frontend with token in URL hash
|
| 260 |
+
frontend_url = base_url
|
| 261 |
+
redirect_url = f"{frontend_url}/#token={jwt_token}"
|
| 262 |
+
logger.info(f"Redirecting to frontend: {redirect_url}")
|
| 263 |
+
return RedirectResponse(url=redirect_url, status_code=302)
|
| 264 |
+
|
| 265 |
+
except httpx.HTTPError as e:
|
| 266 |
+
logger.exception(f"HTTP error during OAuth: {e}")
|
| 267 |
+
raise HTTPException(
|
| 268 |
+
status_code=500, detail="OAuth flow failed due to network error"
|
| 269 |
+
)
|
| 270 |
+
except Exception as e:
|
| 271 |
+
logger.exception(f"Unexpected error during OAuth: {e}")
|
| 272 |
+
raise HTTPException(status_code=500, detail="OAuth flow failed")
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
@router.post("/api/tokens", response_model=TokenResponse)
|
| 276 |
+
async def create_api_token(auth: AuthContext = Depends(get_auth_context)):
|
| 277 |
+
"""Issue a new JWT for the authenticated user."""
|
| 278 |
+
token, expires_at = auth_service.issue_token_response(auth.user_id)
|
| 279 |
+
return TokenResponse(token=token, token_type="bearer", expires_at=expires_at)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
@router.get("/api/me", response_model=User)
|
| 283 |
+
async def get_current_user(auth: AuthContext = Depends(get_auth_context)):
|
| 284 |
+
"""Return profile metadata for the authenticated user."""
|
| 285 |
+
user_id = auth.user_id
|
| 286 |
+
vault_service = VaultService()
|
| 287 |
+
vault_path = vault_service.initialize_vault(user_id)
|
| 288 |
+
|
| 289 |
+
# Attempt to derive a stable "created" timestamp from the vault directory
|
| 290 |
+
try:
|
| 291 |
+
stat = vault_path.stat()
|
| 292 |
+
created_dt = datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc)
|
| 293 |
+
except Exception:
|
| 294 |
+
created_dt = datetime.now(timezone.utc)
|
| 295 |
+
|
| 296 |
+
profile: Optional[HFProfile] = None
|
| 297 |
+
if user_id.startswith("hf-"):
|
| 298 |
+
username = user_id[len("hf-") :]
|
| 299 |
+
profile = HFProfile(
|
| 300 |
+
username=username,
|
| 301 |
+
name=username.replace("-", " ").title(),
|
| 302 |
+
avatar_url=f"https://api.dicebear.com/7.x/initials/svg?seed={username}",
|
| 303 |
+
)
|
| 304 |
+
elif user_id not in {"local-dev", "demo-user"}:
|
| 305 |
+
profile = HFProfile(username=user_id)
|
| 306 |
+
|
| 307 |
+
return User(
|
| 308 |
+
user_id=user_id,
|
| 309 |
+
hf_profile=profile,
|
| 310 |
+
vault_path=str(vault_path),
|
| 311 |
+
created=created_dt,
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
__all__ = ["router"]
|
backend/src/api/routes/index.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP API routes for index operations."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
|
| 10 |
+
from ...models.index import IndexHealth
|
| 11 |
+
from ...services.database import DatabaseService
|
| 12 |
+
from ...services.indexer import IndexerService
|
| 13 |
+
from ...services.vault import VaultService
|
| 14 |
+
from ..middleware import AuthContext, get_auth_context
|
| 15 |
+
|
| 16 |
+
router = APIRouter()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class RebuildResponse(BaseModel):
|
| 20 |
+
"""Response from index rebuild."""
|
| 21 |
+
|
| 22 |
+
status: str
|
| 23 |
+
notes_indexed: int
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.get("/api/index/health", response_model=IndexHealth)
|
| 27 |
+
async def get_index_health(auth: AuthContext = Depends(get_auth_context)):
|
| 28 |
+
"""Get index health statistics."""
|
| 29 |
+
user_id = auth.user_id
|
| 30 |
+
db_service = DatabaseService()
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
conn = db_service.connect()
|
| 34 |
+
try:
|
| 35 |
+
cursor = conn.execute(
|
| 36 |
+
"""
|
| 37 |
+
SELECT note_count, last_full_rebuild, last_incremental_update
|
| 38 |
+
FROM index_health
|
| 39 |
+
WHERE user_id = ?
|
| 40 |
+
""",
|
| 41 |
+
(user_id,),
|
| 42 |
+
)
|
| 43 |
+
row = cursor.fetchone()
|
| 44 |
+
|
| 45 |
+
if not row:
|
| 46 |
+
# Initialize if not exists
|
| 47 |
+
return IndexHealth(
|
| 48 |
+
user_id=user_id,
|
| 49 |
+
note_count=0,
|
| 50 |
+
last_full_rebuild=None,
|
| 51 |
+
last_incremental_update=None,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
last_full_rebuild = row["last_full_rebuild"]
|
| 55 |
+
last_incremental_update = row["last_incremental_update"]
|
| 56 |
+
|
| 57 |
+
if last_full_rebuild and isinstance(last_full_rebuild, str):
|
| 58 |
+
last_full_rebuild = datetime.fromisoformat(last_full_rebuild.replace("Z", "+00:00"))
|
| 59 |
+
|
| 60 |
+
if last_incremental_update and isinstance(last_incremental_update, str):
|
| 61 |
+
last_incremental_update = datetime.fromisoformat(last_incremental_update.replace("Z", "+00:00"))
|
| 62 |
+
|
| 63 |
+
return IndexHealth(
|
| 64 |
+
user_id=user_id,
|
| 65 |
+
note_count=row["note_count"],
|
| 66 |
+
last_full_rebuild=last_full_rebuild,
|
| 67 |
+
last_incremental_update=last_incremental_update,
|
| 68 |
+
)
|
| 69 |
+
finally:
|
| 70 |
+
conn.close()
|
| 71 |
+
except Exception as e:
|
| 72 |
+
raise HTTPException(status_code=500, detail=f"Failed to get index health: {str(e)}")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@router.post("/api/index/rebuild", response_model=RebuildResponse)
|
| 76 |
+
async def rebuild_index(auth: AuthContext = Depends(get_auth_context)):
|
| 77 |
+
"""Rebuild the entire index from scratch."""
|
| 78 |
+
user_id = auth.user_id
|
| 79 |
+
vault_service = VaultService()
|
| 80 |
+
indexer_service = IndexerService()
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
# Get all notes
|
| 84 |
+
notes = vault_service.list_notes(user_id)
|
| 85 |
+
|
| 86 |
+
# Clear existing index entries
|
| 87 |
+
db_service = DatabaseService()
|
| 88 |
+
conn = db_service.connect()
|
| 89 |
+
try:
|
| 90 |
+
with conn:
|
| 91 |
+
conn.execute("DELETE FROM note_metadata WHERE user_id = ?", (user_id,))
|
| 92 |
+
conn.execute("DELETE FROM note_fts WHERE user_id = ?", (user_id,))
|
| 93 |
+
conn.execute("DELETE FROM note_tags WHERE user_id = ?", (user_id,))
|
| 94 |
+
conn.execute("DELETE FROM note_links WHERE user_id = ?", (user_id,))
|
| 95 |
+
finally:
|
| 96 |
+
conn.close()
|
| 97 |
+
|
| 98 |
+
# Re-index all notes
|
| 99 |
+
indexed_count = 0
|
| 100 |
+
for note in notes:
|
| 101 |
+
try:
|
| 102 |
+
note_data = vault_service.read_note(user_id, note["path"])
|
| 103 |
+
indexer_service.index_note(user_id, note_data)
|
| 104 |
+
indexed_count += 1
|
| 105 |
+
except Exception as e:
|
| 106 |
+
print(f"Failed to index {note['path']}: {e}")
|
| 107 |
+
|
| 108 |
+
# Update index health
|
| 109 |
+
conn = db_service.connect()
|
| 110 |
+
try:
|
| 111 |
+
with conn:
|
| 112 |
+
conn.execute(
|
| 113 |
+
"""
|
| 114 |
+
INSERT INTO index_health (user_id, note_count, last_full_rebuild, last_incremental_update)
|
| 115 |
+
VALUES (?, ?, datetime('now'), datetime('now'))
|
| 116 |
+
ON CONFLICT(user_id) DO UPDATE SET
|
| 117 |
+
note_count = excluded.note_count,
|
| 118 |
+
last_full_rebuild = excluded.last_full_rebuild,
|
| 119 |
+
last_incremental_update = excluded.last_incremental_update
|
| 120 |
+
""",
|
| 121 |
+
(user_id, indexed_count),
|
| 122 |
+
)
|
| 123 |
+
finally:
|
| 124 |
+
conn.close()
|
| 125 |
+
|
| 126 |
+
return RebuildResponse(
|
| 127 |
+
status="completed",
|
| 128 |
+
notes_indexed=indexed_count,
|
| 129 |
+
)
|
| 130 |
+
except Exception as e:
|
| 131 |
+
raise HTTPException(status_code=500, detail=f"Failed to rebuild index: {str(e)}")
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
__all__ = ["router", "RebuildResponse"]
|
| 135 |
+
|
backend/src/api/routes/notes.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP API routes for note operations."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Optional
|
| 7 |
+
from urllib.parse import unquote
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 10 |
+
|
| 11 |
+
from ...models.note import Note, NoteSummary, NoteUpdate, NoteCreate
|
| 12 |
+
from ...services.database import DatabaseService
|
| 13 |
+
from ...services.indexer import IndexerService
|
| 14 |
+
from ...services.vault import VaultService
|
| 15 |
+
from ..middleware import AuthContext, get_auth_context
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ConflictError(Exception):
|
| 21 |
+
"""Raised when optimistic concurrency check fails."""
|
| 22 |
+
|
| 23 |
+
def __init__(self, message: str = "Version conflict detected"):
|
| 24 |
+
self.message = message
|
| 25 |
+
super().__init__(self.message)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.get("/api/notes", response_model=list[NoteSummary])
|
| 29 |
+
async def list_notes(
|
| 30 |
+
folder: Optional[str] = Query(None, description="Optional folder filter"),
|
| 31 |
+
auth: AuthContext = Depends(get_auth_context),
|
| 32 |
+
):
|
| 33 |
+
"""List all notes in the vault."""
|
| 34 |
+
user_id = auth.user_id
|
| 35 |
+
vault_service = VaultService()
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
notes = vault_service.list_notes(user_id, folder=folder)
|
| 39 |
+
|
| 40 |
+
summaries = []
|
| 41 |
+
for note in notes:
|
| 42 |
+
# list_notes returns {path, title, last_modified}
|
| 43 |
+
updated = note.get("last_modified")
|
| 44 |
+
if not isinstance(updated, datetime):
|
| 45 |
+
updated = datetime.now()
|
| 46 |
+
|
| 47 |
+
summaries.append(
|
| 48 |
+
NoteSummary(
|
| 49 |
+
note_path=note["path"],
|
| 50 |
+
title=note["title"],
|
| 51 |
+
updated=updated,
|
| 52 |
+
)
|
| 53 |
+
)
|
| 54 |
+
return summaries
|
| 55 |
+
except Exception as e:
|
| 56 |
+
raise HTTPException(status_code=500, detail=f"Failed to list notes: {str(e)}")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@router.post("/api/notes", response_model=Note, status_code=201)
|
| 60 |
+
async def create_note(create: NoteCreate, auth: AuthContext = Depends(get_auth_context)):
|
| 61 |
+
"""Create a new note."""
|
| 62 |
+
user_id = auth.user_id
|
| 63 |
+
vault_service = VaultService()
|
| 64 |
+
indexer_service = IndexerService()
|
| 65 |
+
db_service = DatabaseService()
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
note_path = create.note_path
|
| 69 |
+
|
| 70 |
+
# Check if note already exists
|
| 71 |
+
try:
|
| 72 |
+
vault_service.read_note(user_id, note_path)
|
| 73 |
+
raise HTTPException(status_code=409, detail=f"Note already exists: {note_path}")
|
| 74 |
+
except FileNotFoundError:
|
| 75 |
+
pass # Good, note doesn't exist
|
| 76 |
+
|
| 77 |
+
# Prepare metadata
|
| 78 |
+
metadata = create.metadata.model_dump() if create.metadata else {}
|
| 79 |
+
if create.title:
|
| 80 |
+
metadata["title"] = create.title
|
| 81 |
+
|
| 82 |
+
# Write note to vault
|
| 83 |
+
written_note = vault_service.write_note(
|
| 84 |
+
user_id,
|
| 85 |
+
note_path,
|
| 86 |
+
body=create.body,
|
| 87 |
+
metadata=metadata,
|
| 88 |
+
title=create.title
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# Index the note
|
| 92 |
+
new_version = indexer_service.index_note(user_id, written_note)
|
| 93 |
+
|
| 94 |
+
# Update index health
|
| 95 |
+
conn = db_service.connect()
|
| 96 |
+
try:
|
| 97 |
+
with conn:
|
| 98 |
+
indexer_service.update_index_health(conn, user_id)
|
| 99 |
+
finally:
|
| 100 |
+
conn.close()
|
| 101 |
+
|
| 102 |
+
# Return created note
|
| 103 |
+
created = written_note["metadata"].get("created")
|
| 104 |
+
updated_ts = written_note["metadata"].get("updated")
|
| 105 |
+
|
| 106 |
+
if isinstance(created, str):
|
| 107 |
+
created = datetime.fromisoformat(created.replace("Z", "+00:00"))
|
| 108 |
+
elif not isinstance(created, datetime):
|
| 109 |
+
created = datetime.now()
|
| 110 |
+
|
| 111 |
+
if isinstance(updated_ts, str):
|
| 112 |
+
updated_ts = datetime.fromisoformat(updated_ts.replace("Z", "+00:00"))
|
| 113 |
+
elif not isinstance(updated_ts, datetime):
|
| 114 |
+
updated_ts = created
|
| 115 |
+
|
| 116 |
+
return Note(
|
| 117 |
+
user_id=user_id,
|
| 118 |
+
note_path=note_path,
|
| 119 |
+
version=new_version,
|
| 120 |
+
title=written_note["title"],
|
| 121 |
+
metadata=written_note["metadata"],
|
| 122 |
+
body=written_note["body"],
|
| 123 |
+
created=created,
|
| 124 |
+
updated=updated_ts,
|
| 125 |
+
size_bytes=written_note.get("size_bytes", len(written_note["body"].encode("utf-8"))),
|
| 126 |
+
)
|
| 127 |
+
except HTTPException:
|
| 128 |
+
raise
|
| 129 |
+
except ValueError as e:
|
| 130 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 131 |
+
except Exception as e:
|
| 132 |
+
raise HTTPException(status_code=500, detail=f"Failed to create note: {str(e)}")
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@router.get("/api/notes/{path:path}", response_model=Note)
|
| 136 |
+
async def get_note(path: str, auth: AuthContext = Depends(get_auth_context)):
|
| 137 |
+
"""Get a specific note by path."""
|
| 138 |
+
user_id = auth.user_id
|
| 139 |
+
vault_service = VaultService()
|
| 140 |
+
db_service = DatabaseService()
|
| 141 |
+
|
| 142 |
+
try:
|
| 143 |
+
# URL decode the path
|
| 144 |
+
note_path = unquote(path)
|
| 145 |
+
|
| 146 |
+
# Read note from vault
|
| 147 |
+
note_data = vault_service.read_note(user_id, note_path)
|
| 148 |
+
|
| 149 |
+
# Get version from index
|
| 150 |
+
conn = db_service.connect()
|
| 151 |
+
try:
|
| 152 |
+
cursor = conn.execute(
|
| 153 |
+
"SELECT version FROM note_metadata WHERE user_id = ? AND note_path = ?",
|
| 154 |
+
(user_id, note_path),
|
| 155 |
+
)
|
| 156 |
+
row = cursor.fetchone()
|
| 157 |
+
version = row["version"] if row else 1
|
| 158 |
+
finally:
|
| 159 |
+
conn.close()
|
| 160 |
+
|
| 161 |
+
# Parse metadata
|
| 162 |
+
metadata = note_data.get("metadata", {})
|
| 163 |
+
created = metadata.get("created")
|
| 164 |
+
updated = metadata.get("updated")
|
| 165 |
+
|
| 166 |
+
if isinstance(created, str):
|
| 167 |
+
created = datetime.fromisoformat(created.replace("Z", "+00:00"))
|
| 168 |
+
elif not isinstance(created, datetime):
|
| 169 |
+
created = datetime.now()
|
| 170 |
+
|
| 171 |
+
if isinstance(updated, str):
|
| 172 |
+
updated = datetime.fromisoformat(updated.replace("Z", "+00:00"))
|
| 173 |
+
elif not isinstance(updated, datetime):
|
| 174 |
+
updated = created
|
| 175 |
+
|
| 176 |
+
return Note(
|
| 177 |
+
user_id=user_id,
|
| 178 |
+
note_path=note_path,
|
| 179 |
+
version=version,
|
| 180 |
+
title=note_data["title"],
|
| 181 |
+
metadata=metadata,
|
| 182 |
+
body=note_data["body"],
|
| 183 |
+
created=created,
|
| 184 |
+
updated=updated,
|
| 185 |
+
size_bytes=note_data.get("size_bytes", len(note_data["body"].encode("utf-8"))),
|
| 186 |
+
)
|
| 187 |
+
except FileNotFoundError:
|
| 188 |
+
raise HTTPException(status_code=404, detail=f"Note not found: {path}")
|
| 189 |
+
except Exception as e:
|
| 190 |
+
raise HTTPException(status_code=500, detail=f"Failed to read note: {str(e)}")
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
@router.put("/api/notes/{path:path}", response_model=Note)
|
| 194 |
+
async def update_note(
|
| 195 |
+
path: str,
|
| 196 |
+
update: NoteUpdate,
|
| 197 |
+
auth: AuthContext = Depends(get_auth_context),
|
| 198 |
+
):
|
| 199 |
+
"""Update a note with optimistic concurrency control."""
|
| 200 |
+
user_id = auth.user_id
|
| 201 |
+
vault_service = VaultService()
|
| 202 |
+
indexer_service = IndexerService()
|
| 203 |
+
db_service = DatabaseService()
|
| 204 |
+
|
| 205 |
+
try:
|
| 206 |
+
# URL decode the path
|
| 207 |
+
note_path = unquote(path)
|
| 208 |
+
|
| 209 |
+
# Check version if provided
|
| 210 |
+
if update.if_version is not None:
|
| 211 |
+
conn = db_service.connect()
|
| 212 |
+
try:
|
| 213 |
+
cursor = conn.execute(
|
| 214 |
+
"SELECT version FROM note_metadata WHERE user_id = ? AND note_path = ?",
|
| 215 |
+
(user_id, note_path),
|
| 216 |
+
)
|
| 217 |
+
row = cursor.fetchone()
|
| 218 |
+
current_version = row["version"] if row else 0
|
| 219 |
+
|
| 220 |
+
if current_version != update.if_version:
|
| 221 |
+
raise ConflictError(
|
| 222 |
+
f"Version conflict: expected {update.if_version}, got {current_version}"
|
| 223 |
+
)
|
| 224 |
+
finally:
|
| 225 |
+
conn.close()
|
| 226 |
+
|
| 227 |
+
# Prepare metadata
|
| 228 |
+
metadata = update.metadata.model_dump() if update.metadata else {}
|
| 229 |
+
if update.title:
|
| 230 |
+
metadata["title"] = update.title
|
| 231 |
+
|
| 232 |
+
# Write note to vault
|
| 233 |
+
written_note = vault_service.write_note(
|
| 234 |
+
user_id,
|
| 235 |
+
note_path,
|
| 236 |
+
body=update.body,
|
| 237 |
+
metadata=metadata,
|
| 238 |
+
title=update.title
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
# Index the note
|
| 242 |
+
new_version = indexer_service.index_note(user_id, written_note)
|
| 243 |
+
|
| 244 |
+
# Update index health
|
| 245 |
+
conn = db_service.connect()
|
| 246 |
+
try:
|
| 247 |
+
with conn:
|
| 248 |
+
indexer_service.update_index_health(conn, user_id)
|
| 249 |
+
finally:
|
| 250 |
+
conn.close()
|
| 251 |
+
|
| 252 |
+
# Return updated note
|
| 253 |
+
created = written_note["metadata"].get("created")
|
| 254 |
+
updated_ts = written_note["metadata"].get("updated")
|
| 255 |
+
|
| 256 |
+
if isinstance(created, str):
|
| 257 |
+
created = datetime.fromisoformat(created.replace("Z", "+00:00"))
|
| 258 |
+
elif not isinstance(created, datetime):
|
| 259 |
+
created = datetime.now()
|
| 260 |
+
|
| 261 |
+
if isinstance(updated_ts, str):
|
| 262 |
+
updated_ts = datetime.fromisoformat(updated_ts.replace("Z", "+00:00"))
|
| 263 |
+
elif not isinstance(updated_ts, datetime):
|
| 264 |
+
updated_ts = created
|
| 265 |
+
|
| 266 |
+
return Note(
|
| 267 |
+
user_id=user_id,
|
| 268 |
+
note_path=note_path,
|
| 269 |
+
version=new_version,
|
| 270 |
+
title=written_note["title"],
|
| 271 |
+
metadata=written_note["metadata"],
|
| 272 |
+
body=written_note["body"],
|
| 273 |
+
created=created,
|
| 274 |
+
updated=updated_ts,
|
| 275 |
+
size_bytes=written_note.get("size_bytes", len(written_note["body"].encode("utf-8"))),
|
| 276 |
+
)
|
| 277 |
+
except ConflictError as e:
|
| 278 |
+
raise HTTPException(status_code=409, detail=str(e))
|
| 279 |
+
except FileNotFoundError:
|
| 280 |
+
raise HTTPException(status_code=404, detail=f"Note not found: {path}")
|
| 281 |
+
except ValueError as e:
|
| 282 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 283 |
+
except Exception as e:
|
| 284 |
+
raise HTTPException(status_code=500, detail=f"Failed to update note: {str(e)}")
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
__all__ = ["router", "ConflictError"]
|
| 288 |
+
|
backend/src/api/routes/search.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP API routes for search operations."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Optional
|
| 7 |
+
from urllib.parse import unquote
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 10 |
+
from pydantic import BaseModel
|
| 11 |
+
|
| 12 |
+
from ...models.index import Tag
|
| 13 |
+
from ...models.search import SearchResult
|
| 14 |
+
from ...services.database import DatabaseService
|
| 15 |
+
from ...services.indexer import IndexerService
|
| 16 |
+
from ..middleware import AuthContext, get_auth_context
|
| 17 |
+
|
| 18 |
+
router = APIRouter()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class BacklinkResult(BaseModel):
|
| 22 |
+
"""Result from backlinks query."""
|
| 23 |
+
|
| 24 |
+
note_path: str
|
| 25 |
+
title: str
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.get("/api/search", response_model=list[SearchResult])
|
| 29 |
+
async def search_notes(
|
| 30 |
+
q: str = Query(..., min_length=1, max_length=256),
|
| 31 |
+
auth: AuthContext = Depends(get_auth_context),
|
| 32 |
+
):
|
| 33 |
+
"""Full-text search across all notes."""
|
| 34 |
+
user_id = auth.user_id
|
| 35 |
+
indexer_service = IndexerService()
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
results = indexer_service.search_notes(user_id, q, limit=50)
|
| 39 |
+
|
| 40 |
+
search_results = []
|
| 41 |
+
for result in results:
|
| 42 |
+
# Use snippet from search results
|
| 43 |
+
snippet = result.get("snippet", "")
|
| 44 |
+
|
| 45 |
+
updated = result.get("updated")
|
| 46 |
+
if isinstance(updated, str):
|
| 47 |
+
updated = datetime.fromisoformat(updated.replace("Z", "+00:00"))
|
| 48 |
+
elif not isinstance(updated, datetime):
|
| 49 |
+
updated = datetime.now()
|
| 50 |
+
|
| 51 |
+
search_results.append(
|
| 52 |
+
SearchResult(
|
| 53 |
+
note_path=result["path"],
|
| 54 |
+
title=result["title"],
|
| 55 |
+
snippet=snippet,
|
| 56 |
+
score=result.get("score", 0.0),
|
| 57 |
+
updated=updated,
|
| 58 |
+
)
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
return search_results
|
| 62 |
+
except Exception as e:
|
| 63 |
+
raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@router.get("/api/backlinks/{path:path}", response_model=list[BacklinkResult])
|
| 67 |
+
async def get_backlinks(path: str, auth: AuthContext = Depends(get_auth_context)):
|
| 68 |
+
"""Get all notes that link to this note."""
|
| 69 |
+
user_id = auth.user_id
|
| 70 |
+
indexer_service = IndexerService()
|
| 71 |
+
|
| 72 |
+
try:
|
| 73 |
+
# URL decode the path
|
| 74 |
+
note_path = unquote(path)
|
| 75 |
+
|
| 76 |
+
backlinks = indexer_service.get_backlinks(user_id, note_path)
|
| 77 |
+
|
| 78 |
+
return [
|
| 79 |
+
BacklinkResult(
|
| 80 |
+
note_path=backlink["path"],
|
| 81 |
+
title=backlink["title"],
|
| 82 |
+
)
|
| 83 |
+
for backlink in backlinks
|
| 84 |
+
]
|
| 85 |
+
except Exception as e:
|
| 86 |
+
raise HTTPException(status_code=500, detail=f"Failed to get backlinks: {str(e)}")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@router.get("/api/tags", response_model=list[Tag])
|
| 90 |
+
async def get_tags(auth: AuthContext = Depends(get_auth_context)):
|
| 91 |
+
"""Get all tags with usage counts."""
|
| 92 |
+
user_id = auth.user_id
|
| 93 |
+
indexer_service = IndexerService()
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
tags = indexer_service.get_tags(user_id)
|
| 97 |
+
|
| 98 |
+
return [
|
| 99 |
+
Tag(tag_name=tag["tag"], count=tag["count"])
|
| 100 |
+
for tag in tags
|
| 101 |
+
]
|
| 102 |
+
except Exception as e:
|
| 103 |
+
raise HTTPException(status_code=500, detail=f"Failed to get tags: {str(e)}")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
__all__ = ["router", "BacklinkResult"]
|
| 107 |
+
|
backend/src/mcp/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP (Model Context Protocol) server implementation."""
|
| 2 |
+
|
| 3 |
+
from .server import mcp
|
| 4 |
+
|
| 5 |
+
__all__ = ["mcp"]
|
backend/src/mcp/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (317 Bytes). View file
|
|
|
backend/src/mcp/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (293 Bytes). View file
|
|
|
backend/src/mcp/__pycache__/server.cpython-311.pyc
ADDED
|
Binary file (12.1 kB). View file
|
|
|
backend/src/mcp/__pycache__/server.cpython-313.pyc
ADDED
|
Binary file (10.3 kB). View file
|
|
|
backend/src/mcp/server.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastMCP server exposing vault and indexing tools."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import time
|
| 8 |
+
from typing import Any, Dict, List, Optional
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
from dotenv import load_dotenv
|
| 12 |
+
from fastmcp import FastMCP
|
| 13 |
+
from pydantic import Field
|
| 14 |
+
|
| 15 |
+
# Load environment variables from the backend/.env file regardless of CWD
|
| 16 |
+
ENV_PATH = Path(__file__).resolve().parents[2] / ".env"
|
| 17 |
+
load_dotenv(dotenv_path=ENV_PATH)
|
| 18 |
+
|
| 19 |
+
from ..services import IndexerService, VaultNote, VaultService
|
| 20 |
+
from ..services.auth import AuthError, AuthService
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
from fastmcp.server.http import _current_http_request # type: ignore
|
| 24 |
+
except ImportError: # pragma: no cover
|
| 25 |
+
_current_http_request = None
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
mcp = FastMCP(
|
| 30 |
+
"obsidian-docs-viewer",
|
| 31 |
+
instructions=(
|
| 32 |
+
"Multi-tenant vault tools. STDIO uses user_id 'local-dev'; HTTP mode must validate each "
|
| 33 |
+
"request with JWT.sub. Note paths must be relative '.md' ≤256 chars without '..' or '\\'. "
|
| 34 |
+
"Frontmatter is YAML: tags are string arrays and 'version' is reserved. Notes must be ≤1 MiB; "
|
| 35 |
+
"writes refresh created/updated timestamps and synchronously update the search index; deletes "
|
| 36 |
+
"clear index rows and backlinks. Wikilinks use [[...]] slug matching (prefer same folder, else "
|
| 37 |
+
"lexicographic). Search ranking = bm25(title*3, body*1) + recency bonus (+1 ≤7d, +0.5 ≤30d)."
|
| 38 |
+
),
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
vault_service = VaultService()
|
| 42 |
+
indexer_service = IndexerService()
|
| 43 |
+
auth_service = AuthService()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _current_user_id() -> str:
|
| 47 |
+
"""Resolve the acting user ID (local mode defaults to local-dev)."""
|
| 48 |
+
# HTTP transport (hosted) uses Authorization headers
|
| 49 |
+
if _current_http_request is not None:
|
| 50 |
+
try:
|
| 51 |
+
request = _current_http_request.get() # type: ignore[call-arg]
|
| 52 |
+
except LookupError:
|
| 53 |
+
request = None
|
| 54 |
+
if request is not None:
|
| 55 |
+
header = request.headers.get("Authorization")
|
| 56 |
+
if not header:
|
| 57 |
+
raise PermissionError("Authorization header required")
|
| 58 |
+
scheme, _, token = header.partition(" ")
|
| 59 |
+
if scheme.lower() != "bearer" or not token:
|
| 60 |
+
raise PermissionError("Authorization header must be 'Bearer <token>'")
|
| 61 |
+
try:
|
| 62 |
+
payload = auth_service.validate_jwt(token)
|
| 63 |
+
except AuthError as exc:
|
| 64 |
+
raise PermissionError(exc.message) from exc
|
| 65 |
+
os.environ.setdefault("LOCAL_USER_ID", payload.sub)
|
| 66 |
+
return payload.sub
|
| 67 |
+
|
| 68 |
+
# STDIO / local fall back
|
| 69 |
+
return os.getenv("LOCAL_USER_ID", "local-dev")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _note_to_response(note: VaultNote) -> Dict[str, Any]:
|
| 73 |
+
return {
|
| 74 |
+
"path": note["path"],
|
| 75 |
+
"title": note["title"],
|
| 76 |
+
"metadata": dict(note.get("metadata") or {}),
|
| 77 |
+
"body": note.get("body", ""),
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@mcp.tool(
|
| 82 |
+
name="list_notes",
|
| 83 |
+
description="List notes in the vault (optionally scoped to a folder).",
|
| 84 |
+
)
|
| 85 |
+
def list_notes(
|
| 86 |
+
folder: Optional[str] = Field(
|
| 87 |
+
default=None,
|
| 88 |
+
description="Optional relative folder (trim '/' ; no '..' or '\\').",
|
| 89 |
+
),
|
| 90 |
+
) -> List[Dict[str, Any]]:
|
| 91 |
+
start_time = time.time()
|
| 92 |
+
user_id = _current_user_id()
|
| 93 |
+
|
| 94 |
+
notes = vault_service.list_notes(user_id, folder=folder)
|
| 95 |
+
|
| 96 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 97 |
+
logger.info(
|
| 98 |
+
"MCP tool called",
|
| 99 |
+
extra={
|
| 100 |
+
"tool_name": "list_notes",
|
| 101 |
+
"user_id": user_id,
|
| 102 |
+
"folder": folder or "(root)",
|
| 103 |
+
"result_count": len(notes),
|
| 104 |
+
"duration_ms": f"{duration_ms:.2f}",
|
| 105 |
+
},
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
return [
|
| 109 |
+
{
|
| 110 |
+
"path": entry["path"],
|
| 111 |
+
"title": entry["title"],
|
| 112 |
+
"last_modified": entry["last_modified"].isoformat(),
|
| 113 |
+
}
|
| 114 |
+
for entry in notes
|
| 115 |
+
]
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@mcp.tool(name="read_note", description="Read a Markdown note with metadata and body.")
|
| 119 |
+
def read_note(
|
| 120 |
+
path: str = Field(
|
| 121 |
+
..., description="Relative '.md' path ≤256 chars (no '..' or '\\')."
|
| 122 |
+
),
|
| 123 |
+
) -> Dict[str, Any]:
|
| 124 |
+
start_time = time.time()
|
| 125 |
+
user_id = _current_user_id()
|
| 126 |
+
|
| 127 |
+
note = vault_service.read_note(user_id, path)
|
| 128 |
+
|
| 129 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 130 |
+
logger.info(
|
| 131 |
+
"MCP tool called",
|
| 132 |
+
extra={
|
| 133 |
+
"tool_name": "read_note",
|
| 134 |
+
"user_id": user_id,
|
| 135 |
+
"note_path": path,
|
| 136 |
+
"duration_ms": f"{duration_ms:.2f}",
|
| 137 |
+
},
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
return _note_to_response(note)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@mcp.tool(
|
| 144 |
+
name="write_note",
|
| 145 |
+
description="Create or update a note. Automatically updates frontmatter timestamps and search index.",
|
| 146 |
+
)
|
| 147 |
+
def write_note(
|
| 148 |
+
path: str = Field(
|
| 149 |
+
..., description="Relative '.md' path ≤256 chars (no '..' or '\\')."
|
| 150 |
+
),
|
| 151 |
+
body: str = Field(..., description="Markdown body ≤1 MiB."),
|
| 152 |
+
title: Optional[str] = Field(
|
| 153 |
+
default=None,
|
| 154 |
+
description="Optional title override; otherwise frontmatter/H1/filename is used.",
|
| 155 |
+
),
|
| 156 |
+
metadata: Optional[Dict[str, Any]] = Field(
|
| 157 |
+
default=None,
|
| 158 |
+
description="Optional frontmatter dict (tags arrays of strings; 'version' reserved).",
|
| 159 |
+
),
|
| 160 |
+
) -> Dict[str, Any]:
|
| 161 |
+
start_time = time.time()
|
| 162 |
+
user_id = _current_user_id()
|
| 163 |
+
|
| 164 |
+
note = vault_service.write_note(
|
| 165 |
+
user_id,
|
| 166 |
+
path,
|
| 167 |
+
title=title,
|
| 168 |
+
metadata=metadata,
|
| 169 |
+
body=body,
|
| 170 |
+
)
|
| 171 |
+
indexer_service.index_note(user_id, note)
|
| 172 |
+
|
| 173 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 174 |
+
logger.info(
|
| 175 |
+
"MCP tool called",
|
| 176 |
+
extra={
|
| 177 |
+
"tool_name": "write_note",
|
| 178 |
+
"user_id": user_id,
|
| 179 |
+
"note_path": path,
|
| 180 |
+
"duration_ms": f"{duration_ms:.2f}",
|
| 181 |
+
},
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
return {"status": "ok", "path": path}
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
@mcp.tool(name="delete_note", description="Delete a note and remove it from the index.")
|
| 188 |
+
def delete_note(
|
| 189 |
+
path: str = Field(
|
| 190 |
+
..., description="Relative '.md' path ≤256 chars (no '..' or '\\')."
|
| 191 |
+
),
|
| 192 |
+
) -> Dict[str, str]:
|
| 193 |
+
start_time = time.time()
|
| 194 |
+
user_id = _current_user_id()
|
| 195 |
+
|
| 196 |
+
vault_service.delete_note(user_id, path)
|
| 197 |
+
indexer_service.delete_note_index(user_id, path)
|
| 198 |
+
|
| 199 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 200 |
+
logger.info(
|
| 201 |
+
"MCP tool called",
|
| 202 |
+
extra={
|
| 203 |
+
"tool_name": "delete_note",
|
| 204 |
+
"user_id": user_id,
|
| 205 |
+
"note_path": path,
|
| 206 |
+
"duration_ms": f"{duration_ms:.2f}",
|
| 207 |
+
},
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
return {"status": "ok"}
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
@mcp.tool(
|
| 214 |
+
name="search_notes",
|
| 215 |
+
description="Full-text search with snippets and recency-aware scoring.",
|
| 216 |
+
)
|
| 217 |
+
def search_notes(
|
| 218 |
+
query: str = Field(..., description="Non-empty search query (bm25 + recency)."),
|
| 219 |
+
limit: int = Field(50, ge=1, le=100, description="Result cap between 1 and 100."),
|
| 220 |
+
) -> List[Dict[str, Any]]:
|
| 221 |
+
start_time = time.time()
|
| 222 |
+
user_id = _current_user_id()
|
| 223 |
+
|
| 224 |
+
results = indexer_service.search_notes(user_id, query, limit=limit)
|
| 225 |
+
|
| 226 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 227 |
+
logger.info(
|
| 228 |
+
"MCP tool called",
|
| 229 |
+
extra={
|
| 230 |
+
"tool_name": "search_notes",
|
| 231 |
+
"user_id": user_id,
|
| 232 |
+
"query": query,
|
| 233 |
+
"limit": limit,
|
| 234 |
+
"result_count": len(results),
|
| 235 |
+
"duration_ms": f"{duration_ms:.2f}",
|
| 236 |
+
},
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
return [
|
| 240 |
+
{
|
| 241 |
+
"path": row["path"],
|
| 242 |
+
"title": row["title"],
|
| 243 |
+
"snippet": row["snippet"],
|
| 244 |
+
}
|
| 245 |
+
for row in results
|
| 246 |
+
]
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
@mcp.tool(
|
| 250 |
+
name="get_backlinks", description="List notes that reference the target note."
|
| 251 |
+
)
|
| 252 |
+
def get_backlinks(
|
| 253 |
+
path: str = Field(
|
| 254 |
+
..., description="Relative '.md' path ≤256 chars (no '..' or '\\')."
|
| 255 |
+
),
|
| 256 |
+
) -> List[Dict[str, Any]]:
|
| 257 |
+
user_id = _current_user_id()
|
| 258 |
+
backlinks = indexer_service.get_backlinks(user_id, path)
|
| 259 |
+
return backlinks
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
@mcp.tool(name="get_tags", description="List tags and associated note counts.")
|
| 263 |
+
def get_tags() -> List[Dict[str, Any]]:
|
| 264 |
+
user_id = _current_user_id()
|
| 265 |
+
return indexer_service.get_tags(user_id)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
if __name__ == "__main__":
|
| 269 |
+
transport = os.getenv("MCP_TRANSPORT", "stdio").strip().lower()
|
| 270 |
+
|
| 271 |
+
# Configure HTTP transport with custom port if specified
|
| 272 |
+
if transport == "http":
|
| 273 |
+
# Prefer MCP_PORT, then platform-provided PORT, with a sane default.
|
| 274 |
+
port = int(os.getenv("MCP_PORT"))
|
| 275 |
+
# Bind to all interfaces by default for hosted environments (HF Spaces).
|
| 276 |
+
host = os.getenv("MCP_HOST", "0.0.0.0")
|
| 277 |
+
logger.info(
|
| 278 |
+
"Starting MCP server",
|
| 279 |
+
extra={"transport": transport, "host": host, "port": port},
|
| 280 |
+
)
|
| 281 |
+
mcp.run(transport=transport, host=host, port=port)
|
| 282 |
+
else:
|
| 283 |
+
logger.info("Starting MCP server", extra={"transport": transport})
|
| 284 |
+
mcp.run(transport=transport)
|
backend/src/models/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic models for data validation and serialization."""
|
| 2 |
+
|
| 3 |
+
from .auth import JWTPayload, TokenResponse
|
| 4 |
+
from .index import IndexHealth, Tag, Wikilink
|
| 5 |
+
from .note import Note, NoteCreate, NoteMetadata, NoteSummary, NoteUpdate
|
| 6 |
+
from .search import SearchRequest, SearchResult
|
| 7 |
+
from .user import HFProfile, User
|
| 8 |
+
|
| 9 |
+
__all__ = [
|
| 10 |
+
"User",
|
| 11 |
+
"HFProfile",
|
| 12 |
+
"Note",
|
| 13 |
+
"NoteMetadata",
|
| 14 |
+
"NoteCreate",
|
| 15 |
+
"NoteUpdate",
|
| 16 |
+
"NoteSummary",
|
| 17 |
+
"Wikilink",
|
| 18 |
+
"Tag",
|
| 19 |
+
"IndexHealth",
|
| 20 |
+
"SearchResult",
|
| 21 |
+
"SearchRequest",
|
| 22 |
+
"TokenResponse",
|
| 23 |
+
"JWTPayload",
|
| 24 |
+
]
|
| 25 |
+
|
backend/src/models/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (863 Bytes). View file
|
|
|
backend/src/models/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (721 Bytes). View file
|
|
|
backend/src/models/__pycache__/auth.cpython-311.pyc
ADDED
|
Binary file (1.64 kB). View file
|
|
|
backend/src/models/__pycache__/auth.cpython-313.pyc
ADDED
|
Binary file (1.39 kB). View file
|
|
|