English · Español
Lab 03 — Notas inline ancladas a párrafos, más el journal diario¶
🇪🇸 La página de teoría se vuelve interactiva: cada párrafo puede recibir una nota personal sin abandonar la lectura. Y al final del día, una entrada en el journal — un único archivo por día, append-only. La regla: el contenido de los usuarios pasa siempre por el sanitizer de Phase 37 antes de aparecer en pantalla.
Objetivo¶
Renderizar cada página de theory/lab con anclas estables por párrafo. Junto a cada párrafo anclado, exponer un widget de "nota" inline que abre un editor SimpleMDE en la página, deja al usuario escribir Markdown, y persiste la nota indexada por (student_id, page_id, anchor_id). Una vista "Notes" separada lista todas las notas ordenables por tag, fase y fecha. El Journal diario es un archivo libre por día indexado por fecha (una fila por día, agregada dentro del día). Todo el renderizado de Markdown del lado del servidor vía markdown-it-py + el sanitizer de la Fase 37.
Por qué existe este lab¶
El log de tres capas de CLAUDE.md (§A4) vive en learners/<u>/journal/. El lab 03 convierte el journal diario en un artefacto nativo del portal en vez de un archivo que el learner tiene que recordar abrir. La función de nota inline es la "captura en contexto" que el perfil de Borja (learners/borja/profile.md) marca como la función más echada de menos al leer teoría densa: para cuando cambias a un archivo de notas separado has perdido el hilo de pensamiento.
El XSS almacenado es el riesgo agazapado. Las notas y entradas de journal son generadas por el usuario, renderizadas a otros visualizadores admin, y almacenadas en la DB. Saltarse el sanitizer de la Fase 37 aquí permitiría a un learner inyectar un script que se dispara en el dashboard de admin del profesor. Los tests del lab dejan esto clavado explícitamente.
Prerrequisitos¶
- Labs 00–02 hechos.
- Módulo sanitizer de la Fase 37 en
src/sanitizer/exponiendosanitize_html(rendered_html) -> str. markdown-it-pyya en el lockfile de la Fase 37; si no,uv add markdown-it-py.
Entregables¶
- Migración alembic creando la tabla
notes:(id PK, student_id, page_id, anchor_id, body_md, tags TEXT[], created_at, updated_at). - Migración alembic creando la tabla
journal_entries:(student_id, day DATE, body_md, updated_at), PK(student_id, day). src/miniportal/markdown.py—render(md: str) -> strllamando a markdown-it + el sanitizer.src/miniportal/anchors.py—anchor_id(page_id: str, heading: str, paragraph_index: int) -> str(determinista, slug-estable).src/miniportal/routes/notes.py— endpoints REST (GET/POST/PUT/DELETE /notes/...) + vista de listaGET /notes.src/miniportal/routes/journal.py—GET /journal,GET /journal/{date},POST /journal/{date}.src/miniportal/static/portal.js— JS vanilla mínimo para abrir el editor inline (sin framework).src/miniportal/templates/notes_list.html.jinja,journal_entry.html.jinja,inline_note_widget.html.jinja.tests/portal/test_note_anchor_persistence.py.tests/portal/test_journal_append_idempotency.py.tests/portal/test_markdown_xss_sanitizer.py.
Paso 1 — Anclas estables por párrafo¶
# src/miniportal/anchors.py
import re, hashlib
_SLUG_RE = re.compile(r"[^a-z0-9]+")
def slugify(text: str) -> str:
raise NotImplementedError("Lab 03 step 1 — lowercase, collapse runs of non-alnum to '-', strip leading/trailing '-'.")
def anchor_id(page_id: str, heading: str, paragraph_index: int) -> str:
"""Return a deterministic anchor id of the form:
<page_id>::<heading_slug>::p<paragraph_index>
Properties required:
1. Pure function — same inputs → same output, always.
2. Stable across heading renames *of unrelated* sections.
3. Renaming THIS heading invalidates the anchor (notes need re-anchoring).
4. Short enough to embed in an HTML id (<=128 chars).
"""
raise NotImplementedError("Lab 03 step 1 — compose the three parts; assert length <= 128.")
La propiedad 3 es intencional: si el currículo renombra una cabecera de sección, las notas adheridas a ella deben marcarse (no seguir silenciosamente) para que el learner revise si la nota todavía aplica. Este lab añade un reporte de "notas huérfanas" más adelante.
Paso 2 — Render Markdown → sanitize¶
# src/miniportal/markdown.py
from markdown_it import MarkdownIt
from sanitizer import sanitize_html # Phase 37 module
_MD = MarkdownIt("commonmark", {"linkify": True, "html": False})
def render(body_md: str) -> str:
"""Markdown -> HTML -> Phase 37 sanitizer -> safe HTML.
`html: False` is the FIRST line of defense (no raw HTML in input);
the sanitizer is the second. Both must be on.
"""
raise NotImplementedError("Lab 03 step 2 — pass body through markdown-it then sanitize_html.")
La regla de dos defensas (html: False Y sanitizer) es un principio de la Fase 37 re-aplicado. Quitar cualquiera de las dos hace fallar el test de XSS.
Paso 3 — Endpoints de notes¶
# src/miniportal/routes/notes.py
from fastapi import APIRouter, Depends, Form
router = APIRouter(prefix="/notes")
@router.get("")
async def list_notes(
student = Depends(current_student),
tag: str | None = None,
phase: int | None = None,
):
"""Render notes_list.html, sortable by tag/phase/date. Default sort: date desc."""
raise NotImplementedError("Lab 03 step 3 — query notes, render template.")
@router.post("")
async def create_note(
page_id: str = Form(...),
anchor_id: str = Form(...),
body_md: str = Form(...),
tags: str = Form(""),
student = Depends(current_student),
):
"""`tags` is comma-separated; persisted as TEXT[] (or JSON if SQLite).
Validation:
- page_id matches /^[a-z0-9-/]+$/
- anchor_id matches the format from step 1
- body_md length <= 32 KB
- tags: each tag matches /^[a-z][a-z0-9-]{0,30}$/, max 8 tags
Sanitize tags before persistence."""
raise NotImplementedError("Lab 03 step 3 — validate, insert, return 201 with the rendered HTML.")
@router.put("/{note_id}")
async def update_note(note_id: int, body_md: str = Form(...), student = Depends(current_student)):
raise NotImplementedError("Lab 03 step 3 — owner check, update, return rendered HTML.")
@router.delete("/{note_id}")
async def delete_note(note_id: int, student = Depends(current_student)):
raise NotImplementedError("Lab 03 step 3 — owner check, soft-delete (set deleted_at), return 204.")
Las notas son propiedad de exactamente un estudiante. Incluso los admins no editan las notas de otros learners a través de este endpoint — solo las leen en la vista de admin (lab 05). El CRUD es de un solo propietario.
Paso 4 — Widget inline (JS vanilla)¶
src/miniportal/static/portal.js (objetivo ≤ 100 líneas):
// Locate every <p data-anchor-id="..."> ; on hover, show a "+ note" affordance.
// On click, open a SimpleMDE textarea in a small dialog overlay.
// On save, POST to /notes; on success, swap in the rendered HTML inline.
// All requests carry the CSRF token from the meta tag.
(function () {
// raise NotImplementedError equivalent: leave a `throw` so the missing impl is loud at runtime.
throw new Error("Lab 03 step 4 — implement the inline note widget (no framework).");
})();
Restricción: sin dependencia de framework. SimpleMDE se trae vía un único <link> + <script> desde static/vendor/ (vendored una vez, commiteado al repo — no cargado desde CDN; regla de supply-chain de security/supply-chain.md).
Paso 5 — El journal¶
# src/miniportal/routes/journal.py
from datetime import date as date_t
from fastapi import APIRouter, Depends, Form, HTTPException
router = APIRouter(prefix="/journal")
@router.get("")
async def journal_index(student = Depends(current_student)):
raise NotImplementedError("Lab 03 step 5 — render the list of (date, snippet) for this student.")
@router.get("/{day}")
async def journal_show(day: date_t, student = Depends(current_student)):
raise NotImplementedError("Lab 03 step 5 — render the day's entry; 404 if none.")
@router.post("/{day}")
async def journal_upsert(day: date_t, body_md: str = Form(...), student = Depends(current_student)):
"""Append-only WITHIN a day:
- If no row exists for (student, day): INSERT.
- If a row exists: APPEND body_md to the existing body with a separator.
--- 14:32 ---
...new content...
- Idempotency: if the submitted body_md matches the last appended block byte-for-byte
(modulo trailing whitespace) within the last 60 seconds, treat as no-op and return 200.
Editing PAST days is forbidden: day < today returns 409 Conflict.
"""
raise NotImplementedError("Lab 03 step 5 — upsert with append + idempotency window + past-day guard.")
La regla "append-only dentro de un día, sin editar días pasados" espeja CLAUDE.md §3. El portal la impone porque Borja quería explícitamente que el journal se sintiera como una bitácora, no un wiki.
Paso 6 — Reporte de notas huérfanas¶
Una nota queda huérfana cuando su anchor_id ya no existe en el page_id referenciado (la cabecera fue renombrada o el párrafo eliminado). Un barrido diario en segundo plano marca tales notas:
# src/miniportal/services/notes_sweeper.py
def find_orphans(db, anchors_by_page: dict[str, set[str]]) -> list[int]:
raise NotImplementedError("Lab 03 step 6 — return ids of notes whose anchor_id is not in anchors_by_page[page_id].")
El lab 05 saca estas en el dashboard de admin; para el lab 03 solo escribimos el sweeper + un test.
Paso 7 — Tests¶
# tests/portal/test_note_anchor_persistence.py
def test_note_round_trip():
raise NotImplementedError("Create a note via POST /notes; GET it back; assert body_md and rendered HTML match.")
def test_anchor_id_is_deterministic():
raise NotImplementedError("anchor_id('p/theory/01', 'Why caches', 2) called twice -> equal.")
def test_anchor_changes_when_heading_renamed():
raise NotImplementedError("Different heading text -> different anchor_id (notes 'detach' on rename).")
# tests/portal/test_journal_append_idempotency.py
def test_first_entry_inserts():
raise NotImplementedError("POST /journal/<today> creates one row.")
def test_second_entry_same_day_appends_with_separator():
raise NotImplementedError("Second POST appends; body contains '---' separator + two timestamps.")
def test_same_content_within_window_is_noop():
raise NotImplementedError("POST identical body_md twice within 60s -> only one append.")
def test_past_day_edit_rejected():
raise NotImplementedError("POST /journal/<yesterday> -> 409.")
# tests/portal/test_markdown_xss_sanitizer.py
def test_script_tag_stripped():
raise NotImplementedError("Submit body_md='<script>alert(1)</script>'; assert rendered HTML contains no <script>.")
def test_javascript_url_stripped():
raise NotImplementedError("Submit a Markdown link [click](javascript:alert(1)); rendered href does not contain 'javascript:'.")
def test_onerror_attribute_stripped():
raise NotImplementedError("Submit ' <img src=x onerror=alert(1)>'; rendered HTML has no onerror.")
def test_safe_markdown_preserved():
raise NotImplementedError("Submit normal **bold** and `code`; rendered HTML contains <strong> and <code>.")
Cómo es "done"¶
- Las migraciones se aplican.
-
anchor_ides determinista y de longitud acotada. - El CRUD de notas hace ida y vuelta; comprobación de propietario impuesta; el soft-delete funciona.
- El append-only-en-día del journal funciona; las ediciones de días pasados se rechazan; la ventana de idempotencia se verifica.
- Los tres tests XSS pasan; el sanitizer se invoca desde un único helper, no duplicado.
-
mypy --strictybanditlimpios para los nuevos módulos. - SimpleMDE vendored bajo
src/miniportal/static/vendor/— sin carga por CDN. - El sweeper de notas huérfanas tiene un test unitario; la lista resultante es correcta en una fixture seedeada.
Trampas comunes¶
- Cargar SimpleMDE desde CDN. Un CDN comprometido implica inyección de script en cada renderizado de página. Vendorízalo; fija el hash.
html: Trueen markdown-it. Un solo cambio de carácter y tu sanitizer es la única defensa — y la primera vez que tenga un bug, despachas un XSS almacenado. Ambas defensas activas, ambas testeadas.- Calcular anchor_id en el cliente en tiempo de render. El servidor debe calcular y emitir la ancla en el HTML renderizado. Calcularla en JS permite al cliente fabricar anclas y crear notas contra páginas que nunca vio.
- Dejar
tagslibre. Tags sin restricciones se convierten en una pesadilla de UI y un vector de XSS almacenado si se renderizan en bruto. Restringe forma, conteo máximo, sanitiza. - Permitir ediciones de días pasados del journal porque "es solo un off-by-one". No lo es. La regla append-only-en-día es el contrato; si Borja la relaja, el journal deja de ser bitácora y se vuelve wiki — y su rastro de auditoría se pudre.
- Ventana de idempotencia demasiado larga. Una ventana de 5 minutos significa que un "tuve dos pensamientos distintos en tres minutos" legítimo se colapsa. 60 s es el default del lab; ajusta según feedback de uso.
Siguiente: lab/04-quizzes-exams-and-replay.md — YAML de quiz, scoring, revisión SM-2.