Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,19 @@ RUN pip install .
# vulnerabilidad permite ejecución arbitraria dentro del contenedor.
RUN addgroup --system --gid 1001 studymation \
&& adduser --system --uid 1001 --ingroup studymation studymation \
&& chown -R studymation:studymation /app
&& chown -R studymation:studymation /app \
# Chromium (launched by puppeteer for mermaid-cli) needs writable HOME,
# XDG dirs and a user-data-dir. Without this, the chromium subprocess
# crashes with "chrome_crashpad_handler: --database is required" because
# it can't initialise its config / crash-database tree under the default
# HOME of the unprivileged user (which adduser --system doesn't create).
&& mkdir -p /home/studymation /tmp/chromium-user-data \
&& chown -R studymation:studymation /home/studymation /tmp/chromium-user-data \
&& chmod 700 /home/studymation
ENV HOME=/home/studymation \
XDG_CONFIG_HOME=/home/studymation/.config \
XDG_CACHE_HOME=/home/studymation/.cache \
PUPPETEER_DISABLE_HEADLESS_WARNING=true
USER studymation:studymation

# Healthcheck para que orquestadores (DigitalOcean / Docker Swarm / k8s)
Expand Down
161 changes: 145 additions & 16 deletions backend/app/core/document/assemblers/diagram_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@
from __future__ import annotations

import asyncio
import base64
import io
import os
import tempfile
from pathlib import Path

import aiohttp

from app.utils.logger import get_logger

logger = get_logger(__name__)
Expand All @@ -40,6 +43,14 @@
Path("/usr/bin/mmdc"),
]

# Remote renderer fallback. mermaid.ink encodes the source as base64-url and
# returns a PNG via a simple GET. Used only when the local mmdc binary fails
# (Chromium crash, sandbox issue, etc.) so the document still ships a
# rendered figure instead of falling back to a raw-source block.
_REMOTE_RENDERER_URL = "https://mermaid.ink/img/{payload}?type=png&bgColor=FFFFFF"
_REMOTE_RENDERER_TIMEOUT_SECONDS = 12
_REMOTE_RENDERER_MAX_BYTES = 4 * 1024 * 1024


def _find_mmdc() -> str:
"""Busca el ejecutable mmdc en las rutas conocidas o en PATH."""
Expand Down Expand Up @@ -70,21 +81,33 @@ def __init__(self) -> None:
self._preflight_result: bool | None = None

async def preflight(self) -> bool:
"""Verifica que mmdc esté disponible y responda a `--version`.
"""Verifica que algún renderer Mermaid esté disponible.

Devuelve True si mmdc responde a `--version` localmente. Si mmdc no
está, el renderer remoto sigue funcionando (no requiere binario), así
que devolvemos True igualmente para que el pipeline NO emita el
warning `mermaid_renderer_unavailable` cuando hay fallback HTTPS.

Cacheado por instancia: la primera invocación arranca el subproceso;
las siguientes devuelven el resultado en memoria. Si el binario está
ausente, retorna False sin lanzar — el pipeline trata Mermaid como
feature opcional.
las siguientes devuelven el resultado en memoria.
"""
if self._preflight_result is not None:
return self._preflight_result
try:
env = os.environ.copy()
# Chromium (lanzado por puppeteer) requiere HOME y XDG dirs
# escribibles. Si no, falla con "chrome_crashpad_handler: --database
# is required" en Debian-slim. En contenedor el Dockerfile fija HOME
# a /home/studymation; en local apuntamos a /tmp como fallback.
env.setdefault("HOME", "/tmp")
env.setdefault("XDG_CONFIG_HOME", env.get("HOME", "/tmp") + "/.config")
env.setdefault("XDG_CACHE_HOME", env.get("HOME", "/tmp") + "/.cache")
proc = await asyncio.create_subprocess_exec(
self._mmdc,
"--version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
try:
await asyncio.wait_for(proc.communicate(), timeout=5)
Expand All @@ -95,33 +118,58 @@ async def preflight(self) -> bool:
except TimeoutError:
pass
logger.warning("diagram_renderer_preflight_timeout")
self._preflight_result = False
return False
self._preflight_result = proc.returncode == 0
if not self._preflight_result:
# Aún tenemos el fallback remoto.
self._preflight_result = True
return True
if proc.returncode == 0:
self._preflight_result = True
else:
logger.warning(
"diagram_renderer_preflight_failed",
"diagram_renderer_preflight_failed_remote_fallback",
returncode=proc.returncode,
)
# mmdc roto pero el fallback remoto sigue activo.
self._preflight_result = True
return self._preflight_result
except FileNotFoundError:
logger.warning("diagram_renderer_preflight_binary_missing", binary=self._mmdc)
self._preflight_result = False
return False
# mmdc no instalado: aún tenemos el fallback remoto, así que
# reportamos preflight OK para no emitir mermaid_renderer_unavailable.
logger.info(
"diagram_renderer_preflight_binary_missing_remote_fallback",
binary=self._mmdc,
)
self._preflight_result = True
return True
except Exception as exc:
logger.warning("diagram_renderer_preflight_error", error=str(exc))
self._preflight_result = False
return False
self._preflight_result = True
return True

async def render(self, mermaid_source: str) -> io.BytesIO | None:
"""Renderiza un diagrama Mermaid y devuelve un buffer PNG.

Estrategia en cascada:
1. mmdc local (mermaid-cli + Chromium del sistema).
2. mermaid.ink remoto (PNG vía HTTPS) si mmdc falla.

Args:
mermaid_source: String con la definición Mermaid (sin delimitadores).

Returns:
BytesIO con la imagen PNG, o None si el renderizado falla.
BytesIO con la imagen PNG, o None si todos los renderizadores
disponibles fallan.
"""
buf = await self._render_local(mermaid_source)
if buf is not None:
return buf

# Fallback remoto — mmdc puede fallar por sandbox, Chromium ausente o
# crash del puppeteer. El renderer remoto es best-effort y solo se
# invoca tras un fallo local, así que no añade latencia en el camino feliz.
return await self._render_remote(mermaid_source)

async def _render_local(self, mermaid_source: str) -> io.BytesIO | None:
"""Renderiza Mermaid invocando mmdc local. Devuelve None si falla."""
tmp_mmd: str | None = None
tmp_png: str | None = None

Expand Down Expand Up @@ -151,13 +199,29 @@ async def render(self, mermaid_source: str) -> io.BytesIO | None:
if self._puppeteer_config:
cmd += ["--puppeteerConfigFile", self._puppeteer_config]

# Force a writable HOME/XDG dir so Chromium (launched by puppeteer
# inside mmdc) can create its config / crash-database directories
# even when the API process runs as an unprivileged user whose
# home isn't writable. Without this, on Debian-slim the chromium
# child crashes with "chrome_crashpad_handler: --database is required".
env = os.environ.copy()
# Chromium (lanzado por puppeteer) requiere HOME y XDG dirs
# escribibles. Si no, falla con "chrome_crashpad_handler: --database
# is required" en Debian-slim. En contenedor el Dockerfile fija HOME
# a /home/studymation; en local apuntamos a /tmp como fallback.
env.setdefault("HOME", "/tmp")
env.setdefault("XDG_CONFIG_HOME", env.get("HOME", "/tmp") + "/.config")
env.setdefault("XDG_CACHE_HOME", env.get("HOME", "/tmp") + "/.cache")
env.setdefault("PUPPETEER_DISABLE_HEADLESS_WARNING", "true")

proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
try:
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=45)
except TimeoutError:
# mmdc lanza Chromium/puppeteer; si no terminamos el proceso
# explícitamente queda zombie consumiendo RAM hasta reinicio.
Expand Down Expand Up @@ -200,3 +264,68 @@ async def render(self, mermaid_source: str) -> io.BytesIO | None:
os.unlink(path)
except OSError:
pass

async def _render_remote(self, mermaid_source: str) -> io.BytesIO | None:
"""Renderiza Mermaid usando mermaid.ink como fallback HTTPS.

El payload se codifica en base64-url-safe (sin padding) y se solicita
un PNG directamente. La respuesta es la imagen binaria, sin necesidad
de Chromium local. Best-effort: cualquier error devuelve None y el
assembler activa el fallback de tabla estructural.
"""
source = mermaid_source.strip()
if not source:
return None

try:
payload = base64.urlsafe_b64encode(source.encode("utf-8")).decode("ascii")
payload = payload.rstrip("=")
url = _REMOTE_RENDERER_URL.format(payload=payload)
timeout = aiohttp.ClientTimeout(total=_REMOTE_RENDERER_TIMEOUT_SECONDS)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(
url,
headers={"User-Agent": "Studymation/1.0 (mermaid renderer fallback)"},
allow_redirects=True,
) as response:
if response.status != 200:
logger.warning(
"diagram_renderer_remote_non_200",
status=response.status,
source_preview=source[:80],
)
return None
content_type = response.headers.get("Content-Type", "")
if not content_type.startswith("image/"):
logger.warning(
"diagram_renderer_remote_bad_content_type",
content_type=content_type[:60],
)
return None
buffer = bytearray()
async for chunk in response.content.iter_chunked(64 * 1024):
buffer.extend(chunk)
if len(buffer) > _REMOTE_RENDERER_MAX_BYTES:
logger.warning("diagram_renderer_remote_oversize")
return None
if len(buffer) < 256:
logger.warning("diagram_renderer_remote_too_small", size=len(buffer))
return None
logger.info(
"diagram_renderer_remote_success",
size=len(buffer),
source_preview=source[:60],
)
buf = io.BytesIO(bytes(buffer))
buf.seek(0)
return buf
except TimeoutError:
logger.warning("diagram_renderer_remote_timeout")
return None
except Exception as exc:
logger.warning(
"diagram_renderer_remote_error",
error_type=type(exc).__name__,
error=str(exc)[:200],
)
return None
Loading
Loading