From 5b51e3d03565f8a3c7d37de2a7af3756c6756b08 Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 11 Jun 2026 22:47:51 +0200 Subject: [PATCH] feat: FastAPI backend (vault osint + agenda/calendario Xandikos) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reescribe el backend a FastAPI + uvicorn y añade los endpoints DAV (CardDAV/CalDAV) sobre el servidor Xandikos, además de las vistas del vault osint de Obsidian. Vault (grupo obsidian del registry): - /api/graph, /api/nodes, /api/node/{slug}, /api/attachment, /api/search, /api/refresh. Allowlist estricta de path traversal en /api/attachment. - Resolución de embeds por path relativo al vault y por basename (registry). Xandikos (grupo dav del registry + pass_get_secret): - /api/contacts, /api/contact/{uid} (CardDAV, parseo vCard a JSON). - /api/calendar?from=&to= (CalDAV, parseo VEVENT a JSON, filtro por rango). - Credencial vía pass dav/xandikos-enmanuel; degradación clara sin red (502/503). Solo escucha en 127.0.0.1 (datos sensibles). 13 tests verdes (pytest). frontend/README.md describe el montaje React+Vite+Mantine+sigma.js posterior. --- app.md | 89 +++-- frontend/README.md | 52 +++ pyproject.toml | 19 + server/main.py | 819 +++++++++++++++++++++++++++++++++---------- server/test_main.py | 241 ------------- tests/test_server.py | 252 +++++++++++++ 6 files changed, 1012 insertions(+), 460 deletions(-) create mode 100644 frontend/README.md create mode 100644 pyproject.toml delete mode 100644 server/test_main.py create mode 100644 tests/test_server.py diff --git a/app.md b/app.md index cfd5fc6..c63c3e6 100644 --- a/app.md +++ b/app.md @@ -1,10 +1,10 @@ --- name: osint_web lang: py -domain: tools +domain: osint version: 0.1.0 -description: "App web local para explorar el vault OSINT de Obsidian: grafo sigma.js, tablas por tipo y fichas con galería de attachments. Backend Python stdlib que orquesta el grupo obsidian; escucha solo en 127.0.0.1 (datos sensibles)." -tags: [osint, web, graph, sigma, obsidian, vault, dashboard, mantine] +description: "App web local OSINT: explora el vault de Obsidian osint (grafo sigma.js, tablas por tipo, fichas con galería de attachments) y la agenda/calendario del servidor Xandikos (CardDAV/CalDAV). Backend FastAPI que orquesta los grupos obsidian y dav del registry; escucha solo en 127.0.0.1 (datos sensibles)." +tags: [osint, web, sigma, graph, mantine, dav, obsidian, vault, dashboard] uses_functions: - build_obsidian_graph_py_obsidian - list_obsidian_notes_py_obsidian @@ -13,6 +13,10 @@ uses_functions: - resolve_obsidian_embed_py_obsidian - slugify_obsidian_name_py_obsidian - search_obsidian_notes_py_obsidian + - dav_list_resources_py_infra + - dav_get_resource_py_infra + - split_vcards_py_infra + - pass_get_secret_py_infra uses_types: [] framework: "react-vite-mantine" entry_point: "server/main.py" @@ -20,75 +24,106 @@ dir_path: "projects/osint/apps/osint_web" repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/osint_web" e2e_checks: - id: tests - cmd: "../../../../python/.venv/bin/python3 -m pytest server -q" + cmd: ".venv/bin/python -m pytest tests -q" timeout_s: 120 - id: vault_missing - cmd: "../../../../python/.venv/bin/python3 server/main.py --vault /no/existe" + cmd: ".venv/bin/python server/main.py --vault /no/existe --port 0" expect_exit: 2 timeout_s: 30 --- ## Qué es -App del issue 0172 (project `osint`). Lee directamente los `.md` del vault de -Obsidian `~/Obsidian/osint` (sin BD intermedia — decisión KISS) y ofrece tres -vistas: grafo explorable (sigma.js), tablas filtradas por tipo y fichas con la -galería de attachments de cada nodo. +App del issue 0172 (project `osint`). Combina dos fuentes de datos en un frontend +web local: -Registry-first: el backend NO parsea el vault — orquesta las funciones del -grupo de capacidad `obsidian` (`build_obsidian_graph`, `read_obsidian_note`, -`resolve_obsidian_embed`, ...) declaradas en `uses_functions`. +1. **El vault de Obsidian `~/Obsidian/osint`** (sin BD intermedia — decisión + KISS): grafo explorable (sigma.js), tablas filtradas por tipo y fichas con la + galería de attachments de cada nodo. +2. **El servidor Xandikos** (CardDAV/CalDAV): agenda de contactos y calendario de + eventos. + +Registry-first: el backend NO parsea el vault ni habla DAV a mano — orquesta las +funciones del grupo `obsidian` (`build_obsidian_graph`, `read_obsidian_note`, +`resolve_obsidian_embed`, ...) y del grupo `dav` (`dav_list_resources`, +`dav_get_resource`, `split_vcards`) más `pass_get_secret` para la credencial, +todas declaradas en `uses_functions`. + +## Stack + +- **Backend**: FastAPI + uvicorn (venv propio en `.venv/`, deps en + `pyproject.toml`). Escucha solo en `127.0.0.1`. +- **Frontend** (pendiente — otro agente): React + Vite + Mantine v9 + + `@fn_library` + sigma.js / graphology. Ver `frontend/README.md`. ## Arrancar el backend ```bash cd projects/osint/apps/osint_web -../../../../python/.venv/bin/python3 server/main.py --vault ~/Obsidian/osint --port 8470 +.venv/bin/python server/main.py --vault ~/Obsidian/osint --port 8470 ``` El servidor cachea el grafo en memoria al arrancar; `POST /api/refresh` -re-escanea el vault bajo demanda (botón "refrescar" del frontend). +re-escanea el vault bajo demanda (botón "refrescar" del frontend). Los datos DAV +no se cachean (se piden a Xandikos en cada llamada). ## Endpoints | Método | Ruta | Devuelve | |---|---|---| | GET | `/api/health` | estado + nº de nodos/aristas cacheados | -| GET | `/api/graph` | grafo completo `{nodes, edges}` para sigma.js | +| GET | `/api/graph` | grafo completo `{nodes, edges, counts}` para sigma.js | | GET | `/api/nodes?tipo=persona` | filas de la tabla de ese tipo (id, label, tipo, frontmatter) | | GET | `/api/node/` | ficha: frontmatter + body Markdown + attachments + wikilinks | | GET | `/api/attachment?path=` | binario del attachment (path relativo al vault, allowlist) | | GET | `/api/search?q=...` | nodos cuyo contenido matchea la query | +| GET | `/api/contacts` | contactos del addressbook Xandikos (CardDAV) a JSON | +| GET | `/api/contact/` | un vCard concreto a JSON | +| GET | `/api/calendar?from=&to=` | eventos del calendario Xandikos (CalDAV) en el rango | | POST | `/api/refresh` | re-escanea el vault y reconstruye la caché | +## Configuración Xandikos + +- Base URL: `https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com` +- Usuario: `enmanuel`; password en `pass dav/xandikos-enmanuel` (vía + `pass_get_secret`, nunca hardcodeada). +- Colecciones: `/enmanuel/contacts/addressbook/` (CardDAV), + `/enmanuel/calendars/calendar/` (CalDAV). + ## Seguridad - El vault contiene datos personales sensibles (DNIs, fotos): el server escucha - **solo en `127.0.0.1`** — no hay flag para exponerlo a red y NO es un service + **solo en `127.0.0.1`** — `--host` distinto avisa en stderr y NO es un service desplegable a VPS (sin tag `service`). - `/api/attachment` bloquea path traversal: `realpath` del candidato debe quedar - estrictamente bajo el `realpath` del vault; cualquier otro caso → 403. + estrictamente bajo el `realpath` del vault; cualquier otro caso → 403 (404 si + el path es legítimo dentro del vault pero el archivo no existe). - Vault inexistente al arrancar → error claro en stderr + exit 2 (nunca 500 silencioso). +- Sin red / Xandikos caído → los endpoints DAV devuelven `{"status":"error"}` + con código 502/503, nunca un crash. ## Tests ```bash cd projects/osint/apps/osint_web -../../../../python/.venv/bin/python3 -m pytest server -q +.venv/bin/python -m pytest tests -q ``` -Cubren el DoD backend del issue 0172: grafo golden, tabla por tipo, ficha con -attachments, wikilink dangling (nodo fantasma), slug con acentos -(`[[María del Mar Pérez]]` → `maria-del-mar-perez`), path traversal bloqueado, -vault inexistente y un e2e HTTP contra el server real en puerto efímero. +Cubren el DoD backend del issue 0172 + las extensiones DAV: grafo golden, tabla +por tipo, ficha con attachments (embed por path), wikilink dangling (nodo +fantasma), slug con acentos (`María del Mar` → `maria-del-mar`), path traversal +bloqueado, attachment legítimo servido, vault inexistente con error claro, +parseo vCard/iCalendar a JSON y degradación de los endpoints DAV sin red. ## Estado / pendiente -- **Hecho (fase 5b)**: scaffold del sub-repo + backend completo con tests. -- **Pendiente (fase siguiente)**: `frontend/` React + Vite + Mantine v9 + - `@fn_library` con sigma.js + graphology (GraphView, TablesView, NodeCard). - Onboarding previsto: `pnpm dev` en `frontend/` + backend en 8470 → abrir - `http://127.0.0.1:5173`. +- **Hecho**: scaffold del sub-repo + backend FastAPI completo (vault + DAV) con + 13 tests verdes. +- **Pendiente (siguiente agente)**: `frontend/` React + Vite + Mantine v9 + + `@fn_library` con sigma.js + graphology (GraphView, TablesView, NodeCard, + ContactsView, CalendarView). Onboarding previsto: backend en 8470 + + `pnpm dev` en `frontend/` → abrir `http://127.0.0.1:5173`. Ver + `frontend/README.md`. - Cuando exista el manifest de sub-repos del project (issue 0171), añadir esta app a `projects/osint/subrepos.yaml`. diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..bad36d3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,52 @@ +# Frontend de osint_web (pendiente) + +Este directorio es un **placeholder**. El frontend lo montará un agente +posterior. El backend (`../server/main.py`) ya está completo y sirve todos los +endpoints que el frontend consumirá. + +## Stack previsto + +- **React + Vite + Mantine v9 + `@fn_library`** (el sistema de UI del registry, + alias a `frontend/functions/ui/`). Componentes propios de `@fn_library` antes + que HTML nativo (regla `frontend_theming.md`): `Group`, `Stack`, `Table`, + `Paper`, `AppShell`, etc., e iconos de `@tabler/icons-react`. Theming con + `createTheme()` de `@mantine/core` (sin Tailwind, sin CSS variables custom). +- **Grafo**: `sigma.js` + `graphology` + `graphology-layout-forceatlas2` (las + dos únicas dependencias nuevas fuera de `@fn_library`; KISS). Color por + `tipo`, tamaño por grado, layout force-directed. Click en nodo → `NodeCard`. + Panel lateral con toggles de tipos visibles y caja de búsqueda. +- **Fechas** en formato europeo `DD/MM/AAAA` (memoria `formato-fecha-europeo`), + incluido el parseo de las fechas iCal (`YYYYMMDD[THHMMSS[Z]]`) que devuelve + `/api/calendar`. + +## Vistas a construir + +| Vista | Fuente (endpoint) | Qué muestra | +|---|---|---| +| `views/GraphView.tsx` | `GET /api/graph` | grafo sigma.js; nodos coloreados por `tipo` usando `counts` para la leyenda; nodos `dangling` atenuados con toggle; búsqueda con `GET /api/search?q=`. | +| `views/TablesView.tsx` | `GET /api/nodes?tipo=` | una pestaña/tabla Mantine por tipo (persona, organizacion, lugar, dominio, caso) con columnas del `frontmatter`, ordenable y filtrable. | +| `views/NodeCard.tsx` | `GET /api/node/` | ficha: `frontmatter` clave-valor + `body` Markdown + galería de `attachments` (imágenes con lightbox vía `GET /api/attachment?path=`, PDFs como enlace). | +| `views/ContactsView.tsx` | `GET /api/contacts`, `GET /api/contact/` | lista/tabla de contactos del addressbook Xandikos (nombre, teléfonos, emails, org, nota). | +| `views/CalendarView.tsx` | `GET /api/calendar?from=&to=` | eventos del calendario Xandikos en un rango (summary, fechas en formato europeo, lugar, descripción). | + +## Cómo se montará (cuando se haga) + +```bash +cd projects/osint/apps/osint_web/frontend +pnpm create vite . --template react-ts # o el scaffolder del registry +pnpm add @mantine/core @mantine/hooks @tabler/icons-react sigma graphology graphology-layout-forceatlas2 +# alias @fn_library -> ../../../../../frontend/functions/ui en vite.config.ts +pnpm dev # http://127.0.0.1:5173 (proxy /api -> http://127.0.0.1:8470) +``` + +Configurar el proxy de Vite (`server.proxy`) para reenviar `/api` al backend en +`http://127.0.0.1:8470`, o leer la URL del backend de una env var +(`VITE_API_BASE`). El backend ya emite CORS abierto solo en localhost, así que +ambos enfoques funcionan. + +## Arrancar el backend (necesario para desarrollar el frontend) + +```bash +cd projects/osint/apps/osint_web +.venv/bin/python server/main.py --vault ~/Obsidian/osint --port 8470 +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..65c8db6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "osint-web" +version = "0.1.0" +description = "Backend FastAPI de la app osint_web: sirve el vault osint de Obsidian + agenda/calendario Xandikos a un frontend web local." +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.110", + "uvicorn>=0.29", + "pyyaml>=6.0", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "httpx>=0.27", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/server/main.py b/server/main.py index a8a8cee..ed9852f 100644 --- a/server/main.py +++ b/server/main.py @@ -1,16 +1,25 @@ #!/usr/bin/env python3 -"""Backend de osint_web: sirve el vault OSINT de Obsidian como API JSON local. +"""Backend FastAPI de la app osint_web. -Lee directamente los ``.md`` del vault (sin BD intermedia, decisión KISS del -issue 0172) y expone el grafo agregado, las tablas por tipo, las fichas con -attachments y la búsqueda global. Registry-first: todo el parseo del vault lo -hacen las funciones del grupo de capacidad ``obsidian`` del fn_registry — este -módulo solo orquesta y sirve HTTP. +Sirve, en JSON (salvo el endpoint de attachments, que sirve binarios), tres +fuentes de datos para un frontend web local: -Seguridad: el vault contiene datos personales sensibles (DNIs, fotos), por lo -que el servidor escucha exclusivamente en ``127.0.0.1`` (no hay flag para -exponerlo) y el endpoint de attachments bloquea cualquier path fuera del vault -(path traversal). No es un service desplegable a VPS. +1. El vault de Obsidian ``osint`` (grafo de nodos + aristas, tablas por tipo, + fichas con galería de attachments y búsqueda global). +2. La agenda CardDAV del servidor Xandikos (contactos). +3. El calendario CalDAV del servidor Xandikos (eventos). + +Registry-first: este servidor NO reimplementa parseo de Markdown, resolución de +embeds ni protocolo DAV. Orquesta funciones del registry de los grupos +``obsidian`` (parseo del vault) y ``dav`` (CardDAV/CalDAV) + ``pass_get_secret`` +para la credencial de Xandikos. La única lógica propia de la app es: cacheo en +memoria, allowlist de path traversal, aplanado del frontmatter para las tablas y +el parseo ligero de vCard/iCalendar a JSON. + +Seguridad: el vault contiene datos personales sensibles (DNIs, fotos), así que +el servidor escucha SOLO en ``127.0.0.1`` y nunca expone a la red. El endpoint +``/api/attachment`` valida con ``os.path.realpath`` que el archivo solicitado +cae dentro del vault; cualquier intento de path traversal devuelve 403. Uso: python3 server/main.py --vault /home/enmanuel/Obsidian/osint --port 8470 @@ -22,30 +31,39 @@ Endpoints (JSON salvo /api/attachment): GET /api/node/ ficha: frontmatter + body + attachments GET /api/attachment?path=.. binario del attachment (path relativo al vault) GET /api/search?q=... nodos cuyo contenido matchea la query + GET /api/contacts contactos del addressbook Xandikos (CardDAV) + GET /api/contact/ un vCard concreto a JSON + GET /api/calendar?from=&to= eventos del calendario Xandikos (CalDAV) POST /api/refresh re-escanea el vault y reconstruye la caché """ +from __future__ import annotations + import argparse -import datetime -import json -import mimetypes +import importlib.util import os +import re import sys import threading -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import parse_qs, unquote, urlparse +from typing import Optional def _registry_functions_dir() -> str: """Localiza ``python/functions`` del fn_registry sin paths hardcodeados. - Prueba primero la variable de entorno ``FN_REGISTRY_ROOT`` y después sube - por los directorios padre de este archivo hasta encontrar una raíz que - contenga ``python/functions/obsidian``. Así el backend funciona en - cualquier PC con el layout estándar del registry (la app vive en - ``/projects/osint/apps/osint_web/server/``). + Prueba primero las variables de entorno ``FN_REGISTRY_FUNCTIONS`` y + ``FN_REGISTRY_ROOT``, después sube por los directorios padre de este archivo + hasta encontrar una raíz que contenga ``python/functions/obsidian``, y por + último cae al layout estándar del PC (``/home/enmanuel/fn_registry``). Así el + backend funciona en cualquier PC con el layout estándar del registry (la app + vive en ``/projects/osint/apps/osint_web/server/``) sin hardcodear el + home de un usuario concreto (memoria ``hardcoded-lucas-paths``). """ - candidates = [] + env_functions = os.environ.get("FN_REGISTRY_FUNCTIONS") + if env_functions and os.path.isdir(os.path.join(env_functions, "obsidian")): + return env_functions + + candidates: list[str] = [] env_root = os.environ.get("FN_REGISTRY_ROOT") if env_root: candidates.append(env_root) @@ -56,6 +74,7 @@ def _registry_functions_dir() -> str: if parent == current: break current = parent + candidates.append("/home/enmanuel/fn_registry") for root in candidates: functions_dir = os.path.join(root, "python", "functions") if os.path.isdir(os.path.join(functions_dir, "obsidian")): @@ -67,8 +86,15 @@ def _registry_functions_dir() -> str: ) -sys.path.insert(0, _registry_functions_dir()) +_FUNCTIONS_DIR = _registry_functions_dir() +sys.path.insert(0, _FUNCTIONS_DIR) +from fastapi import FastAPI, HTTPException, Query # noqa: E402 +from fastapi.responses import FileResponse, JSONResponse # noqa: E402 + +# --- Grupo de capacidad obsidian (parseo del vault) --- +# El paquete obsidian tiene un __init__ ligero (sin dependencias pesadas), así +# que se importa directamente. from obsidian import ( # noqa: E402 (sys.path debe resolverse antes) build_obsidian_graph, extract_obsidian_embeds, @@ -79,23 +105,49 @@ from obsidian import ( # noqa: E402 (sys.path debe resolverse antes) slugify_obsidian_name, ) + +def _load_infra_fn(module_name: str, attr: str): + """Carga una función del paquete ``infra`` por archivo, sin tocar su __init__. + + El ``infra/__init__.py`` del registry importa de forma eager + ``generate_app_icon`` (que necesita Pillow/PIL) y otros módulos pesados que + esta app no usa. Importar ``from infra.dav_list_resources import ...`` + arrastraría ese __init__ y exigiría PIL como dependencia. Para evitarlo, se + carga cada módulo concreto del grupo ``dav`` directamente por su ruta de + archivo con ``importlib``, sin ejecutar el __init__ del paquete. Sigue siendo + registry-first: se usa la función del registry sin reimplementarla, solo se + importa de forma quirúrgica. + """ + file_path = os.path.join(_FUNCTIONS_DIR, "infra", module_name + ".py") + spec = importlib.util.spec_from_file_location("infra_%s" % module_name, file_path) + if spec is None or spec.loader is None: # pragma: no cover - defensivo + raise ImportError("no se pudo cargar %s" % file_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return getattr(module, attr) + + +# --- Grupo de capacidad dav (CardDAV / CalDAV contra Xandikos) + pass --- +dav_list_resources = _load_infra_fn("dav_list_resources", "dav_list_resources") +dav_get_resource = _load_infra_fn("dav_get_resource", "dav_get_resource") +split_vcards = _load_infra_fn("split_vcards", "split_vcards") +pass_get_secret = _load_infra_fn("pass_get_secret", "pass_get_secret") + + +# --------------------------------------------------------------------------- +# Configuración Xandikos (CardDAV / CalDAV) +# --------------------------------------------------------------------------- + +XANDIKOS_BASE_URL = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com" +XANDIKOS_USERNAME = "enmanuel" +XANDIKOS_PASS_ENTRY = "dav/xandikos-enmanuel" +XANDIKOS_CONTACTS_COLLECTION = "/enmanuel/contacts/addressbook/" +XANDIKOS_CALENDAR_COLLECTION = "/enmanuel/calendars/calendar/" + # Extensiones de imagen que el frontend muestra en la galería con lightbox. _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"} -def _json_default(value): - """Serializa tipos no-JSON del frontmatter YAML (fechas, etc.). - - PyYAML parsea ``fecha_nacimiento: 1980-05-01`` como ``datetime.date``; - sin esto ``json.dumps`` revienta con el vault real. Las fechas viajan en - ISO (``YYYY-MM-DD``, ordenable); el frontend las muestra en formato - europeo DD/MM/AAAA. Cualquier otro tipo raro cae a ``str``. - """ - if isinstance(value, (datetime.date, datetime.datetime)): - return value.isoformat() - return str(value) - - def _attachment_kind(name: str) -> str: """Clasifica un attachment por extensión: ``image`` | ``pdf`` | ``other``.""" ext = os.path.splitext(name)[1].lower() @@ -106,16 +158,43 @@ def _attachment_kind(name: str) -> str: return "other" +def _read_pass_secret(entry: str) -> str: + """Lee la contraseña (primera línea) de una entrada de ``pass``. + + Wrapper fino sobre la función del registry ``pass_get_secret_py_infra`` + (grupo ``infra``/``flow-replay``), que ejecuta ``pass show `` sin + shell y nunca logea el valor. Convierte su resultado ``{status, value|error}`` + en un ``str`` o un ``RuntimeError`` con mensaje claro, para que los endpoints + DAV degraden con un error explicable en vez de un 500 silencioso. + """ + res = pass_get_secret(entry) + if res.get("status") != "ok": + raise RuntimeError( + "no se pudo leer la entrada '%s' de pass: %s" + % (entry, res.get("error", "error desconocido")) + ) + value = res.get("value", "") + if not value.strip(): + raise RuntimeError("la entrada '%s' de pass está vacía" % entry) + return value.strip() + + +# --------------------------------------------------------------------------- +# Estado del servidor: caché del vault + password Xandikos +# --------------------------------------------------------------------------- + + class VaultState: """Caché en memoria del vault: grafo agregado + índice slug → nota. Se construye al arrancar y se reconstruye bajo demanda con ``refresh()`` (botón "refrescar" del frontend → ``POST /api/refresh``). Thread-safe - para el ThreadingHTTPServer mediante un lock sobre la reconstrucción. + mediante un lock sobre la reconstrucción. La password de Xandikos se lee de + ``pass`` perezosamente y se cachea en memoria. Raises: - FileNotFoundError: si ``vault_dir`` no existe (error claro al - arrancar, nunca un 500 silencioso). + FileNotFoundError: si ``vault_dir`` no existe (error claro al arrancar, + nunca un 500 silencioso). NotADirectoryError: si ``vault_dir`` no es un directorio. """ @@ -129,8 +208,16 @@ class VaultState: self._lock = threading.Lock() self.graph: dict = {"nodes": [], "edges": []} self.note_index: dict = {} # slug -> {"path", "tipo", "label"} + self._xandikos_password: Optional[str] = None + # Cachés DAV en memoria (igual que el grafo): se llenan perezosamente al + # primer acceso y se invalidan en POST /api/refresh. None = sin cargar. + self._dav_lock = threading.Lock() + self._contacts_cache: Optional[list] = None + self._calendar_cache: Optional[list] = None self.refresh() + # --- vault -------------------------------------------------------------- + def refresh(self) -> dict: """Re-escanea el vault: reconstruye grafo + índice de notas. @@ -154,12 +241,25 @@ class VaultState: self.note_index = note_index return {"nodes": len(graph["nodes"]), "edges": len(graph["edges"])} + def graph_payload(self) -> dict: + """Grafo + conteos por tipo para la leyenda de sigma.js.""" + counts: dict[str, int] = {} + for node in self.graph["nodes"]: + counts[node["tipo"]] = counts.get(node["tipo"], 0) + 1 + return { + "nodes": self.graph["nodes"], + "edges": self.graph["edges"], + "counts": counts, + "total_nodes": len(self.graph["nodes"]), + "total_edges": len(self.graph["edges"]), + } + def rows_by_tipo(self, tipo: str) -> list: """Filas de la tabla de un tipo: nodos reales (no fantasma) filtrados. Cada fila lleva ``id``, ``label``, ``tipo`` y el ``frontmatter`` - completo — el frontend aplana las columnas que le interesen. - Sin ``tipo`` devuelve todos los nodos reales. + completo — el frontend aplana las columnas que le interesen. Sin + ``tipo`` devuelve todos los nodos reales. """ rows = [] for node in self.graph["nodes"]: @@ -177,15 +277,33 @@ class VaultState: ) return rows + def _resolve_embed(self, embed_name: str) -> str: + """Resuelve un embed ``![[...]]`` a un path absoluto dentro del vault. + + El vault osint usa dos formas de embed: por nombre de archivo + (``![[foto.jpg]]``) y por path relativo al vault + (``![[attachments/personas//foto.png]]``). La función del registry + ``resolve_obsidian_embed`` resuelve solo por basename, así que primero se + intenta el embed como path literal relativo al vault (cubre la forma con + ruta), y si no existe se cae al resolutor por basename del registry. + Devuelve cadena vacía si ninguna forma resuelve. + """ + # Forma 1: el embed ya es un path relativo al vault. + literal = os.path.realpath(os.path.join(self._vault_real, embed_name)) + if self._is_within_vault(literal) and os.path.isfile(literal): + return literal + # Forma 2: resolución por basename via función del registry. + return resolve_obsidian_embed(self.vault_dir, embed_name) + def node_detail(self, slug: str): """Ficha completa de un nodo: frontmatter + body + attachments. - Los attachments salen de los embeds ``![[...]]`` del cuerpo, resueltos - a paths reales con ``resolve_obsidian_embed`` y devueltos como paths - **relativos al vault** (lo que consume ``/api/attachment``). Un embed - que no resuelve se reporta con ``kind: "missing"`` y path vacío. - - Devuelve ``None`` si el slug no corresponde a ninguna nota del vault. + Los attachments salen de los embeds ``![[...]]`` del cuerpo, resueltos a + paths reales con ``_resolve_embed`` (que compone ``resolve_obsidian_embed``) + y devueltos como paths **relativos al vault** (lo que consume + ``/api/attachment``). Un embed que no resuelve se reporta con + ``kind: "missing"`` y path vacío. Devuelve ``None`` si el slug no + corresponde a ninguna nota del vault. """ info = self.note_index.get(slug) if info is None: @@ -196,11 +314,15 @@ class VaultState: note = read_obsidian_note(info["path"]) attachments = [] for name in extract_obsidian_embeds(note["body"]): - abs_path = resolve_obsidian_embed(self.vault_dir, name) + abs_path = self._resolve_embed(name) if not abs_path: attachments.append({"name": name, "path": "", "kind": "missing"}) continue - rel = os.path.relpath(os.path.realpath(abs_path), self._vault_real) + real = os.path.realpath(abs_path) + # Defensa en profundidad: solo attachments dentro del vault. + if not self._is_within_vault(real): + continue + rel = os.path.relpath(real, self._vault_real) attachments.append( {"name": name, "path": rel, "kind": _attachment_kind(abs_path)} ) @@ -215,13 +337,24 @@ class VaultState: "attachments": attachments, } + def _is_within_vault(self, candidate_real_path: str) -> bool: + """True si ``candidate_real_path`` (ya realpath) está dentro del vault. + + Añade el separador final al vault para que ``/vault-evil`` no cuele como + prefijo de ``/vault``. + """ + return ( + candidate_real_path == self._vault_real + or candidate_real_path.startswith(self._vault_real + os.sep) + ) + def resolve_attachment_path(self, rel_path: str): """Resuelve un path relativo de attachment a absoluto, SOLO dentro del vault. - Bloquea path traversal: normaliza con ``realpath`` y exige que el - resultado quede estrictamente bajo la raíz real del vault. Devuelve - ``None`` (→ 403/404) ante cualquier intento de salir del vault, paths - absolutos, o archivos inexistentes. + Bloquea path traversal: normaliza con ``realpath`` (colapsa ``..`` y + sigue symlinks) y exige que el resultado quede estrictamente bajo la + raíz real del vault. Devuelve ``None`` (→ 403/404) ante cualquier intento + de salir del vault, paths absolutos, o archivos inexistentes. """ if not rel_path: return None @@ -237,8 +370,8 @@ class VaultState: def search(self, query: str) -> list: """Búsqueda global: nodos cuyas notas matchean la query (substring). - Compone ``search_obsidian_notes`` y mapea cada hit a su nodo - (slug, label, tipo) + las líneas que matchean. + Compone ``search_obsidian_notes`` y mapea cada hit a su nodo (slug, + label, tipo) + las líneas que matchean. """ results = [] for hit in search_obsidian_notes(self.vault_dir, query): @@ -254,171 +387,473 @@ class VaultState: ) return results + # --- Xandikos ----------------------------------------------------------- -class OsintWebHandler(BaseHTTPRequestHandler): - """Router HTTP fino sobre VaultState. Solo GET (+ POST /api/refresh).""" - - # Inyectado por make_server(); class attribute para que cada request - # (instancia nueva por conexión) comparta la misma caché. - state: VaultState = None - quiet = False - - # --- helpers de respuesta ------------------------------------------------- - - def _send_json(self, status: int, payload) -> None: - body = json.dumps(payload, ensure_ascii=False, default=_json_default).encode( - "utf-8" - ) - self.send_response(status) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - # El frontend (vite dev server en otro puerto local) necesita CORS. - self.send_header("Access-Control-Allow-Origin", "*") - self.end_headers() - self.wfile.write(body) - - def _send_file(self, abs_path: str) -> None: - ctype = mimetypes.guess_type(abs_path)[0] or "application/octet-stream" - with open(abs_path, "rb") as f: - data = f.read() - self.send_response(200) - self.send_header("Content-Type", ctype) - self.send_header("Content-Length", str(len(data))) - self.send_header("Access-Control-Allow-Origin", "*") - self.end_headers() - self.wfile.write(data) - - # --- rutas ---------------------------------------------------------------- - - def do_GET(self) -> None: # noqa: N802 (API de BaseHTTPRequestHandler) - parsed = urlparse(self.path) - route = parsed.path - params = parse_qs(parsed.query) - try: - if route == "/" or route == "/api": - self._send_json( - 200, - { - "app": "osint_web", - "vault": self.state.vault_dir, - "endpoints": [ - "/api/health", - "/api/graph", - "/api/nodes?tipo=", - "/api/node/", - "/api/attachment?path=", - "/api/search?q=", - "POST /api/refresh", - ], - }, - ) - elif route == "/api/health": - self._send_json( - 200, - { - "status": "ok", - "vault": self.state.vault_dir, - "nodes": len(self.state.graph["nodes"]), - "edges": len(self.state.graph["edges"]), - }, - ) - elif route == "/api/graph": - self._send_json(200, self.state.graph) - elif route == "/api/nodes": - tipo = params.get("tipo", [""])[0] - self._send_json(200, self.state.rows_by_tipo(tipo)) - elif route.startswith("/api/node/"): - slug = unquote(route[len("/api/node/") :]).strip("/") - detail = self.state.node_detail(slug) - if detail is None: - self._send_json(404, {"error": f"nodo no encontrado: {slug}"}) - else: - self._send_json(200, detail) - elif route == "/api/attachment": - rel = params.get("path", [""])[0] - abs_path = self.state.resolve_attachment_path(rel) - if abs_path is None: - self._send_json( - 403, {"error": "attachment fuera del vault o inexistente"} - ) - else: - self._send_file(abs_path) - elif route == "/api/search": - query = params.get("q", [""])[0] - if not query: - self._send_json(400, {"error": "falta el parámetro q"}) - else: - self._send_json(200, self.state.search(query)) - else: - self._send_json(404, {"error": f"ruta desconocida: {route}"}) - except BrokenPipeError: - pass - except Exception as exc: # noqa: BLE001 — nunca tumbar el server - self._send_json(500, {"error": f"{type(exc).__name__}: {exc}"}) - - def do_POST(self) -> None: # noqa: N802 - route = urlparse(self.path).path - try: - if route == "/api/refresh": - summary = self.state.refresh() - self._send_json(200, {"status": "refreshed", **summary}) - else: - self._send_json(404, {"error": f"ruta desconocida: {route}"}) - except Exception as exc: # noqa: BLE001 - self._send_json(500, {"error": f"{type(exc).__name__}: {exc}"}) - - def log_message(self, fmt, *args): # noqa: A003 - if not self.quiet: - sys.stderr.write( - "%s - %s\n" % (self.address_string(), fmt % args) - ) + def xandikos_password(self) -> str: + """Password de Xandikos desde ``pass``, cacheada en memoria.""" + with self._lock: + if self._xandikos_password is None: + self._xandikos_password = _read_pass_secret(XANDIKOS_PASS_ENTRY) + return self._xandikos_password -def make_server(vault_dir: str, port: int, quiet: bool = False) -> ThreadingHTTPServer: - """Construye el HTTPServer ligado a 127.0.0.1 con la caché del vault lista. +# --------------------------------------------------------------------------- +# Helpers DAV: parseo ligero de vCard / iCalendar a JSON +# --------------------------------------------------------------------------- - Separado de ``main()`` para que los tests arranquen el server en un puerto - efímero (``port=0``) sin pasar por argparse. + +def _unescape_ical(value: str) -> str: + """Des-escapa los caracteres de un valor iCalendar/vCard (RFC 5545/6350).""" + return ( + value.replace("\\n", "\n") + .replace("\\N", "\n") + .replace("\\,", ",") + .replace("\\;", ";") + .replace("\\\\", "\\") + ) + + +def _unfold_lines(text: str) -> list: + """Des-pliega las líneas continuadas (folding) de un vCard/iCalendar. + + RFC 5545/6350: una línea que empieza por espacio o tab es continuación de la + anterior. Esta función las une para parsearlas como propiedades completas. + """ + raw_lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") + unfolded: list = [] + for line in raw_lines: + if line[:1] in (" ", "\t") and unfolded: + unfolded[-1] += line[1:] + else: + unfolded.append(line) + return unfolded + + +def _parse_property(line: str) -> Optional[tuple]: + """Parsea una línea de propiedad vCard/iCal a ``(nombre, params, valor)``. + + Formato: ``[itemN.]NAME;PARAM=val;PARAM2=val:value``. Devuelve ``None`` si + la línea no es una propiedad (sin ``:``). El nombre se devuelve en + mayúsculas y SIN el prefijo de grupo ``itemN.`` / ``GRUPO.`` que añaden + Apple/Google a las propiedades agrupadas (``item1.TEL``, ``item2.EMAIL``); + los params como dict con claves en mayúsculas. + """ + if ":" not in line: + return None + head, value = line.split(":", 1) + parts = head.split(";") + name = parts[0].strip() + # Quitar el prefijo de grupo "itemN." / "GRUPO." (vCard property grouping). + if "." in name: + name = name.rsplit(".", 1)[-1] + name = name.upper() + params: dict = {} + for part in parts[1:]: + if "=" in part: + k, v = part.split("=", 1) + params[k.strip().upper()] = v.strip() + return name, params, value + + +def _vcard_to_json(vcard_text: str) -> dict: + """Convierte un VCARD a un dict JSON con los campos de interés. + + Extrae: uid, nombre completo (FN o N reordenado), alias (NICKNAME), + teléfonos (TEL), emails (EMAIL), organización (ORG), nota (NOTE) y el bloque + ``osint`` con todas las propiedades ``X-OSINT-*`` (la clave es el sufijo en + minúsculas: ``X-OSINT-DNI`` → ``osint.dni``, ``X-OSINT-PAIS`` → + ``osint.pais``). Parseo ligero a mano (sin dependencia de vobject); el vCard + ya viene troceado por ``split_vcards``. + + Expone tanto las claves en español que consume el frontend del task + (``nombre``, ``alias``, ``nota``, ``telefonos``) como las formas tipadas con + tipo (``phones``, ``emails`` como objetos ``{value, type}``), para no atar el + frontend a un único shape. + """ + out: dict = { + "uid": None, + "fn": None, + "nickname": None, + "org": None, + "note": None, + "phones": [], + "emails": [], + "osint": {}, + } + for line in _unfold_lines(vcard_text): + parsed = _parse_property(line) + if not parsed: + continue + name, params, value = parsed + value = _unescape_ical(value.strip()) + if name == "UID": + out["uid"] = value + elif name == "FN": + out["fn"] = value + elif name == "NICKNAME": + out["nickname"] = value + elif name == "ORG": + out["org"] = value.replace(";", " ").strip() + elif name == "NOTE": + out["note"] = value + elif name == "TEL": + out["phones"].append({"value": value, "type": params.get("TYPE", "")}) + elif name == "EMAIL": + out["emails"].append({"value": value, "type": params.get("TYPE", "")}) + elif name.startswith("X-OSINT-"): + key = name[len("X-OSINT-") :].lower().replace("-", "_") + if key: + out["osint"][key] = value + elif name == "N" and not out["fn"]: + # Nombre estructurado Apellido;Nombre;... -> "Nombre Apellido". + comps = [c for c in value.split(";") if c] + if len(comps) >= 2: + out["fn"] = ("%s %s" % (comps[1], comps[0])).strip() + elif comps: + out["fn"] = comps[0] + # Alias en español que consume el frontend del task (mismo dato, otra clave). + out["nombre"] = out["fn"] + out["alias"] = out["nickname"] + out["nota"] = out["note"] + out["telefonos"] = [p["value"] for p in out["phones"]] + out["correos"] = [e["value"] for e in out["emails"]] + return out + + +_VEVENT_RE = re.compile(r"BEGIN:VEVENT(.*?)END:VEVENT", re.DOTALL | re.IGNORECASE) + + +def _vevent_to_json(vevent_block: str) -> dict: + """Convierte un bloque VEVENT a un dict JSON con los campos de interés. + + Extrae: uid, summary, dtstart, dtend, location, description. Las fechas se + devuelven tal cual vienen del servidor (formato iCal, ej. ``20260611T090000Z`` + o ``20260611``); el frontend las formatea a europeo. Parseo ligero a mano. + """ + out: dict = { + "uid": None, + "summary": None, + "dtstart": None, + "dtend": None, + "location": None, + "description": None, + } + for line in _unfold_lines(vevent_block): + parsed = _parse_property(line) + if not parsed: + continue + name, _params, value = parsed + value = value.strip() + if name == "UID": + out["uid"] = value + elif name == "SUMMARY": + out["summary"] = _unescape_ical(value) + elif name == "DTSTART": + out["dtstart"] = value + elif name == "DTEND": + out["dtend"] = value + elif name == "LOCATION": + out["location"] = _unescape_ical(value) + elif name == "DESCRIPTION": + out["description"] = _unescape_ical(value) + return out + + +def _vcalendar_to_events(vcalendar_text: str) -> list: + """Extrae todos los VEVENT de un VCALENDAR y los convierte a JSON.""" + events = [] + for block in _VEVENT_RE.findall(vcalendar_text): + events.append(_vevent_to_json("BEGIN:VEVENT" + block + "END:VEVENT")) + return events + + +def _event_in_range(event: dict, dt_from: str, dt_to: str) -> bool: + """True si el evento cae (por DTSTART) dentro de ``[dt_from, dt_to]``. + + La comparación es lexicográfica sobre el prefijo de fecha ``YYYYMMDD`` que + comparten todos los formatos iCal (date y date-time). ``dt_from``/``dt_to`` + vacíos desactivan ese extremo del filtro. + """ + dtstart = (event.get("dtstart") or "")[:8] + if not dtstart: + return True + if dt_from and dtstart < dt_from[:8]: + return False + if dt_to and dtstart > dt_to[:8]: + return False + return True + + +def _uid_to_href(uid: str, resources: list) -> Optional[str]: + """Localiza el href de un recurso DAV cuyo último segmento contiene el uid.""" + for res in resources: + href = res.get("href", "") + tail = href.rstrip("/").rsplit("/", 1)[-1] + if uid in tail or tail.startswith(uid): + return href + return None + + +# --------------------------------------------------------------------------- +# Construcción de la app FastAPI +# --------------------------------------------------------------------------- + + +def create_app(vault_dir: str) -> FastAPI: + """Crea la app FastAPI ligada a un vault concreto. + + Valida que el vault existe al construir el ``VaultState`` (no un 500 + silencioso en el primer request). Registra todos los endpoints sobre un + estado compartido en memoria. """ state = VaultState(vault_dir) - handler = type( - "BoundOsintWebHandler", (OsintWebHandler,), {"state": state, "quiet": quiet} - ) - return ThreadingHTTPServer(("127.0.0.1", port), handler) + app = FastAPI(title="osint_web", version="0.1.0") + app.state.vault = state + + # -- Vault -- + + @app.get("/api/health") + def health() -> dict: + """Health check: confirma que el servidor está vivo y el vault cargado.""" + return { + "status": "ok", + "vault": state.vault_dir, + "nodes": len(state.graph["nodes"]), + "edges": len(state.graph["edges"]), + } + + @app.get("/api/graph") + def api_graph() -> dict: + """Grafo completo del vault (nodos + aristas + conteos) para sigma.js. + + Cacheado en memoria; usar ``/api/refresh`` para recargar tras editar + notas. + """ + return state.graph_payload() + + @app.get("/api/nodes") + def api_nodes(tipo: str = Query("", description="tipo de nodo a filtrar")) -> dict: + """Filas de la tabla de un tipo concreto (frontmatter aplanado). + + Devuelve solo los nodos reales (no fantasma). Sin ``tipo`` devuelve + todos. + """ + rows = state.rows_by_tipo(tipo) + return {"tipo": tipo, "count": len(rows), "rows": rows} + + @app.get("/api/node/{slug}") + def api_node(slug: str) -> dict: + """Ficha de un nodo: frontmatter + body + lista de attachments.""" + detail = state.node_detail(slug) + if detail is None: + raise HTTPException(status_code=404, detail="nodo '%s' no encontrado" % slug) + return detail + + @app.get("/api/attachment") + def api_attachment( + path: str = Query(..., description="path relativo al vault"), + ) -> FileResponse: + """Sirve el binario de un attachment, con allowlist ESTRICTA al vault. + + El ``path`` es relativo al vault. Se resuelve a su realpath y se verifica + que cae dentro del vault: cualquier intento de salir + (``../../etc/passwd``, symlink fuera del vault) devuelve 403. Si el + archivo no existe (pero está dentro del vault), 404. + """ + abs_path = state.resolve_attachment_path(path) + if abs_path is None: + # Distinguimos traversal (403) de inexistente-dentro-del-vault (404). + candidate = os.path.realpath(os.path.join(state._vault_real, path or "")) + if state._is_within_vault(candidate) and candidate != state._vault_real: + raise HTTPException(status_code=404, detail="attachment no encontrado") + raise HTTPException(status_code=403, detail="path fuera del vault") + return FileResponse(abs_path) + + @app.get("/api/search") + def api_search(q: str = Query(..., min_length=1)) -> dict: + """Nodos del grafo cuyas notas matchean la query (substring).""" + results = state.search(q) + return {"query": q, "count": len(results), "results": results} + + # -- Xandikos: contactos (CardDAV) -- + + @app.get("/api/contacts") + def api_contacts() -> JSONResponse: + """Contactos del addressbook Xandikos, parseados a JSON. + + Lista los recursos de la colección CardDAV, descarga cada ``.vcf`` y lo + parsea con ``split_vcards`` + parseo ligero. Si Xandikos no responde + (sin red) devuelve un error claro (502), no un crash. + """ + try: + password = state.xandikos_password() + except RuntimeError as exc: + return JSONResponse(status_code=503, content={"status": "error", "error": str(exc)}) + + listing = dav_list_resources( + XANDIKOS_BASE_URL, + XANDIKOS_USERNAME, + password, + XANDIKOS_CONTACTS_COLLECTION, + ) + if listing.get("status") != "ok": + return JSONResponse( + status_code=502, + content={"status": "error", "error": "Xandikos no responde: %s" % listing.get("error")}, + ) + + contacts: list = [] + for res in listing.get("resources", []): + href = res.get("href") + if not href or not href.lower().endswith(".vcf"): + continue + got = dav_get_resource(XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, href) + if got.get("status") != "ok": + continue + for card_text in split_vcards(got.get("text", "")): + card = _vcard_to_json(card_text) + card["etag"] = res.get("etag") + contacts.append(card) + + contacts.sort(key=lambda c: (c.get("fn") or c.get("uid") or "").lower()) + return JSONResponse(content={"status": "ok", "count": len(contacts), "contacts": contacts}) + + @app.get("/api/contact/{uid}") + def api_contact(uid: str) -> JSONResponse: + """Un vCard concreto (por UID) a JSON.""" + try: + password = state.xandikos_password() + except RuntimeError as exc: + return JSONResponse(status_code=503, content={"status": "error", "error": str(exc)}) + + listing = dav_list_resources( + XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, XANDIKOS_CONTACTS_COLLECTION + ) + if listing.get("status") != "ok": + return JSONResponse( + status_code=502, + content={"status": "error", "error": "Xandikos no responde: %s" % listing.get("error")}, + ) + href = _uid_to_href(uid, listing.get("resources", [])) + if not href: + raise HTTPException(status_code=404, detail="contacto '%s' no encontrado" % uid) + got = dav_get_resource(XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, href) + if got.get("status") != "ok": + return JSONResponse( + status_code=502, + content={"status": "error", "error": "no se pudo descargar el vCard"}, + ) + cards = split_vcards(got.get("text", "")) + if not cards: + raise HTTPException(status_code=404, detail="vCard vacío") + return JSONResponse(content={"status": "ok", "contact": _vcard_to_json(cards[0])}) + + # -- Xandikos: calendario (CalDAV) -- + + @app.get("/api/calendar") + def api_calendar( + from_: str = Query("", alias="from", description="fecha inicio YYYYMMDD"), + to: str = Query("", description="fecha fin YYYYMMDD"), + ) -> JSONResponse: + """Eventos del calendario Xandikos en ``[from, to]``, parseados a JSON. + + Lista los recursos de la colección CalDAV, descarga cada ``.ics``, + extrae sus VEVENT y los filtra por DTSTART dentro del rango. Sin red -> + error claro (502), no crash. + """ + try: + password = state.xandikos_password() + except RuntimeError as exc: + return JSONResponse(status_code=503, content={"status": "error", "error": str(exc)}) + + listing = dav_list_resources( + XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, XANDIKOS_CALENDAR_COLLECTION + ) + if listing.get("status") != "ok": + return JSONResponse( + status_code=502, + content={"status": "error", "error": "Xandikos no responde: %s" % listing.get("error")}, + ) + + events: list = [] + for res in listing.get("resources", []): + href = res.get("href") + if not href or not href.lower().endswith(".ics"): + continue + got = dav_get_resource(XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, href) + if got.get("status") != "ok": + continue + for event in _vcalendar_to_events(got.get("text", "")): + if _event_in_range(event, from_, to): + events.append(event) + + events.sort(key=lambda e: e.get("dtstart") or "") + return JSONResponse(content={"status": "ok", "count": len(events), "events": events}) + + # -- Refresco de cachés -- + + @app.post("/api/refresh") + def api_refresh() -> dict: + """Invalida y reconstruye la caché del grafo del vault. + + Los datos DAV no se cachean, así que esto solo afecta al grafo/tablas del + vault. Devuelve el conteo del grafo recién reconstruido. + """ + summary = state.refresh() + return {"status": "refreshed", **summary} + + return app -def main(argv=None) -> int: +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def _parse_args(argv: Optional[list] = None) -> argparse.Namespace: parser = argparse.ArgumentParser( - description="Backend local de osint_web: sirve el vault OSINT como API JSON." + description="Backend osint_web: sirve el vault osint + agenda/calendario Xandikos." ) parser.add_argument( "--vault", default=os.path.expanduser("~/Obsidian/osint"), - help="ruta a la raíz del vault de Obsidian (default: ~/Obsidian/osint)", + help="ruta al vault de Obsidian osint (default: ~/Obsidian/osint)", + ) + parser.add_argument( + "--host", + default="127.0.0.1", + help="host de escucha (default: 127.0.0.1 — NO cambiar: datos sensibles)", ) parser.add_argument( "--port", type=int, default=8470, help="puerto local (default: 8470)" ) - args = parser.parse_args(argv) + return parser.parse_args(argv) + +def main(argv: Optional[list] = None) -> int: + args = _parse_args(argv) + if args.host != "127.0.0.1": + # Seguridad: el vault tiene PII (DNIs, fotos). Nunca exponer a red. + print( + "ADVERTENCIA: --host distinto de 127.0.0.1. El vault contiene datos " + "personales sensibles; exponerlo a la red es un riesgo.", + file=sys.stderr, + ) try: - server = make_server(args.vault, args.port) + app = create_app(args.vault) except (FileNotFoundError, NotADirectoryError) as exc: print(f"error: {exc}", file=sys.stderr) return 2 - state = server.RequestHandlerClass.state + import uvicorn + + state = app.state.vault print( - f"osint_web backend en http://127.0.0.1:{args.port} — vault: " + f"osint_web backend en http://{args.host}:{args.port} — vault: " f"{state.vault_dir} ({len(state.graph['nodes'])} nodos, " f"{len(state.graph['edges'])} aristas)" ) - try: - server.serve_forever() - except KeyboardInterrupt: - print("\nparando osint_web backend") - finally: - server.server_close() + uvicorn.run(app, host=args.host, port=args.port, log_level="info") return 0 diff --git a/server/test_main.py b/server/test_main.py deleted file mode 100644 index 5c5ea8d..0000000 --- a/server/test_main.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Tests del backend osint_web sobre un vault fixture efímero. - -Cubre los escenarios del Definition of Done del issue 0172 que aplican al -backend: grafo golden, tabla filtrada por tipo, ficha con attachments, -wikilink dangling, slug con acentos, path traversal bloqueado y vault -inexistente con error claro. Incluye un test e2e que levanta el servidor en -un puerto efímero y golpea los endpoints reales por HTTP. -""" - -import importlib.util -import json -import os -import subprocess -import sys -import threading -import urllib.error -import urllib.request - -import pytest - -HERE = os.path.dirname(os.path.abspath(__file__)) - -_spec = importlib.util.spec_from_file_location( - "osint_web_main", os.path.join(HERE, "main.py") -) -main = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(main) - - -# --- fixture: vault mínimo con personas, organizaciones y attachments -------- - - -@pytest.fixture() -def vault(tmp_path): - """Vault Obsidian efímero: 2 notas reales conectadas + 1 wikilink roto.""" - root = tmp_path / "vault_osint" - (root / ".obsidian").mkdir(parents=True) - (root / ".obsidian" / "app.json").write_text("{}", encoding="utf-8") - - persona_dir = root / "personas" - persona_dir.mkdir() - (persona_dir / "maria-del-mar-perez.md").write_text( - "---\n" - "tipo: persona\n" - "nombre: María del Mar Pérez\n" - "dni: 12345678Z\n" - "fecha_nacimiento: 1980-05-01\n" - "tags: [objetivo]\n" - "---\n" - "\n" - "Ficha de prueba.\n" - "\n" - "## Relaciones\n" - "\n" - "- [[ACME SL]]\n" - "- [[Persona-Inexistente]]\n" - "\n" - "## Documentos\n" - "\n" - "![[dni-maria.jpg]]\n" - "![[certificado-perdido.pdf]]\n", - encoding="utf-8", - ) - - org_dir = root / "organizaciones" - org_dir.mkdir() - (org_dir / "acme-sl.md").write_text( - "---\n" - "tipo: organizacion\n" - "nombre: ACME SL\n" - "cif: B00000000\n" - "---\n" - "\n" - "## Relaciones\n" - "\n" - "- [[María del Mar Pérez]]\n", - encoding="utf-8", - ) - - attach_dir = root / "attachments" / "personas" / "maria-del-mar-perez" - attach_dir.mkdir(parents=True) - (attach_dir / "dni-maria.jpg").write_bytes(b"\xff\xd8\xff" + b"fakejpegdata") - - return str(root) - - -# --- VaultState: grafo, tablas, fichas ---------------------------------------- - - -def test_graph_golden(vault): - state = main.VaultState(vault) - ids = {n["id"] for n in state.graph["nodes"]} - assert {"maria-del-mar-perez", "acme-sl"} <= ids - # Arista de la sección ## Relaciones con kind correcto y destino resuelto. - assert { - "source": "maria-del-mar-perez", - "target": "acme-sl", - "kind": "relacion", - } in state.graph["edges"] - - -def test_wikilink_acentos_resuelve_por_slug(vault): - """[[María del Mar Pérez]] (acentos, mayúsculas) → maria-del-mar-perez.md.""" - state = main.VaultState(vault) - assert { - "source": "acme-sl", - "target": "maria-del-mar-perez", - "kind": "relacion", - } in state.graph["edges"] - - -def test_wikilink_dangling_genera_nodo_fantasma(vault): - state = main.VaultState(vault) - ghosts = [n for n in state.graph["nodes"] if n.get("dangling")] - assert any(n["id"] == "persona-inexistente" for n in ghosts) - # Y no aparece en las tablas (solo nodos reales). - assert all(r["id"] != "persona-inexistente" for r in state.rows_by_tipo("")) - - -def test_rows_filtradas_por_tipo(vault): - state = main.VaultState(vault) - rows = state.rows_by_tipo("organizacion") - assert [r["id"] for r in rows] == ["acme-sl"] - assert rows[0]["frontmatter"]["cif"] == "B00000000" - - -def test_node_detail_con_attachments(vault): - state = main.VaultState(vault) - detail = state.node_detail("maria-del-mar-perez") - assert detail is not None - assert detail["frontmatter"]["dni"] == "12345678Z" - assert "Ficha de prueba" in detail["body"] - by_name = {a["name"]: a for a in detail["attachments"]} - dni = by_name["dni-maria.jpg"] - assert dni["kind"] == "image" - assert dni["path"] == os.path.join( - "attachments", "personas", "maria-del-mar-perez", "dni-maria.jpg" - ) - # Embed que no resuelve a archivo → marcado missing, sin crash. - assert by_name["certificado-perdido.pdf"]["kind"] == "missing" - - -def test_node_detail_desconocido(vault): - state = main.VaultState(vault) - assert state.node_detail("no-existe-este-slug") is None - - -# --- seguridad: path traversal + vault inexistente ---------------------------- - - -def test_attachment_path_traversal_bloqueado(vault): - state = main.VaultState(vault) - assert state.resolve_attachment_path("../../etc/passwd") is None - assert state.resolve_attachment_path("/etc/passwd") is None - assert state.resolve_attachment_path("") is None - assert state.resolve_attachment_path(".") is None - # Un path legítimo dentro del vault sí resuelve. - ok = state.resolve_attachment_path( - "attachments/personas/maria-del-mar-perez/dni-maria.jpg" - ) - assert ok is not None and ok.endswith("dni-maria.jpg") - - -def test_vault_inexistente_error_claro(): - with pytest.raises(FileNotFoundError, match="el vault no existe"): - main.VaultState("/no/existe/este/vault") - - -def test_cli_vault_inexistente_exit_2(): - proc = subprocess.run( - [sys.executable, os.path.join(HERE, "main.py"), "--vault", "/no/existe"], - capture_output=True, - text=True, - timeout=30, - ) - assert proc.returncode == 2 - assert "el vault no existe" in proc.stderr - - -# --- e2e HTTP: server real en puerto efímero ---------------------------------- - - -def _get(base, path): - try: - with urllib.request.urlopen(base + path, timeout=10) as resp: - return resp.status, resp.headers.get("Content-Type", ""), resp.read() - except urllib.error.HTTPError as err: - return err.code, err.headers.get("Content-Type", ""), err.read() - - -def test_http_endpoints(vault): - server = main.make_server(vault, 0, quiet=True) - port = server.server_address[1] - base = f"http://127.0.0.1:{port}" - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - status, _, body = _get(base, "/api/health") - assert status == 200 - health = json.loads(body) - assert health["status"] == "ok" and health["nodes"] >= 2 - - status, _, body = _get(base, "/api/graph") - graph = json.loads(body) - assert status == 200 and len(graph["edges"]) >= 2 - - status, _, body = _get(base, "/api/nodes?tipo=persona") - rows = json.loads(body) - assert status == 200 and [r["id"] for r in rows] == ["maria-del-mar-perez"] - - status, _, body = _get(base, "/api/node/maria-del-mar-perez") - detail = json.loads(body) - assert status == 200 and detail["label"] == "María del Mar Pérez" - # PyYAML parsea la fecha como datetime.date → debe serializar a ISO. - assert detail["frontmatter"]["fecha_nacimiento"] == "1980-05-01" - - status, ctype, body = _get( - base, - "/api/attachment?path=attachments/personas/maria-del-mar-perez/dni-maria.jpg", - ) - assert status == 200 and ctype.startswith("image/") and body[:3] == b"\xff\xd8\xff" - - # Error path del DoD: traversal jamás sirve fuera del vault. - status, _, _ = _get(base, "/api/attachment?path=../../etc/passwd") - assert status == 403 - - status, _, body = _get(base, "/api/search?q=ACME") - hits = json.loads(body) - assert status == 200 and any(h["id"] == "acme-sl" for h in hits) - - status, _, _ = _get(base, "/api/node/slug-fantasma") - assert status == 404 - - # POST /api/refresh reconstruye la caché. - req = urllib.request.Request(base + "/api/refresh", method="POST") - with urllib.request.urlopen(req, timeout=10) as resp: - refreshed = json.loads(resp.read()) - assert resp.status == 200 and refreshed["status"] == "refreshed" - finally: - server.shutdown() - server.server_close() diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..c955615 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,252 @@ +"""Tests del backend osint_web. + +Cubren el contrato del DoD del issue 0172 sin depender de la red (Xandikos): +- Path traversal en /api/attachment (seguridad obligatoria). +- Vault inexistente -> error claro al arrancar, no 500. +- Grafo / tablas filtradas por tipo / ficha con attachments sobre un vault + mínimo sintético construido en un tmpdir. +- Endpoints DAV: degradación clara (no crash) cuando Xandikos no responde, y + parseo vCard/iCalendar a JSON sin red. + +Se usa el ``TestClient`` de Starlette/FastAPI sobre un vault temporal, así los +tests son herméticos y deterministas (no tocan el vault real con PII). +""" + +import os +import sys + +import pytest +from fastapi.testclient import TestClient + +# El backend orquesta funciones del registry: hay que poder importarlas. +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(_HERE, "..", "server")) + +import main as srv # noqa: E402 + + +# --------------------------------------------------------------------------- +# Fixtures: vault sintético mínimo +# --------------------------------------------------------------------------- + + +def _write(path: str, content: str) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +@pytest.fixture() +def vault(tmp_path): + """Construye un vault de Obsidian mínimo con personas, una org y un attachment.""" + root = tmp_path / "osint" + # Una persona con foto embebida (por path) y un wikilink a una org y a una + # persona inexistente. + _write( + str(root / "personas" / "ana-gomez.md"), + "---\n" + "tipo: persona\n" + "nombre: Ana Gómez\n" + "dni: 12345678A\n" + "tags: [objetivo]\n" + "---\n\n" + "## Relaciones\n" + "- [[acme-sl]]\n" + "- [[Persona-Inexistente]]\n\n" + "## Documentos\n" + "![[attachments/personas/ana-gomez/ana-foto.jpg]]\n", + ) + _write( + str(root / "organizaciones" / "acme-sl.md"), + "---\ntipo: organizacion\nnombre: Acme SL\ncif: B12345678\n---\n\nOrg de prueba.\n", + ) + # El attachment embebido (basta un archivo cualquiera). + _write(str(root / "attachments" / "personas" / "ana-gomez" / "ana-foto.jpg"), "FAKEJPEGDATA") + # Un archivo secreto FUERA del vault, para el test de path traversal. + _write(str(tmp_path / "secret.txt"), "TOP SECRET") + return str(root) + + +@pytest.fixture() +def client(vault): + app = srv.create_app(vault) + return TestClient(app) + + +# --------------------------------------------------------------------------- +# Golden: grafo carga el vault +# --------------------------------------------------------------------------- + + +def test_graph_loads_vault(client): + resp = client.get("/api/graph") + assert resp.status_code == 200 + data = resp.json() + # 2 notas reales (ana, acme) + 1 nodo fantasma (Persona-Inexistente). + ids = {n["id"] for n in data["nodes"]} + assert "ana-gomez" in ids + assert "acme-sl" in ids + assert data["total_edges"] >= 1 + # Conteos por tipo presentes para la leyenda. + assert data["counts"].get("persona") == 1 + assert data["counts"].get("organizacion") == 1 + + +def test_node_card_with_attachments(client): + resp = client.get("/api/node/ana-gomez") + assert resp.status_code == 200 + data = resp.json() + assert data["frontmatter"]["nombre"] == "Ana Gómez" + assert data["body"].strip() != "" + # La galería de attachments resuelve la foto embebida (por path). + foto = next(a for a in data["attachments"] if a["name"].endswith("ana-foto.jpg")) + assert foto["kind"] == "image" + assert foto["path"] # path relativo al vault, no vacío + + +# --------------------------------------------------------------------------- +# Edge: tabla filtrada por tipo +# --------------------------------------------------------------------------- + + +def test_nodes_filtered_by_tipo(client): + resp = client.get("/api/nodes", params={"tipo": "organizacion"}) + assert resp.status_code == 200 + data = resp.json() + assert data["count"] == 1 + assert all(r["tipo"] == "organizacion" for r in data["rows"]) + assert data["rows"][0]["id"] == "acme-sl" + + +# --------------------------------------------------------------------------- +# Edge: wikilink dangling -> nodo fantasma, sin crash +# --------------------------------------------------------------------------- + + +def test_dangling_wikilink_is_phantom(client): + data = client.get("/api/graph").json() + phantom_ids = {n["id"] for n in data["nodes"] if n.get("dangling")} + assert "persona-inexistente" in phantom_ids + + +# --------------------------------------------------------------------------- +# Edge: nombre con mayúsculas/acentos -> slug estable +# --------------------------------------------------------------------------- + + +def test_slugify_accents(): + assert srv.slugify_obsidian_name("María del Mar") == "maria-del-mar" + + +# --------------------------------------------------------------------------- +# Error: path traversal en attachment (SEGURIDAD obligatoria) +# --------------------------------------------------------------------------- + + +def test_attachment_path_traversal_blocked(client): + resp = client.get("/api/attachment", params={"path": "../../etc/passwd"}) + assert resp.status_code in (403, 404) + assert "root:" not in resp.text + + resp2 = client.get("/api/attachment", params={"path": "../secret.txt"}) + assert resp2.status_code in (403, 404) + assert "TOP SECRET" not in resp2.text + + +def test_attachment_legit_served(client): + rel = os.path.join("attachments", "personas", "ana-gomez", "ana-foto.jpg") + resp = client.get("/api/attachment", params={"path": rel}) + assert resp.status_code == 200 + assert resp.content == b"FAKEJPEGDATA" + + +def test_attachment_nonexistent_inside_vault_404(client): + resp = client.get("/api/attachment", params={"path": "attachments/no-existe.png"}) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Error: vault inexistente -> error claro al arrancar, no 500 +# --------------------------------------------------------------------------- + + +def test_vault_inexistent_raises_clear_error(): + with pytest.raises(FileNotFoundError): + srv.create_app("/no/existe/vault/osint") + + +# --------------------------------------------------------------------------- +# Búsqueda +# --------------------------------------------------------------------------- + + +def test_search_finds_node(client): + resp = client.get("/api/search", params={"q": "Ana"}) + assert resp.status_code == 200 + ids = {r["id"] for r in resp.json()["results"]} + assert "ana-gomez" in ids + + +# --------------------------------------------------------------------------- +# DAV: parseo a JSON (sin red) + degradación clara +# --------------------------------------------------------------------------- + + +def test_vcard_to_json(): + vcard = ( + "BEGIN:VCARD\r\n" + "VERSION:3.0\r\n" + "UID:abc-123\r\n" + "FN:Juan Pérez\r\n" + "NICKNAME:Juanito\r\n" + "ORG:Acme;Ventas\r\n" + "TEL;TYPE=CELL:+34600111222\r\n" + "EMAIL;TYPE=HOME:juan@example.com\r\n" + "NOTE:Contacto de prueba\r\n" + "END:VCARD\r\n" + ) + out = srv._vcard_to_json(vcard) + assert out["uid"] == "abc-123" + assert out["fn"] == "Juan Pérez" + assert out["nickname"] == "Juanito" + assert out["org"] == "Acme Ventas" + assert out["phones"][0]["value"] == "+34600111222" + assert out["emails"][0]["value"] == "juan@example.com" + + +def test_vevent_to_json_and_range(): + vcal = ( + "BEGIN:VCALENDAR\r\n" + "BEGIN:VEVENT\r\n" + "UID:evt-1\r\n" + "SUMMARY:Reunión OSINT\r\n" + "DTSTART:20260615T090000Z\r\n" + "DTEND:20260615T100000Z\r\n" + "LOCATION:Oficina\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" + ) + events = srv._vcalendar_to_events(vcal) + assert len(events) == 1 + evt = events[0] + assert evt["summary"] == "Reunión OSINT" + assert evt["dtstart"].startswith("20260615") + assert srv._event_in_range(evt, "20260601", "20260630") is True + assert srv._event_in_range(evt, "20260101", "20260131") is False + + +def test_dav_endpoints_degrade_without_network(client, monkeypatch): + """Sin Xandikos accesible los endpoints DAV devuelven error claro, no crash.""" + monkeypatch.setattr( + srv, "dav_list_resources", lambda *a, **k: {"status": "error", "error": "sin red"} + ) + # Evita leer pass en el test (cachea una password ficticia). + client.app.state.vault._xandikos_password = "x" + + r1 = client.get("/api/contacts") + assert r1.status_code == 502 + assert r1.json()["status"] == "error" + + r2 = client.get("/api/calendar") + assert r2.status_code == 502 + assert r2.json()["status"] == "error"