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:
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user