// wiki tecnica

Documentacion tecnica

Stack, endpoints, modelos, infraestructura y problemas resueltos.

Guia de usuario Documentacion tecnica

Indice

Stack tecnologico

Infraestructura

Servidor

Docker Compose

services:
  postgres:        # PostgreSQL 16 Alpine — puerto 5432 interno
  tacolu-content:  # App FastAPI — puerto 8100
  ttyd:            # Terminal web — puerto 7681

volumes:
  pgdata:          # Datos PostgreSQL persistentes

Volumenes montados (app)

./data:/app/data       # certificados, leads PDFs
./music:/app/music     # pistas BSO
./output:/app/output   # videos generados
./app:/app/app         # codigo (hot-reload)
./.env:/app/.env:ro    # configuracion

Endpoints API

Publicos (sin auth)

MetodoRutaDescripcion
GET/Landing page
GET/blogListado de articulos
GET/blog/{slug}Articulo individual
GET/formacionCurso interactivo (18 modulos)
GET/formacion/examenPagina del examen final
GET/sobreSobre nosotros
GET/wikiWiki publica (usuario)
GET/wiki/techWiki tecnica (interna)
GET/verificar/{token}Verificacion publica de certificado
GET/healthHealthcheck

Examen + Certificado

MetodoRutaDescripcion
POST/api/examen/checkoutCrea sesion Stripe o bypass si no hay claves
GET/formacion/examen/successCallback post-pago Stripe
POST/api/examen/iniciarGenera 40 preguntas aleatorias, arranca cronometro
POST/api/examen/enviarCorrige, calcula nota, genera certificado si aprueba
GET/api/examen/certificado/{token}Descarga PDF del certificado
GET/api/examen/estado/{token}Estado del examen (recuperacion de sesion)
GET/api/examen/contadorNumero de aprobados (social proof)
POST/api/examen/stripe-webhookWebhook Stripe (backup confirmacion pago)

Leads

MetodoRutaDescripcion
POST/api/leadsCaptura email, devuelve resumen PDF
GET/api/leads/countTotal de leads capturados

Admin (requiere JWT)

MetodoRutaDescripcion
POST/api/auth/loginLogin, devuelve JWT
GET/api/jobsListar jobs de video
POST/api/jobsCrear job de video
GET/api/tracksListar pistas de audio

Modelos de datos

Examen

id            INTEGER PK
token         VARCHAR UNIQUE     # UUID, identifica el examen
nombre        VARCHAR            # nombre completo del alumno
email         VARCHAR            # email del alumno
dni           VARCHAR            # DNI/NIE
stripe_session_id  VARCHAR       # ID sesion Stripe (null si modo prueba)
paid          BOOLEAN            # pago completado
paid_at       TIMESTAMP          # fecha de pago
preguntas_json  TEXT             # JSON [{id, correctAnswer}] — 40 preguntas
respuestas_json TEXT             # JSON [{id, answer}] — respuestas del alumno
nota          FLOAT              # 0-100
aprobado      BOOLEAN
intentos      INTEGER            # intentos usados
max_intentos  INTEGER            # 3 por defecto
started_at    TIMESTAMP          # inicio del intento actual
finished_at   TIMESTAMP          # fin del intento
cert_path     VARCHAR            # ruta al PDF del certificado
created_at    TIMESTAMP

Lead

id            INTEGER PK
nombre        VARCHAR
email         VARCHAR
source        VARCHAR            # "resumen-pdf"
created_at    TIMESTAMP

Articulo

id            INTEGER PK
titulo        VARCHAR
slug          VARCHAR UNIQUE
contenido     TEXT               # HTML del articulo
estado        VARCHAR            # borrador|revision|aprobado|publicado
categoria     VARCHAR
fecha_publicacion  TIMESTAMP
created_at    TIMESTAMP
updated_at    TIMESTAMP

Autenticacion

JWT con HMAC-SHA256 via python-jose. Token en cookie tacolu_auth. Lifetime: 24h. Solo usado para el panel admin (/dashboard, /api/jobs). El examen y las paginas publicas NO requieren autenticacion.

Certificados PDF

Decisiones de arquitectura

Sin cuentas de usuario para el examen

El examen se identifica con un token UUID guardado en localStorage. Evita la friccion de registro/login para un producto de pago unico. El email + DNI del formulario son suficientes para identificar al alumno.

fpdf2 en vez de WeasyPrint

WeasyPrint necesita ~200MB de dependencias (cairo, pango, gdk-pixbuf). fpdf2 son 1MB y genera PDFs validos con fuentes core. Limitacion: solo latin-1, no soporta unicode completo ni CSS.

Stripe Checkout (hosted) en vez de Stripe Elements

Stripe Checkout es una pagina hospedada por Stripe — no necesitamos manejar datos de tarjeta ni cumplir PCI DSS nivel alto. El flujo es: crear sesion server-side, redirigir al usuario, verificar al volver. Menos codigo, menos riesgo.

PostgreSQL en vez de SQLite

SQLite funcionaba bien en desarrollo pero no escala a concurrencia real. PostgreSQL via asyncpg da pool de conexiones, transacciones ACID reales, y compatibilidad con herramientas estandar (pgAdmin, backups, replicas). La migracion fue transparente: mismos modelos SQLModel, solo cambio el driver.

Problemas y soluciones

TypeError: can't compare offset-naive and offset-aware datetimes
Causa: SQLite pierde tzinfo al guardar. PostgreSQL rechaza mezclar naive y aware.
Fix: _now() devuelve naive UTC (.replace(tzinfo=None)). Todos los datetime guardados en DB son naive UTC.
Regex SyntaxError en Windows al parsear course_data.js
Causa: python -c con regex [^"\\] falla en cmd.exe por escape de comillas.
Fix: escribir el script a un .py temporal y ejecutar python script.py.
FPDFUnicodeEncodingException con em-dash en titulos de modulos
Causa: el caracter — (U+2014) no esta en latin-1, que es lo que usa Helvetica core.
Fix: sanitizar titulos con .replace("—", "-") y .encode("latin-1", errors="replace").
Contenedor en crash loop tras cambiar a PostgreSQL
Causa: asyncpg no estaba instalado en la imagen Docker (se instalo con pip exec que no persiste).
Fix: rebuild de la imagen con docker compose build para incluir asyncpg en la capa pip install.
WebFetch no alcanza IPs de Tailscale
Causa: el servidor de WebFetch no esta en la tailnet.
Fix: verificar siempre con curl desde la maquina local, no con WebFetch.
Respuestas del examen perdidas por crash en enviar
Causa: el servidor crasheaba antes de guardar las respuestas por error de timezone.
Fix: recuperar respuestas del JS del navegador (JSON.stringify(respuestas)), corregir manualmente en DB, regenerar certificado.
Stripe SDK y PDF bloquean event loop async
Causa: stripe.checkout.Session.create(), fpdf2 y qrcode son sincronos, llamados desde async def.
Fix: asyncio.to_thread() en todas las llamadas blocking (Stripe, certificado, resumen PDF).
correctAnswer visible en DevTools del navegador
Causa: course_data.js se servia completo con | safe, incluyendo respuestas correctas de las 69 preguntas.
Fix: _get_clean_course_data() elimina correctAnswer y explanation via regex. Tests del curso corrigen via POST /api/curso/check (server-side).
pip install en docker exec no persiste entre reinicios
Causa: la capa del filesystem del contenedor se recrea al reiniciar. pip exec instala en la capa efimera.
Fix: siempre docker compose build para que las dependencias queden en la imagen.

Auditoria de seguridad (junio 2026)

Se ejecutaron dos revisiones automatizadas (security-review + code-review ultra) que analizaron los 42 ficheros del proyecto. Se encontraron 4 criticos, 9 altos, 10 medios y 6 bajos. Todos resueltos.

Medidas de seguridad activas

MedidaImplementacion
Rate limitingslowapi: checkout 3/min, enviar 10/min, leads 5/min, curso/check 20/min
CORSOrigins restringidos: tacolu-content.duckdns.org, formacion, IP tailscale
Headers seguridadX-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy
XSS hardeningesc() en innerHTML del examen, textContent en tests del curso, correctAnswer eliminado del JS
Input validationField(max_length) en todos los schemas Pydantic (nombre 200, email 100, dni 15, token 50)
Doble envioCheck finished_at != None antes de aceptar respuestas del examen
Admin seedPassword desde ADMIN_PASSWORD env var, genera aleatoria si no existe
JWT secretSin default — la app no arranca si SECRET_KEY no esta en .env
Docker non-rootappuser en Dockerfile, HEALTHCHECK integrado
PG passwordSin fallback en docker-compose (falla si no esta en .env)
Borradores/borradores redirige a /blog sin cookie auth
QueriesSELECT COUNT en vez de cargar todos los registros en memoria
Modo pruebaBypass Stripe vinculado a APP_ENV=development, no a ausencia de clave
DNI en PDFEnmascarado (532***9L) en ambas paginas del certificado
Timer examenBasado en remaining_seconds del servidor, no en reloj del cliente
Path traversalValidacion de ruta dentro de CERTS_DIR antes de servir FileResponse
PII en logsTokens truncados a 8 chars, emails ofuscados (lu***@dom.com)
ttyd bindPuerto 7681 vinculado a IP Tailscale (100.99.20.69), no 0.0.0.0
AlembicInicializado: alembic.ini + migrations/env.py async + script.py.mako

Mejoras pendientes

Prioridad alta (antes de abrir a internet)

Prioridad media (requieren servicios externos)

Prioridad baja (codigo)

Futuro (producto/marketing)

Descartados

Resueltos en esta sesion (ya no pendientes)

Migracion a JSON: completada

course_data.js renombrado a .bak. Todo el codigo lee ahora course_data.json con json.load(). El frontend recibe el JSON limpio (sin correctAnswer) via JSON.parse({{ course_data | tojson }}). Cero regex, cero | safe.

Ficheros afectados

Changelog

2026-05-30  Examen final + Stripe + certificado PDF + verificacion + progreso
2026-05-30  Certificado rediseñado: minimalista 2 paginas + QR + APTO/NO APTO
2026-05-30  Verificacion publica /verificar/{token}
2026-05-30  Contador social + barra de progreso curso
2026-05-31  Lead magnet: modal + resumen PDF 3 paginas
2026-05-31  Migracion SQLite → PostgreSQL 16 (asyncpg)
2026-05-31  Wiki publica + wiki tecnica
2026-06-01  Auditoria seguridad: 5 criticos + 7 medios/bajos resueltos
2026-06-01  CORS, headers seguridad, Docker non-root, rate limiting, validaciones
2026-06-01  Modo prueba APP_ENV, DNI enmascarado, timer relativo, path traversal
2026-06-01  PII en logs, ttyd bind Tailscale, Alembic inicializado
2026-06-01  Migracion course_data.js → JSON puro (3 regex eliminados, | safe eliminado)
2026-06-01  CSRF descartado (CORS + JSON Content-Type es proteccion suficiente)
2026-06-01  Curso avanzado ST2 completo: 10 modulos, 40 preguntas, 50 slides
2026-06-01  Upload magic bytes, Pillow explicito, cache invalidation por mtime
2026-06-01  Dudas resueltas: motor Rust (bisemanal, equipo, ferry) + BOE (exenciones RD 640/2007)
2026-06-01  Manual documental basico (15 pags) + avanzado ST2 (9 pags) + chuletario + resumen
2026-06-01  Botones de descarga PDF dinamicos por curso (basico vs avanzado)
2026-06-03  Correccion 10 errores factuales: carga/descarga evento unico, software multimarca, DSRC ambos en movimiento
2026-06-03  Briefings v3 para reescritura de manuales (20 pags basico + 15 pags avanzado)
2026-06-03  Motor Rust consultado para resolver dudas periciales (constants.rs, week.rs, teamwork.rs, rest.rs)
2026-06-03  Reescritura manual basico formato v3 (22 pags prosa + graficos) + avanzado ST2 (15 pags)
2026-06-04  Dashboard: gestion de articulos (37 migrados de SQLite), flujo borrador/revision/aprobado/publicado
2026-06-04  Barra de progreso de lectura en articulos (scroll indicator)
2026-06-04  Borradores protegidos en zona privada (/dashboard), quitados del navbar publico
2026-06-04  Endpoints preview PDFs temporales (GET sin email para revision)

Resumen de sesiones

Sesion 1 (30 mayo - 1 junio 2026)

FeatureDetalleEstado
Examen final + Stripe40 preguntas aleatorias de 69, 60 min cronometrado, 3 intentos, certificado PDFDesplegado (modo prueba)
Certificado PDF2 paginas minimalistas, QR verificacion, APTO/NO APTO, DNI enmascaradoDesplegado
Verificacion publica/verificar/{token} con badge verde/rojo, DNI parcialDesplegado
Curso avanzado ST210 modulos, 10 lecciones, 40 preguntas, /formacion/avanzadoDesplegado
Lead magnetModal tras 2 modulos o 90s, captura email, 4 PDFs descargablesDesplegado
Manual basico PDF22 paginas v3: prosa + graficos + base legal + nota peritoEn revision
Manual avanzado ST2 PDF15 paginas v3: GNSS, DSRC, DLD, retrofit, calibracion, troubleshootingEn revision
Chuletario PDF2 paginas A4 apaisado, referencia rapida para cabinaDesplegado
Wiki publica + tecnica/wiki (usuario), /wiki/tech (desarrollo), /wiki/dudas (pericial)Desplegado
PostgreSQLMigracion de SQLite a PostgreSQL 16 via asyncpgDesplegado
Barra de progresolocalStorage, check verde por modulo, porcentaje visualDesplegado
Contador social"X personas han obtenido el certificado" en pagina del examenDesplegado

Auditoria de seguridad y fixes

Se ejecutaron 2 revisiones automatizadas (security-review + code-review ultra) sobre 42 ficheros. Resultado: 4 criticos, 9 altos, 10 medios, 6 bajos. Todos resueltos:

Migraciones tecnicas

Investigacion y hallazgos

Sesion 2 (3-4 junio 2026)

FeatureDetalleEstado
Dashboard articulos37 articulos migrados de SQLite, flujo borrador/revision/aprobado/publicadoDesplegado
Barra lecturaScroll indicator rojo en articulos y borradoresDesplegado
Correccion errores10 errores factuales en cursos JSON (34 ediciones) y PDFs (26 ediciones)Aplicadas
Briefings v3Briefings verificados para reescritura de manuales (20 pags basico + 15 avanzado)Escritos
Reescritura PDFsManual basico 22 pags v3 + avanzado ST2 15 pags v3En revision
Preview endpointsGET /api/preview/manual-basico|manual-st2|chuletario sin emailTemporal

Errores factuales corregidos (sesion 2)

ErrorDondeCorreccionFuente
Pantalla ST2 "color/tactil"PDF basico pag 11LCD monocromo en TODAS las generacionesManual VDO DTCO 4.1
Carga/descarga "inicio y fin"Curso avanzado M4, PDFsEvento UNICO, no par inicio/finManual VDO DTCO 4.1 pag 57
Software "ecosistemas cerrados"Curso avanzado M6/M9, PDFsSoftware SIEMPRE multimarca, ITS = telematica real-timeValidacion Lucas
DSRC v1 "vehiculo parado"Cursos, PDFs, chuletarioAMBAS versiones leen en movimiento, rango CEN 5-25mAnnexe 1C Reg. 165/2014
Descanso semanal invertidoPDF basico pag 18NORMAL 45h NO en vehiculo, REDUCIDO 24h SI en cabinaValidacion Lucas
CMR = otros trabajosPDF basico pag 13-14CMR/albaran = DISPONIBILIDAD, no otros trabajosValidacion Lucas
Certificado actividades digitalPDF basico pag 19Solo aplica a analogicos, con digital son entradas manualesValidacion Lucas
"8-12% expedientes"PDF avanzado pag 7Estadistica inventada, eliminadaSin fuente = eliminado
"Conduccion fantasma"PDF basico pag 14Sin movimiento de ruedas = no hay conduccionValidacion Lucas
Espera no conocidaPDF basico pag 131a hora = disponibilidad, resto = otros trabajosValidacion Lucas

Fuentes consultadas (sesion 2)

Sesion 3 (5 junio 2026)

FeatureDetalleEstado
Audio shorts ElevenLabs3 shorts del articulo "56 dias" generados con voz clonada LucasDesplegado
Audio articulo completo5482 chars, articulo "56 dias" completo con voz clonadaDesplegado
Video HeyGenShort 1 generado con talking photo (10MB, 1080x1920)Desplegado
Pagina /mediaTabla shorts (audio+video), audio articulo, otros archivosDesplegado
Shorts en DBCampo shorts_json en tabla articulo, 3 shorts con entrada/despedida fijaDesplegado
HeyGen key renovadaAPI key actualizada, 443 creditos disponiblesOK
Acceso iPad/iPhoneProblema: ACLs Tailscale no incluian puerto 8100 para tag:mobileResuelto

Red/Infra resuelto (sesion 3)

Pipeline de contenido (definido, parcialmente implementado)

Acceso web

DesdeURLEstado
PC (Tailscale)http://100.99.20.69:8100/Funciona
iPad/iPhone (Tailscale)http://100.99.20.69:8100/Funciona (puerto 8100 anadido a ACLs)
Internet publicoPendiente Cloudflare TunnelPendiente

Pendientes

Estadisticas acumuladas