feat: FastAPI backend (vault osint + agenda/calendario Xandikos)

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.
This commit is contained in:
agent
2026-06-11 22:47:51 +02:00
parent 6af9a56c28
commit 5b51e3d035
6 changed files with 1012 additions and 460 deletions
+62 -27
View File
@@ -1,10 +1,10 @@
--- ---
name: osint_web name: osint_web
lang: py lang: py
domain: tools domain: osint
version: 0.1.0 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)." 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, graph, sigma, obsidian, vault, dashboard, mantine] tags: [osint, web, sigma, graph, mantine, dav, obsidian, vault, dashboard]
uses_functions: uses_functions:
- build_obsidian_graph_py_obsidian - build_obsidian_graph_py_obsidian
- list_obsidian_notes_py_obsidian - list_obsidian_notes_py_obsidian
@@ -13,6 +13,10 @@ uses_functions:
- resolve_obsidian_embed_py_obsidian - resolve_obsidian_embed_py_obsidian
- slugify_obsidian_name_py_obsidian - slugify_obsidian_name_py_obsidian
- search_obsidian_notes_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: [] uses_types: []
framework: "react-vite-mantine" framework: "react-vite-mantine"
entry_point: "server/main.py" 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" repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/osint_web"
e2e_checks: e2e_checks:
- id: tests - id: tests
cmd: "../../../../python/.venv/bin/python3 -m pytest server -q" cmd: ".venv/bin/python -m pytest tests -q"
timeout_s: 120 timeout_s: 120
- id: vault_missing - 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 expect_exit: 2
timeout_s: 30 timeout_s: 30
--- ---
## Qué es ## Qué es
App del issue 0172 (project `osint`). Lee directamente los `.md` del vault de App del issue 0172 (project `osint`). Combina dos fuentes de datos en un frontend
Obsidian `~/Obsidian/osint` (sin BD intermedia — decisión KISS) y ofrece tres web local:
vistas: grafo explorable (sigma.js), tablas filtradas por tipo y fichas con la
galería de attachments de cada nodo.
Registry-first: el backend NO parsea el vault — orquesta las funciones del 1. **El vault de Obsidian `~/Obsidian/osint`** (sin BD intermedia — decisión
grupo de capacidad `obsidian` (`build_obsidian_graph`, `read_obsidian_note`, KISS): grafo explorable (sigma.js), tablas filtradas por tipo y fichas con la
`resolve_obsidian_embed`, ...) declaradas en `uses_functions`. 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 ## Arrancar el backend
```bash ```bash
cd projects/osint/apps/osint_web 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` 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 ## Endpoints
| Método | Ruta | Devuelve | | Método | Ruta | Devuelve |
|---|---|---| |---|---|---|
| GET | `/api/health` | estado + nº de nodos/aristas cacheados | | 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/nodes?tipo=persona` | filas de la tabla de ese tipo (id, label, tipo, frontmatter) |
| GET | `/api/node/<slug>` | ficha: frontmatter + body Markdown + attachments + wikilinks | | GET | `/api/node/<slug>` | ficha: frontmatter + body Markdown + attachments + wikilinks |
| GET | `/api/attachment?path=<rel>` | binario del attachment (path relativo al vault, allowlist) | | GET | `/api/attachment?path=<rel>` | binario del attachment (path relativo al vault, allowlist) |
| GET | `/api/search?q=...` | nodos cuyo contenido matchea la query | | GET | `/api/search?q=...` | nodos cuyo contenido matchea la query |
| GET | `/api/contacts` | contactos del addressbook Xandikos (CardDAV) a JSON |
| GET | `/api/contact/<uid>` | 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é | | 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 ## Seguridad
- El vault contiene datos personales sensibles (DNIs, fotos): el server escucha - 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`). desplegable a VPS (sin tag `service`).
- `/api/attachment` bloquea path traversal: `realpath` del candidato debe quedar - `/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 - Vault inexistente al arrancar → error claro en stderr + exit 2 (nunca 500
silencioso). silencioso).
- Sin red / Xandikos caído → los endpoints DAV devuelven `{"status":"error"}`
con código 502/503, nunca un crash.
## Tests ## Tests
```bash ```bash
cd projects/osint/apps/osint_web 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 Cubren el DoD backend del issue 0172 + las extensiones DAV: grafo golden, tabla
attachments, wikilink dangling (nodo fantasma), slug con acentos por tipo, ficha con attachments (embed por path), wikilink dangling (nodo
(`[[María del Mar Pérez]]``maria-del-mar-perez`), path traversal bloqueado, fantasma), slug con acentos (`María del Mar``maria-del-mar`), path traversal
vault inexistente y un e2e HTTP contra el server real en puerto efímero. 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 ## Estado / pendiente
- **Hecho (fase 5b)**: scaffold del sub-repo + backend completo con tests. - **Hecho**: scaffold del sub-repo + backend FastAPI completo (vault + DAV) con
- **Pendiente (fase siguiente)**: `frontend/` React + Vite + Mantine v9 + 13 tests verdes.
`@fn_library` con sigma.js + graphology (GraphView, TablesView, NodeCard). - **Pendiente (siguiente agente)**: `frontend/` React + Vite + Mantine v9 +
Onboarding previsto: `pnpm dev` en `frontend/` + backend en 8470 → abrir `@fn_library` con sigma.js + graphology (GraphView, TablesView, NodeCard,
`http://127.0.0.1:5173`. 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 - Cuando exista el manifest de sub-repos del project (issue 0171), añadir esta
app a `projects/osint/subrepos.yaml`. app a `projects/osint/subrepos.yaml`.
+52
View File
@@ -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=<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/<slug>` | 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/<uid>` | 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
```
+19
View File
@@ -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"]
+627 -192
View File
@@ -1,16 +1,25 @@
#!/usr/bin/env python3 #!/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 Sirve, en JSON (salvo el endpoint de attachments, que sirve binarios), tres
issue 0172) y expone el grafo agregado, las tablas por tipo, las fichas con fuentes de datos para un frontend web local:
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.
Seguridad: el vault contiene datos personales sensibles (DNIs, fotos), por lo 1. El vault de Obsidian ``osint`` (grafo de nodos + aristas, tablas por tipo,
que el servidor escucha exclusivamente en ``127.0.0.1`` (no hay flag para fichas con galería de attachments y búsqueda global).
exponerlo) y el endpoint de attachments bloquea cualquier path fuera del vault 2. La agenda CardDAV del servidor Xandikos (contactos).
(path traversal). No es un service desplegable a VPS. 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: Uso:
python3 server/main.py --vault /home/enmanuel/Obsidian/osint --port 8470 python3 server/main.py --vault /home/enmanuel/Obsidian/osint --port 8470
@@ -22,30 +31,39 @@ Endpoints (JSON salvo /api/attachment):
GET /api/node/<slug> ficha: frontmatter + body + attachments GET /api/node/<slug> ficha: frontmatter + body + attachments
GET /api/attachment?path=.. binario del attachment (path relativo al vault) GET /api/attachment?path=.. binario del attachment (path relativo al vault)
GET /api/search?q=... nodos cuyo contenido matchea la query GET /api/search?q=... nodos cuyo contenido matchea la query
GET /api/contacts contactos del addressbook Xandikos (CardDAV)
GET /api/contact/<uid> 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é POST /api/refresh re-escanea el vault y reconstruye la caché
""" """
from __future__ import annotations
import argparse import argparse
import datetime import importlib.util
import json
import mimetypes
import os import os
import re
import sys import sys
import threading import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Optional
from urllib.parse import parse_qs, unquote, urlparse
def _registry_functions_dir() -> str: def _registry_functions_dir() -> str:
"""Localiza ``python/functions`` del fn_registry sin paths hardcodeados. """Localiza ``python/functions`` del fn_registry sin paths hardcodeados.
Prueba primero la variable de entorno ``FN_REGISTRY_ROOT`` y después sube Prueba primero las variables de entorno ``FN_REGISTRY_FUNCTIONS`` y
por los directorios padre de este archivo hasta encontrar una raíz que ``FN_REGISTRY_ROOT``, después sube por los directorios padre de este archivo
contenga ``python/functions/obsidian``. Así el backend funciona en hasta encontrar una raíz que contenga ``python/functions/obsidian``, y por
cualquier PC con el layout estándar del registry (la app vive en último cae al layout estándar del PC (``/home/enmanuel/fn_registry``). Así el
``<root>/projects/osint/apps/osint_web/server/``). backend funciona en cualquier PC con el layout estándar del registry (la app
vive en ``<root>/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") env_root = os.environ.get("FN_REGISTRY_ROOT")
if env_root: if env_root:
candidates.append(env_root) candidates.append(env_root)
@@ -56,6 +74,7 @@ def _registry_functions_dir() -> str:
if parent == current: if parent == current:
break break
current = parent current = parent
candidates.append("/home/enmanuel/fn_registry")
for root in candidates: for root in candidates:
functions_dir = os.path.join(root, "python", "functions") functions_dir = os.path.join(root, "python", "functions")
if os.path.isdir(os.path.join(functions_dir, "obsidian")): 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) from obsidian import ( # noqa: E402 (sys.path debe resolverse antes)
build_obsidian_graph, build_obsidian_graph,
extract_obsidian_embeds, extract_obsidian_embeds,
@@ -79,23 +105,49 @@ from obsidian import ( # noqa: E402 (sys.path debe resolverse antes)
slugify_obsidian_name, 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. # Extensiones de imagen que el frontend muestra en la galería con lightbox.
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"} _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: def _attachment_kind(name: str) -> str:
"""Clasifica un attachment por extensión: ``image`` | ``pdf`` | ``other``.""" """Clasifica un attachment por extensión: ``image`` | ``pdf`` | ``other``."""
ext = os.path.splitext(name)[1].lower() ext = os.path.splitext(name)[1].lower()
@@ -106,16 +158,43 @@ def _attachment_kind(name: str) -> str:
return "other" 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 <entry>`` 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: class VaultState:
"""Caché en memoria del vault: grafo agregado + índice slug → nota. """Caché en memoria del vault: grafo agregado + índice slug → nota.
Se construye al arrancar y se reconstruye bajo demanda con ``refresh()`` Se construye al arrancar y se reconstruye bajo demanda con ``refresh()``
(botón "refrescar" del frontend → ``POST /api/refresh``). Thread-safe (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: Raises:
FileNotFoundError: si ``vault_dir`` no existe (error claro al FileNotFoundError: si ``vault_dir`` no existe (error claro al arrancar,
arrancar, nunca un 500 silencioso). nunca un 500 silencioso).
NotADirectoryError: si ``vault_dir`` no es un directorio. NotADirectoryError: si ``vault_dir`` no es un directorio.
""" """
@@ -129,8 +208,16 @@ class VaultState:
self._lock = threading.Lock() self._lock = threading.Lock()
self.graph: dict = {"nodes": [], "edges": []} self.graph: dict = {"nodes": [], "edges": []}
self.note_index: dict = {} # slug -> {"path", "tipo", "label"} 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() self.refresh()
# --- vault --------------------------------------------------------------
def refresh(self) -> dict: def refresh(self) -> dict:
"""Re-escanea el vault: reconstruye grafo + índice de notas. """Re-escanea el vault: reconstruye grafo + índice de notas.
@@ -154,12 +241,25 @@ class VaultState:
self.note_index = note_index self.note_index = note_index
return {"nodes": len(graph["nodes"]), "edges": len(graph["edges"])} 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: def rows_by_tipo(self, tipo: str) -> list:
"""Filas de la tabla de un tipo: nodos reales (no fantasma) filtrados. """Filas de la tabla de un tipo: nodos reales (no fantasma) filtrados.
Cada fila lleva ``id``, ``label``, ``tipo`` y el ``frontmatter`` Cada fila lleva ``id``, ``label``, ``tipo`` y el ``frontmatter``
completo — el frontend aplana las columnas que le interesen. completo — el frontend aplana las columnas que le interesen. Sin
Sin ``tipo`` devuelve todos los nodos reales. ``tipo`` devuelve todos los nodos reales.
""" """
rows = [] rows = []
for node in self.graph["nodes"]: for node in self.graph["nodes"]:
@@ -177,15 +277,33 @@ class VaultState:
) )
return rows 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/<slug>/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): def node_detail(self, slug: str):
"""Ficha completa de un nodo: frontmatter + body + attachments. """Ficha completa de un nodo: frontmatter + body + attachments.
Los attachments salen de los embeds ``![[...]]`` del cuerpo, resueltos Los attachments salen de los embeds ``![[...]]`` del cuerpo, resueltos a
a paths reales con ``resolve_obsidian_embed`` y devueltos como paths paths reales con ``_resolve_embed`` (que compone ``resolve_obsidian_embed``)
**relativos al vault** (lo que consume ``/api/attachment``). Un embed y devueltos como paths **relativos al vault** (lo que consume
que no resuelve se reporta con ``kind: "missing"`` y path vacío. ``/api/attachment``). Un embed que no resuelve se reporta con
``kind: "missing"`` y path vacío. Devuelve ``None`` si el slug no
Devuelve ``None`` si el slug no corresponde a ninguna nota del vault. corresponde a ninguna nota del vault.
""" """
info = self.note_index.get(slug) info = self.note_index.get(slug)
if info is None: if info is None:
@@ -196,11 +314,15 @@ class VaultState:
note = read_obsidian_note(info["path"]) note = read_obsidian_note(info["path"])
attachments = [] attachments = []
for name in extract_obsidian_embeds(note["body"]): 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: if not abs_path:
attachments.append({"name": name, "path": "", "kind": "missing"}) attachments.append({"name": name, "path": "", "kind": "missing"})
continue 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( attachments.append(
{"name": name, "path": rel, "kind": _attachment_kind(abs_path)} {"name": name, "path": rel, "kind": _attachment_kind(abs_path)}
) )
@@ -215,13 +337,24 @@ class VaultState:
"attachments": attachments, "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): def resolve_attachment_path(self, rel_path: str):
"""Resuelve un path relativo de attachment a absoluto, SOLO dentro del vault. """Resuelve un path relativo de attachment a absoluto, SOLO dentro del vault.
Bloquea path traversal: normaliza con ``realpath`` y exige que el Bloquea path traversal: normaliza con ``realpath`` (colapsa ``..`` y
resultado quede estrictamente bajo la raíz real del vault. Devuelve sigue symlinks) y exige que el resultado quede estrictamente bajo la
``None`` (→ 403/404) ante cualquier intento de salir del vault, paths raíz real del vault. Devuelve ``None`` (→ 403/404) ante cualquier intento
absolutos, o archivos inexistentes. de salir del vault, paths absolutos, o archivos inexistentes.
""" """
if not rel_path: if not rel_path:
return None return None
@@ -237,8 +370,8 @@ class VaultState:
def search(self, query: str) -> list: def search(self, query: str) -> list:
"""Búsqueda global: nodos cuyas notas matchean la query (substring). """Búsqueda global: nodos cuyas notas matchean la query (substring).
Compone ``search_obsidian_notes`` y mapea cada hit a su nodo Compone ``search_obsidian_notes`` y mapea cada hit a su nodo (slug,
(slug, label, tipo) + las líneas que matchean. label, tipo) + las líneas que matchean.
""" """
results = [] results = []
for hit in search_obsidian_notes(self.vault_dir, query): for hit in search_obsidian_notes(self.vault_dir, query):
@@ -254,171 +387,473 @@ class VaultState:
) )
return results return results
# --- Xandikos -----------------------------------------------------------
class OsintWebHandler(BaseHTTPRequestHandler): def xandikos_password(self) -> str:
"""Router HTTP fino sobre VaultState. Solo GET (+ POST /api/refresh).""" """Password de Xandikos desde ``pass``, cacheada en memoria."""
with self._lock:
# Inyectado por make_server(); class attribute para que cada request if self._xandikos_password is None:
# (instancia nueva por conexión) comparta la misma caché. self._xandikos_password = _read_pass_secret(XANDIKOS_PASS_ENTRY)
state: VaultState = None return self._xandikos_password
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=<tipo>",
"/api/node/<slug>",
"/api/attachment?path=<rel>",
"/api/search?q=<query>",
"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 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) state = VaultState(vault_dir)
handler = type( app = FastAPI(title="osint_web", version="0.1.0")
"BoundOsintWebHandler", (OsintWebHandler,), {"state": state, "quiet": quiet} app.state.vault = state
)
return ThreadingHTTPServer(("127.0.0.1", port), handler) # -- 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( 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( parser.add_argument(
"--vault", "--vault",
default=os.path.expanduser("~/Obsidian/osint"), 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( parser.add_argument(
"--port", type=int, default=8470, help="puerto local (default: 8470)" "--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: try:
server = make_server(args.vault, args.port) app = create_app(args.vault)
except (FileNotFoundError, NotADirectoryError) as exc: except (FileNotFoundError, NotADirectoryError) as exc:
print(f"error: {exc}", file=sys.stderr) print(f"error: {exc}", file=sys.stderr)
return 2 return 2
state = server.RequestHandlerClass.state import uvicorn
state = app.state.vault
print( 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"{state.vault_dir} ({len(state.graph['nodes'])} nodos, "
f"{len(state.graph['edges'])} aristas)" f"{len(state.graph['edges'])} aristas)"
) )
try: uvicorn.run(app, host=args.host, port=args.port, log_level="info")
server.serve_forever()
except KeyboardInterrupt:
print("\nparando osint_web backend")
finally:
server.server_close()
return 0 return 0
-241
View File
@@ -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()
+252
View File
@@ -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"