#!/usr/bin/env python3 """Service FastAPI osint_db: dueño único de la base DuckDB data/osint.duckdb. La base es la fuente de verdad estructurada del ecosistema OSINT: índice del vault de Obsidian (notes + entidades con note_path), maestras DAV importadas de Xandikos (contacts, events) y derivadas computadas (schema derived, sin referencias a notas). Solo este proceso escribe la base; las lecturas de /api/query usan una conexión read_only separada (duckdb_query_readonly). Registry-first: el service NO reimplementa parseo de Markdown, protocolo DAV, acceso a pass, ejecución read-only de DuckDB, render de tablas Markdown ni bloques sentinel — importa esas funciones del registry (server/registry_bridge.py) y solo aporta la lógica propia del dominio (mapeo vault→tablas, matching contacto→ficha, derivadas y la API). Seguridad: el vault contiene datos personales sensibles, así que el server escucha SOLO en 127.0.0.1 y /api/query es estrictamente de solo lectura. Uso: .venv/bin/python server/main.py --vault ~/Obsidian/osint --port 8771 Contrato (el plugin de Obsidian parsea el body, no el código HTTP: los endpoints de datos responden SIEMPRE 200 con status ok|error en el body): GET /api/health estado + db_path + número de tablas GET /api/tables inventario de tablas (schema, kind, filas, columnas) POST /api/query SELECT arbitrario read-only {sql, params, max_rows} GET /api/queries catálogo de queries con nombre POST /api/query/named ejecuta una query del catálogo {name, max_rows} POST /api/ingest/vault re-escanea el vault y reconstruye maestras+derivadas POST /api/ingest/dav baja Xandikos y reconstruye contacts/events+derivadas POST /api/render/note ejecuta query y la upserta como bloque sentinel en una nota """ from __future__ import annotations import argparse import os import sys # Permite ejecutar tanto `python server/main.py` como importar `server.main`. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from fastapi import FastAPI # noqa: E402 from pydantic import BaseModel, Field # noqa: E402 from server.config import SENTINEL_MARKER, Config # noqa: E402 from server.db import apply_migrations, list_tables # noqa: E402 from server.ingest import ingest_dav, ingest_vault # noqa: E402 from server.named_queries import NAMED_QUERIES # noqa: E402 from server.registry_bridge import ( # noqa: E402 create_obsidian_note, duckdb_query_readonly, read_obsidian_note, render_markdown_table, update_obsidian_note, upsert_sentinel_block, ) # Tope de filas que un render vuelca en una nota (las notas no son un export). RENDER_MAX_ROWS = 200 class QueryBody(BaseModel): """Body de POST /api/query.""" sql: str params: list = Field(default_factory=list) max_rows: int = 500 class NamedQueryBody(BaseModel): """Body de POST /api/query/named.""" name: str max_rows: int = 500 class RenderNoteBody(BaseModel): """Body de POST /api/render/note. Exactamente uno de sql|query.""" note_path: str block_id: str sql: str | None = None query: str | None = None title: str | None = None def create_app(cfg: Config) -> FastAPI: """Construye la app FastAPI con la configuración dada (inyectable en tests).""" app = FastAPI(title="osint_db", docs_url=None, redoc_url=None) def run_readonly(sql: str, params: list, max_rows: int) -> dict: """Ejecuta un SELECT con la conexión read_only del registry, acotado.""" max_rows = max(1, min(int(max_rows), 10000)) return duckdb_query_readonly(cfg.db_path, sql, params or [], max_rows) @app.get("/api/health") def health() -> dict: try: tables = list_tables(cfg.db_path) except Exception as e: # noqa: BLE001 return {"status": "error", "db_path": cfg.db_path, "error": str(e)} return {"status": "ok", "db_path": cfg.db_path, "tables": len(tables)} @app.get("/api/tables") def tables() -> dict: try: return {"status": "ok", "tables": list_tables(cfg.db_path)} except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} @app.post("/api/query") def query(body: QueryBody) -> dict: return run_readonly(body.sql, body.params, body.max_rows) @app.get("/api/queries") def queries() -> dict: return { "status": "ok", "queries": [ {"name": name, "description": q["description"], "sql": q["sql"]} for name, q in NAMED_QUERIES.items() ], } @app.post("/api/query/named") def query_named(body: NamedQueryBody) -> dict: entry = NAMED_QUERIES.get(body.name) if entry is None: return { "status": "error", "error": f"query con nombre desconocida: {body.name!r} " f"(disponibles: {', '.join(sorted(NAMED_QUERIES))})", } return run_readonly(entry["sql"], [], body.max_rows) @app.post("/api/ingest/vault") def api_ingest_vault() -> dict: try: return ingest_vault(cfg) except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} @app.post("/api/ingest/dav") def api_ingest_dav() -> dict: try: return ingest_dav(cfg) except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} @app.post("/api/render/note") def render_note(body: RenderNoteBody) -> dict: # Resolver el SQL: o viene literal, o por nombre del catálogo. if bool(body.sql) == bool(body.query): return { "status": "error", "error": "indica exactamente uno de los campos 'sql' o 'query' (named)", } if body.query: entry = NAMED_QUERIES.get(body.query) if entry is None: return { "status": "error", "error": f"query con nombre desconocida: {body.query!r}", } sql = entry["sql"] else: sql = body.sql result = run_readonly(sql, [], RENDER_MAX_ROWS) if result.get("status") != "ok": return result table_md = render_markdown_table( result["rows"], columns=result["columns"], max_rows=RENDER_MAX_ROWS ) content = table_md if table_md else "_(sin filas)_" if body.title: content = f"### {body.title}\n\n{content}" # La nota se referencia por path relativo al vault; el path no puede # escapar del vault (mismo criterio anti-traversal que osint_web). rel = body.note_path if body.note_path.endswith(".md") else body.note_path + ".md" abs_path = os.path.abspath(os.path.join(cfg.vault_dir, rel)) vault_real = os.path.realpath(cfg.vault_dir) if not os.path.realpath(abs_path).startswith(vault_real + os.sep): return {"status": "error", "error": f"note_path fuera del vault: {body.note_path!r}"} try: if os.path.exists(abs_path): note = read_obsidian_note(abs_path) new_body = upsert_sentinel_block( note.get("body", "") or "", body.block_id, content, marker=SENTINEL_MARKER, ) update_obsidian_note(abs_path, body=new_body) else: new_body = upsert_sentinel_block( "", body.block_id, content, marker=SENTINEL_MARKER ) create_obsidian_note( cfg.vault_dir, rel, body=new_body, frontmatter={"tipo": "tablero", "tags": ["osintdb"]}, ) except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} return { "status": "ok", "note_path": rel, "rows_rendered": result["row_count"], } return app def main() -> int: """Punto de entrada CLI: valida el vault, migra la base y arranca uvicorn.""" parser = argparse.ArgumentParser( description="Service osint_db: DuckDB fuente de verdad del ecosistema OSINT." ) parser.add_argument("--vault", default=None, help="directorio del vault Obsidian") parser.add_argument("--db", default=None, help="ruta del archivo osint.duckdb") parser.add_argument("--port", type=int, default=None, help="puerto de escucha") parser.add_argument( "--host", default="127.0.0.1", help="host de escucha (datos sensibles: déjalo en 127.0.0.1)", ) args = parser.parse_args() cfg = Config() if args.vault: cfg.vault_dir = os.path.expanduser(args.vault) if args.db: cfg.db_path = os.path.expanduser(args.db) if args.port is not None: cfg.port = args.port cfg.host = args.host if not os.path.isdir(cfg.vault_dir): print( f"ERROR: el vault no existe o no es un directorio: {cfg.vault_dir}", file=sys.stderr, ) return 2 if cfg.host != "127.0.0.1": print( "AVISO: la base contiene datos personales sensibles; escuchar fuera " "de 127.0.0.1 no está soportado.", file=sys.stderr, ) applied = apply_migrations(cfg.db_path) if applied: print(f"migraciones aplicadas: {', '.join(applied)}") import uvicorn app = create_app(cfg) uvicorn.run(app, host=cfg.host, port=cfg.port, log_level="warning") return 0 if __name__ == "__main__": sys.exit(main())