Implement multi-stage Docker build with security hardening, add health check endpoint, optimize in-memory search with O(1) wikilink lookup, extract inline tags from markdown content, and enhance documentation with architecture diagrams and performance metrics
This commit is contained in:
+318
-65
@@ -1,14 +1,16 @@
|
||||
import re
|
||||
import html as html_mod
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
import frontmatter
|
||||
import mistune
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi import FastAPI, HTTPException, Query, Body
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, PlainTextResponse
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.indexer import (
|
||||
build_index,
|
||||
@@ -17,6 +19,7 @@ from backend.indexer import (
|
||||
get_vault_data,
|
||||
find_file_in_index,
|
||||
parse_markdown_file,
|
||||
_extract_tags,
|
||||
SUPPORTED_EXTENSIONS,
|
||||
)
|
||||
from backend.search import search, get_all_tags
|
||||
@@ -27,29 +30,182 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
app = FastAPI(title="ObsiGate", version="1.0.0")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class VaultInfo(BaseModel):
|
||||
"""Summary information about a configured vault."""
|
||||
name: str = Field(description="Display name of the vault")
|
||||
file_count: int = Field(description="Number of indexed files")
|
||||
tag_count: int = Field(description="Number of unique tags")
|
||||
|
||||
|
||||
class BrowseItem(BaseModel):
|
||||
"""A single entry (file or directory) returned by the browse endpoint."""
|
||||
name: str
|
||||
path: str
|
||||
type: str = Field(description="'file' or 'directory'")
|
||||
children_count: Optional[int] = None
|
||||
size: Optional[int] = None
|
||||
extension: Optional[str] = None
|
||||
|
||||
|
||||
class BrowseResponse(BaseModel):
|
||||
"""Paginated directory listing for a vault."""
|
||||
vault: str
|
||||
path: str
|
||||
items: List[BrowseItem]
|
||||
|
||||
|
||||
class FileContentResponse(BaseModel):
|
||||
"""Rendered file content with metadata."""
|
||||
vault: str
|
||||
path: str
|
||||
title: str
|
||||
tags: List[str]
|
||||
frontmatter: Dict[str, Any]
|
||||
html: str
|
||||
raw_length: int
|
||||
extension: str
|
||||
is_markdown: bool
|
||||
|
||||
|
||||
class FileRawResponse(BaseModel):
|
||||
"""Raw text content of a file."""
|
||||
vault: str
|
||||
path: str
|
||||
raw: str
|
||||
|
||||
|
||||
class FileSaveResponse(BaseModel):
|
||||
"""Confirmation after saving a file."""
|
||||
status: str
|
||||
vault: str
|
||||
path: str
|
||||
size: int
|
||||
|
||||
|
||||
class FileDeleteResponse(BaseModel):
|
||||
"""Confirmation after deleting a file."""
|
||||
status: str
|
||||
vault: str
|
||||
path: str
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
"""A single search result."""
|
||||
vault: str
|
||||
path: str
|
||||
title: str
|
||||
tags: List[str]
|
||||
score: int
|
||||
snippet: str
|
||||
modified: str
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Full-text search response."""
|
||||
query: str
|
||||
vault_filter: str
|
||||
tag_filter: Optional[str]
|
||||
count: int
|
||||
results: List[SearchResultItem]
|
||||
|
||||
|
||||
class TagsResponse(BaseModel):
|
||||
"""Tag aggregation response."""
|
||||
vault_filter: Optional[str]
|
||||
tags: Dict[str, int]
|
||||
|
||||
|
||||
class ReloadResponse(BaseModel):
|
||||
"""Index reload confirmation with per-vault stats."""
|
||||
status: str
|
||||
vaults: Dict[str, Any]
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Application health status."""
|
||||
status: str
|
||||
version: str
|
||||
vaults: int
|
||||
total_files: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application lifespan (replaces deprecated on_event)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: build index on startup, cleanup on shutdown."""
|
||||
logger.info("ObsiGate starting \u2014 building index...")
|
||||
await build_index()
|
||||
logger.info("ObsiGate ready.")
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="ObsiGate", version="1.1.0", lifespan=lifespan)
|
||||
|
||||
# Resolve frontend path relative to this file
|
||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Startup
|
||||
# Path safety helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
logger.info("ObsiGate starting — building index...")
|
||||
await build_index()
|
||||
logger.info("ObsiGate ready.")
|
||||
def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
|
||||
"""Resolve a relative path safely within the vault root.
|
||||
|
||||
Prevents directory traversal attacks by ensuring the resolved
|
||||
absolute path is a descendant of *vault_root*.
|
||||
|
||||
Args:
|
||||
vault_root: The vault's root directory (absolute).
|
||||
relative_path: The user-supplied relative path.
|
||||
|
||||
Returns:
|
||||
Resolved absolute ``Path``.
|
||||
|
||||
Raises:
|
||||
HTTPException(403): When the resolved path escapes the vault root.
|
||||
"""
|
||||
resolved = (vault_root / relative_path).resolve()
|
||||
vault_resolved = vault_root.resolve()
|
||||
try:
|
||||
resolved.relative_to(vault_resolved)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="Access denied: path outside vault")
|
||||
return resolved
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown rendering helpers
|
||||
# Markdown rendering helpers (singleton renderer)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Cached mistune renderer — avoids re-creating on every request
|
||||
_markdown_renderer = mistune.create_markdown(
|
||||
escape=False,
|
||||
plugins=["table", "strikethrough", "footnotes", "task_lists"],
|
||||
)
|
||||
|
||||
|
||||
def _convert_wikilinks(content: str, current_vault: str) -> str:
|
||||
"""Convert [[wikilinks]] and [[target|display]] to HTML links."""
|
||||
"""Convert ``[[wikilinks]]`` and ``[[target|display]]`` to clickable HTML.
|
||||
|
||||
Resolved links get a ``data-vault`` / ``data-path`` attribute pair.
|
||||
Unresolved links are rendered as ``<span class="wikilink-missing">``.
|
||||
|
||||
Args:
|
||||
content: Markdown string potentially containing wikilinks.
|
||||
current_vault: Active vault name for resolution priority.
|
||||
|
||||
Returns:
|
||||
Markdown string with wikilinks replaced by HTML anchors.
|
||||
"""
|
||||
def _replace(match):
|
||||
target = match.group(1).strip()
|
||||
display = match.group(2).strip() if match.group(2) else target
|
||||
@@ -67,22 +223,48 @@ def _convert_wikilinks(content: str, current_vault: str) -> str:
|
||||
|
||||
|
||||
def _render_markdown(raw_md: str, vault_name: str) -> str:
|
||||
"""Render markdown string to HTML with wikilink support."""
|
||||
"""Render a markdown string to HTML with wikilink support.
|
||||
|
||||
Uses the cached singleton mistune renderer for performance.
|
||||
|
||||
Args:
|
||||
raw_md: Raw markdown text (frontmatter already stripped).
|
||||
vault_name: Current vault for wikilink resolution context.
|
||||
|
||||
Returns:
|
||||
HTML string.
|
||||
"""
|
||||
converted = _convert_wikilinks(raw_md, vault_name)
|
||||
md = mistune.create_markdown(
|
||||
escape=False,
|
||||
plugins=["table", "strikethrough", "footnotes", "task_lists"],
|
||||
)
|
||||
return md(converted)
|
||||
return _markdown_renderer(converted)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/vaults")
|
||||
@app.get("/api/health", response_model=HealthResponse)
|
||||
async def api_health():
|
||||
"""Health check endpoint for Docker and monitoring.
|
||||
|
||||
Returns:
|
||||
Application status, version, vault count and total file count.
|
||||
"""
|
||||
total_files = sum(len(v["files"]) for v in index.values())
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": app.version,
|
||||
"vaults": len(index),
|
||||
"total_files": total_files,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/vaults", response_model=List[VaultInfo])
|
||||
async def api_vaults():
|
||||
"""List configured vaults with file counts."""
|
||||
"""List all configured vaults with file and tag counts.
|
||||
|
||||
Returns:
|
||||
List of vault summary objects.
|
||||
"""
|
||||
result = []
|
||||
for name, data in index.items():
|
||||
result.append({
|
||||
@@ -93,15 +275,27 @@ async def api_vaults():
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/browse/{vault_name}")
|
||||
@app.get("/api/browse/{vault_name}", response_model=BrowseResponse)
|
||||
async def api_browse(vault_name: str, path: str = ""):
|
||||
"""Browse directories and files in a vault at a given path level."""
|
||||
"""Browse directories and files in a vault at a given path level.
|
||||
|
||||
Returns sorted entries (directories first, then files) with metadata.
|
||||
Hidden files/directories (starting with ``"."`` ) are excluded.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault to browse.
|
||||
path: Relative directory path within the vault (empty = root).
|
||||
|
||||
Returns:
|
||||
``BrowseResponse`` with vault name, path, and item list.
|
||||
"""
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
target = vault_root / path if path else vault_root
|
||||
# Path traversal protection
|
||||
target = _resolve_safe_path(vault_root, path) if path else vault_root.resolve()
|
||||
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Path not found: {path}")
|
||||
@@ -161,15 +355,23 @@ EXT_TO_LANG = {
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}/raw")
|
||||
@app.get("/api/file/{vault_name}/raw", response_model=FileRawResponse)
|
||||
async def api_file_raw(vault_name: str, path: str = Query(..., description="Relative path to file")):
|
||||
"""Return raw file content."""
|
||||
"""Return raw file content as plain text.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileRawResponse`` with vault, path, and raw text content.
|
||||
"""
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = vault_root / path
|
||||
file_path = _resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
@@ -180,13 +382,21 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
|
||||
|
||||
@app.get("/api/file/{vault_name}/download")
|
||||
async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file")):
|
||||
"""Download a file as attachment."""
|
||||
"""Download a file as an attachment.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileResponse`` with ``application/octet-stream`` content-type.
|
||||
"""
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = vault_root / path
|
||||
file_path = _resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
@@ -198,28 +408,37 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
|
||||
)
|
||||
|
||||
|
||||
@app.put("/api/file/{vault_name}/save")
|
||||
async def api_file_save(vault_name: str, path: str = Query(..., description="Relative path to file"), body: dict = {}):
|
||||
"""Save file content."""
|
||||
@app.put("/api/file/{vault_name}/save", response_model=FileSaveResponse)
|
||||
async def api_file_save(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
body: dict = Body(...),
|
||||
):
|
||||
"""Save (overwrite) a file's content.
|
||||
|
||||
Expects a JSON body with a ``content`` key containing the new text.
|
||||
The path is validated against traversal attacks before writing.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
body: JSON body with ``content`` string.
|
||||
|
||||
Returns:
|
||||
``FileSaveResponse`` confirming the write.
|
||||
"""
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = vault_root / path
|
||||
|
||||
# Security: ensure path is within vault
|
||||
try:
|
||||
file_path.resolve().relative_to(vault_root.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="Access denied: path outside vault")
|
||||
file_path = _resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
# Get content from body
|
||||
content = body.get('content', '')
|
||||
|
||||
|
||||
try:
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
logger.info(f"File saved: {vault_name}/{path}")
|
||||
@@ -231,20 +450,25 @@ async def api_file_save(vault_name: str, path: str = Query(..., description="Rel
|
||||
raise HTTPException(status_code=500, detail=f"Error saving file: {str(e)}")
|
||||
|
||||
|
||||
@app.delete("/api/file/{vault_name}")
|
||||
@app.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
|
||||
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file")):
|
||||
"""Delete a file."""
|
||||
"""Delete a file from the vault.
|
||||
|
||||
The path is validated against traversal attacks before deletion.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileDeleteResponse`` confirming the deletion.
|
||||
"""
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = vault_root / path
|
||||
|
||||
try:
|
||||
file_path.resolve().relative_to(vault_root.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="Access denied: path outside vault")
|
||||
file_path = _resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
@@ -260,15 +484,27 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
|
||||
raise HTTPException(status_code=500, detail=f"Error deleting file: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}")
|
||||
@app.get("/api/file/{vault_name}", response_model=FileContentResponse)
|
||||
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file")):
|
||||
"""Return rendered HTML + metadata for a file."""
|
||||
"""Return rendered HTML and metadata for a file.
|
||||
|
||||
Markdown files are parsed for frontmatter, rendered with wikilink
|
||||
support, and returned with extracted tags. Other supported file
|
||||
types are syntax-highlighted as code blocks.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileContentResponse`` with HTML, metadata, and tags.
|
||||
"""
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = vault_root / path
|
||||
file_path = _resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
@@ -279,14 +515,8 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
if ext == ".md":
|
||||
post = parse_markdown_file(raw)
|
||||
|
||||
# Extract metadata
|
||||
tags = post.metadata.get("tags", [])
|
||||
if isinstance(tags, str):
|
||||
tags = [t.strip().lstrip("#") for t in tags.split(",") if t.strip()]
|
||||
elif isinstance(tags, list):
|
||||
tags = [str(t).strip().lstrip("#") for t in tags]
|
||||
else:
|
||||
tags = []
|
||||
# Extract metadata using shared indexer logic
|
||||
tags = _extract_tags(post)
|
||||
|
||||
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
|
||||
html_content = _render_markdown(post.content, vault_name)
|
||||
@@ -321,27 +551,50 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/search")
|
||||
@app.get("/api/search", response_model=SearchResponse)
|
||||
async def api_search(
|
||||
q: str = Query("", description="Search query"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
tag: Optional[str] = Query(None, description="Tag filter"),
|
||||
):
|
||||
"""Full-text search across vaults."""
|
||||
"""Full-text search across vaults with relevance scoring.
|
||||
|
||||
Supports combining free-text queries with tag filters.
|
||||
Results are ranked by a multi-factor scoring algorithm.
|
||||
|
||||
Args:
|
||||
q: Free-text search string.
|
||||
vault: Vault name or ``"all"`` to search everywhere.
|
||||
tag: Comma-separated tag names to require.
|
||||
|
||||
Returns:
|
||||
``SearchResponse`` with ranked results and snippets.
|
||||
"""
|
||||
results = search(q, vault_filter=vault, tag_filter=tag)
|
||||
return {"query": q, "vault_filter": vault, "tag_filter": tag, "count": len(results), "results": results}
|
||||
|
||||
|
||||
@app.get("/api/tags")
|
||||
@app.get("/api/tags", response_model=TagsResponse)
|
||||
async def api_tags(vault: Optional[str] = Query(None, description="Vault filter")):
|
||||
"""Return all unique tags with counts."""
|
||||
"""Return all unique tags with occurrence counts.
|
||||
|
||||
Args:
|
||||
vault: Optional vault name to restrict tag aggregation.
|
||||
|
||||
Returns:
|
||||
``TagsResponse`` with tags sorted by descending count.
|
||||
"""
|
||||
tags = get_all_tags(vault_filter=vault)
|
||||
return {"vault_filter": vault, "tags": tags}
|
||||
|
||||
|
||||
@app.get("/api/index/reload")
|
||||
@app.get("/api/index/reload", response_model=ReloadResponse)
|
||||
async def api_reload():
|
||||
"""Force a re-index of all vaults."""
|
||||
"""Force a full re-index of all configured vaults.
|
||||
|
||||
Returns:
|
||||
``ReloadResponse`` with per-vault file and tag counts.
|
||||
"""
|
||||
stats = await reload_index()
|
||||
return {"status": "ok", "vaults": stats}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user