Markr (3): la aplicación web, con ficheros, notas seguras y compartir con PIN
En la entrada anterior conté cómo funciona el motor que corrige. Esta va de la aplicación web que lo rodea: Django 5.2, con el motor como librería independiente, Celery para las correcciones en segundo plano y JavaScript propio, sin frameworks de frontend.
Hay muchas piezas, así que me centro en las decisiones que creo que merecen explicación. Casi todas tienen un hilo común: cada cosa que entra desde fuera se trata como hostil.
La pantalla de resultados
El texto corregido se pinta en tres capas:
- Fondo de color por nivel (A1 a C2) para las palabras y expresiones del MCER.
- Subrayado discontinuo para las estructuras propuestas por la IA.
- Subrayado rojo para los errores, con la corrección y la explicación al pasar el ratón.
En el panel lateral está la nota desglosada, las dos listas por separado (MCER e IA) y el nivel estimado según cada fuente ("MCER B1 · IA A2"). Hay filtros para ver cada fuente por separado.
Una landing con identidad propia
Decidí tener una landing decente, orientada a dos públicos: profesores con grandes volúmenes de correcciones y alumnos que quieren autocorregirse antes de entregar. Algunas decisiones que me gustaron:
- El rojo se reserva para los errores, como el boli del profesor. Los seis colores del MCER hacen de sistema de marca: aparecen en el logo, los resaltados y la escalera de niveles.
- Tipografía: Bricolage Grotesque para titulares y Atkinson Hyperlegible Next para el texto, una fuente diseñada para la legibilidad.
- Una sola animación: en la cabecera, una redacción de B1 se corrige sola delante del lector. Respeta la preferencia de reducir movimiento del sistema.
- La escalera de niveles se construye leyendo el fichero MCER, y la demo de la cabecera es la salida real del motor. Nada de ejemplos escritos a mano que puedan quedarse desfasados.
- Sin testimonios ni cifras inventadas. Los argumentos de venta solo afirman lo que el producto hace hoy.
Subida de ficheros: lo barato primero
Al principio solo se podía pegar texto. Después añadí la subida de .txt, .md, .doc, .docx y .pdf, con un límite de 5 páginas por documento. Cada archivo se convierte en su propia corrección, hasta 10 por envío.
Procesar documentos subidos por desconocidos es una fuente clásica de problemas: PDFs malformados que cuelgan el parser, ficheros comprimidos que se expanden a gigas... El orden de comprobaciones es deliberado:
- Tamaño (máximo 10 MB) y tipo real, mirando los primeros bytes del fichero. Un
.exerenombrado a.pdfse rechaza. - Número de páginas sin leer el contenido. En PDF, del índice del documento; en DOCX, de los metadatos que guarda Word.
- Bombas zip en
.docx: límite de tamaño descomprimido y de número de entradas. - Solo entonces, la extracción, en un proceso aparte con límite de tiempo (25 segundos). Si un fichero malicioso cuelga el parser, se mata ese proceso y la web sigue funcionando.
Para todo, incluido el texto pegado, hay una equivalencia de 500 palabras por página: 2.500 palabras como máximo. Los ficheros nunca se guardan: se extrae el texto en un directorio temporal que se borra al terminar.
Las librerías: python-docx para Word, markdown-it-py para quedarse solo con el texto de un Markdown (sin HTML incrustado) y pypdf para PDF, en la versión 6.21.2 porque las anteriores tenían vulnerabilidades conocidas para colgar el servidor con PDFs maliciosos. Para los .doc antiguos hace falta LibreOffice, que cuento en la entrada de infraestructura.
Las pruebas encontraron fallos que no habría visto leyendo el código: los párrafos de los .docx salían pegados, un .txt en Windows-1252 se leía como basura porque se interpretaba como UTF-16, y un <script> dentro de un .md llegaba como texto a la corrección.
Paginar sin partir correcciones
Si el resultado ocupa varias páginas, se ve por páginas en lugar de un muro de texto. La regla: solo se corta en finales de párrafo o de frase, y nunca dentro de una anotación. Si una estructura larga cruzara el corte, el corte se desplaza; mejor una página algo más larga que una corrección partida. Un test genera 300 anotaciones al azar y comprueba que ninguna queda cortada.
Notas del corrector: un editor que no ejecuta nada
Debajo del texto hay un desplegable para que el profesor deje sus propias notas: decisiones de corrección, comentarios para la siguiente clase... Pedí un editor moderno pero seguro, y que no reinventáramos nada.
La solución tiene varias capas:
- Editor conocido: Quill 2. No se carga desde un CDN: se descarga el paquete oficial de npm, se verifica su firma de integridad y se sirve desde el propio proyecto.
- Lista cerrada de formatos: negrita, cursiva, subrayado, tachado, listas, citas y enlaces. Imágenes, vídeo y ficheros (también los pegados o arrastrados) están desactivados.
- No se guarda HTML. Se guarda el Delta de Quill, una lista de operaciones de texto con atributos, y el servidor lo valida con Pydantic con
extra="forbid". Cualquier cosa fuera de la lista rechaza el documento entero. Los enlaces solo pueden serhttp,httpsomailto; unojavascript:pierde el enlace y conserva el texto. - Content-Security-Policy en toda la aplicación: aunque algo se colara, el navegador no ejecutaría scripts que no sean los propios.
Un detalle que me pareció muy buena práctica: durante la implementación apareció que Quill 2.0.3 tiene publicada una vulnerabilidad XSS (GHSA-v3m3-f69x-jf25) en su exportación a HTML, sin versión corregida. Como el diseño no guarda ni exporta HTML, no nos afecta, y quedó documentado junto al código del editor para revisarlo cuando salga una versión nueva.
Se probaron 36 ataques en tests (scripts, onerror, javascript: en varias formas, data:, embeds, atributos inventados, tamaños, acceso desde otro usuario, falta de CSRF…) y también en un navegador real pegando <img onerror>, <script> e <iframe>. No se ejecutó nada, con CSP y sin ella.
Compartir con enlace y PIN
Quería que un profesor pudiera mandar una corrección a un alumno sin que este tuviera cuenta. El diseño:
- Enlace:
/c/<identificador>/, con un identificador desecrets.token_urlsafe(16). Son 128 bits de azar en 22 caracteres: igual de imposible de adivinar que un UUID pero más corto, y con un índice único en base de datos, así que la búsqueda es inmediata. - PIN: 6 caracteres de un alfabeto sin los que se confunden al dictarlos (0/O, 1/I/L). Unos 887 millones de combinaciones, y da igual teclearlo en mayúsculas o minúsculas.
- Fuerza bruta: tras 5 intentos fallidos, ese enlace se bloquea 15 minutos (incluso para el PIN correcto). La comparación es en tiempo constante.
- Las notas del profesor nunca llegan a la página pública. No es que estén ocultas: la vista pública no recibe el objeto de la corrección, solo una lista blanca de campos. Un test pone un marcador en las notas y comprueba que no aparece en la respuesta, ni tampoco el PIN ni los datos del profesor.
- Las páginas públicas no se indexan ni se guardan en caché, y el profesor puede regenerar enlace y PIN para invalidar los anteriores.
Las correcciones que ya existían necesitaban su enlace y su PIN, así que hubo que hacer un backfill. La migración sigue el patrón habitual de Django en tres pasos: añade los campos admitiendo vacíos, rellena cada fila con valores propios (RunPython) y después los hace obligatorios y únicos. Un test ejecuta la migración real sobre 30 filas creadas con el esquema anterior.
Un fallo de los propios tests que me hizo gracia: "Valoración" aparece escrita como Valoración dentro del JSON de la página, así que tres comprobaciones del tipo "esto no aparece" habrían pasado aunque se filtrara. Un test que no puede fallar no sirve de nada.
Contacto y textos legales
El formulario de contacto envía los mensajes por SMTP, pero primero los guarda en la base de datos: si el correo falla, el mensaje no se pierde. Lleva el remitente como Reply-To para responder directamente. Contra el spam no hay captcha: un campo trampa invisible, rechazo de envíos en menos de 3 segundos (con la hora de carga firmada para que no se pueda falsificar) y un máximo de 10 mensajes al día por red, guardando solo la IP truncada.
Los textos legales (aviso legal, privacidad, cookies y términos) describen lo que la aplicación hace de verdad según el código. Redactarlos destapó una contradicción: la landing cargaba las fuentes desde Google Fonts, lo que enviaba la IP de cada visitante a Google. Las fuentes pasaron a servirse desde la propia aplicación y la CSP dejó de permitir fuentes externas. Así, "no enviamos datos a terceros, salvo los textos a Anthropic para corregirlos" es verdad.
En la próxima entrada: cómo se despliega todo esto en Dokploy reutilizando un PostgreSQL y un Redis que ya comparten otros proyectos.