Estructura del Proyecto
nvito-invitations es una aplicación Next.js 16 mínimalista cuyo único proposito es servir las invitaciones públicas generadas por el sistema. Su diseño prioriza rendimiento y tiempo de carga, manteniendo las dependencias al mínimo absoluto.
Agnostico al origen: La aplicación renderiza invitaciones de forma identica sin importar si fueron creadas con el motor de templates de Nvito (INTERNAL) o subidas como HTML externo (EXTERNAL). La distincion de source solo existe en el backend y en nvito-admin; para nvito-invitations, toda invitación publicada es simplemente un htmlUrl que se sirve como HTML puro.
Dependencias de Producción (solo 7)
| Dependencia | Versión | Proposito |
|---|---|---|
next | ^16.1.6 | Framework SSG/ISR |
react | ^19.2.3 | Libreria de UI |
react-dom | ^19.2.3 | Renderizado DOM |
dompurify | ^3.x | Sanitizacion HTML segura |
zod | ^3.x | Validación de respuestas API en runtime |
ua-parser-js | ^2.0.9 | Detección de dispositivo/navegador para analitica |
web-vitals | ^4.x | Metricas de rendimiento web (LCP, INP, CLS) |
Esta arquitectura ultraligera garantiza que la aplicación sea extremadamente rápida y tenga una superficie de ataque mínima.
Estructura de Archivos
Estructura de Directorios
Rutas de la Aplicación
| Ruta | Tipo | Descripción |
|---|---|---|
/ | Estatica | Página principal / landing |
/i/[slug] | SSG + ISR | Invitación pública por slug |
/preview/[token] | Dinamica (SSR) | Vista previa con token JWT |
/api/revalidate | API Route | Webhook para revalidación on-demand |
Flujo SSG + ISR
Las invitaciones públicas utilizan Static Site Generation (SSG) combinado con Incremental Static Regeneration (ISR) para lograr tiempos de carga instantaneos con contenido actualizable.
Flujo SSG + ISR — Invitaciones Publicas
Configuración ISR
La página /i/[slug]/page.tsx configura ISR con los siguientes parametros:
export const revalidate = 3600; // Revalidar cada hora (3600 segundos)
export const dynamic = 'force-static'; // Forzar generación estática
export const dynamicParams = true; // Permitir generación on-demand de nuevas rutas
Generación Estatica
La función generateStaticParams() retorna un array vacio en build time, lo que significa que todas las invitaciones se generan on-demand la primera vez que se visitan. Después de la primera visita, la página queda en cache.
InvitationRenderer
El componente InvitationRenderer es un client component responsable de inyectar de forma segura el HTML, CSS y JS compilado de la invitación en el DOM.
Funcionamiento
- Inyección de CSS: Crea un elemento
<style>en el<head>del documento con atributodata-invitation-slugpara identificacion - Inyección de HTML: Establece el
innerHTMLdel contenedor principal - Inyección de JS (opcional): Crea un elemento
<script>dentro del contenedor - Tracking: Registra page view y activa tracking global al montar
- Cleanup: Al desmontar, remueve los elementos
<style>y<script>inyectados y limpia el tracking global
Props
interface InvitationRendererProps {
html: string; // HTML compilado de la invitación
css: string; // CSS compilado
js?: string; // JS compilado (opcional)
slug: string; // Slug de la invitación
invitationId: string; // ID para tracking de analitica
}
Aislamiento
- El componente usa
suppressHydrationWarningpara evitar errores de hidratacion con contenido dinámico - Los refs (
styleRef,scriptRef) previenen duplicacion de inyección en re-renders - El
viewTrackedRefasegura que el page view se registre solo una vez
Modo Preview para Admin
La ruta /preview/[token] permite a los administradores ver una vista previa de la invitación antes de publicarla. Se accede desde el panel admin mediante un iframe.
Caracteristicas
- Autenticación por token JWT: El token se genera en el backend y tiene expiración
- Sin cache (
cache: 'no-store',dynamic: 'force-dynamic',revalidate: 0) - No indexable:
robots: { index: false, follow: false } - HTML completo: El backend compila el template y devuelve un
fullDocumentHTML completo - Renderizado directo: Usa
dangerouslySetInnerHTMLpara inyectar el documento completo
Flujo de Preview
- Admin abre el editor visual en nvito-admin
- El admin solicita un token de preview al API
- El API genera un JWT con expiración corta
- nvito-admin carga un iframe con
/preview/{token} - nvito-invitations obtiene el HTML compilado del API usando el token
- Se renderiza el documento completo sin cache
Tracking de Analitica
Tipos de Eventos
El sistema de analitica registra 6 tipos de eventos:
| Evento | Descripción | Activación |
|---|---|---|
VIEW | Vista de la página | Automática al cargar |
RSVP_OPEN | Apertura del formulario RSVP | Automática via MutationObserver |
RSVP_SUBMIT | Envio del formulario RSVP | Automática al submit |
LINK_CLICK | Clic en enlace externo | Automática via event listener |
SHARE | Acción de compartir | Via window.NvitoAnalytics |
DOWNLOAD | Descarga de archivo | Via window.NvitoAnalytics |
Datos Recopilados
Cada evento de analitica incluye:
- visitorId: ID único persistido en
localStorage - userAgent: Cadena completa del user agent
- device: Tipo de dispositivo (desktop, mobile, tablet)
- browser: Nombre del navegador
- os: Sistema operativo
- referrer: URL de procedencia
Global Tracking
El módulo global-tracking.ts expone un objeto window.NvitoAnalytics que el JavaScript generado de la invitación puede utilizar para registrar eventos personalizados:
// Disponible dentro del JS de la invitación
window.NvitoAnalytics.trackRsvpOpen(invitationId);
window.NvitoAnalytics.trackShare(invitationId, 'whatsapp');
window.NvitoAnalytics.trackDownload(invitationId, 'pdf');
Auto-tracking
El sistema configura automáticamente:
- Links externos: Detecta clics en
<a>conhrefexterno - Formularios RSVP: Usa
MutationObserverpara detectar cuando un formulario RSVP aparece en el DOM, ysubmitlistener para registrar envios
SEO y Metadata Dinamica
Cada invitación genera metadata única para SEO y redes sociales usando generateMetadata():
Open Graph
openGraph: {
title: `Invitación - ${slug}`,
description: `Te invitamos a nuestro evento especial - ${eventName}`,
type: 'website',
url: `/i/${slug}`,
}
Twitter Card
twitter: {
card: 'summary_large_image',
title: `Invitación - ${slug}`,
description: 'Te invitamos a nuestro evento especial',
}
Robots
Las invitaciones publicadas son indexables: robots: { index: true, follow: true }.
Las vistas de preview no son indexables: robots: { index: false, follow: false }.
Webhook de Revalidación
Cuando un administrador pública o actualiza una invitación, el API envia un webhook a nvito-invitations para invalidar el cache ISR y regenerar la página.
Webhook de Revalidacion ISR
Seguridad del Webhook
- Timing-safe comparison: Se usa
crypto.timingSafeEqual()para comparar el secreto, previniendo timing attacks que podrian inferir el secreto caracter por caracter midiendo tiempos de respuesta - Validación Zod: El body se valida con
revalidateRequestSchema(paths como array de strings con regex, slug con regex y max 200 chars) viasafeParse()en lugar de validación manual - Rate limiting: Máximo 10 requests por ventana de tiempo; el request 11 retorna 429
- Metodo HTTP: Solo acepta POST; GET retorna 405
- Logging detallado: Cada revalidación exitosa o fallida se registra en logs
- Respuestá estructurada: Retorna timestamp, slug y resultado por cada path
Estrategia de Cache
Niveles de Cache
| Nivel | TTL | Proposito |
|---|---|---|
| ISR (Next.js) | 3600s (1 hora) | Cache de página generada estáticamente |
| Fetch API metadata | revalidate: 3600 | Cache de metadata de la invitación |
| Fetch HTML/CSS/JS | revalidate: 3600 | Cache de archivos compilados del CDN |
| Revalidación on-demand | Instantanea | Via webhook POST /api/revalidate |
Headers HTTP Esperados
Para los archivos servidos desde el CDN (R2/MinIO):
Cache-Control: public, max-age=3600 # HTML (1 hora)
Cache-Control: public, max-age=31536000 # CSS y JS (1 ano, inmutable)
Regeneración
La página se regenera en dos escenarios:
- Automática: Cuando el TTL de 1 hora expira y llega una nueva solicitud
- On-demand: Cuando el API envia el webhook de revalidación al publicar una invitación
Headers de Seguridad
Headers HTTP Configurados
| Header | Valor | Protección |
|---|---|---|
Content-Security-Policy | frame-ancestors 'self' {admin-domains} | Previene clickjacking: solo el panel admin puede cargar invitaciones en iframes |
Strict-Transport-Security | max-age=31536000; includeSubDomains | Fuerza HTTPS durante 1 ano, incluyendo subdominios |
Permissions-Policy | camera=(), microphone=(), geolocation=(), interest-cohort=() | Deshabilita APIs innecesarias del navegador y FLoC tracking |
X-Content-Type-Options | nosniff | Previene MIME type sniffing |
X-Frame-Options | SAMEORIGIN | Protección adicional contra clickjacking |
Prevencion XSS en Analytics Script
Las invitaciones públicas inyectan un script de analitica con variables dinámicas (invitationId, API_URL). Para prevenir inyección XSS:
escapeJsString(): Función dedicada que escapa',",\,</script>, newlines y caracteres Unicode peligrosos antes de inyectarlos en el<script>tag- Vectores neutralizados: Payloads como
'; alert(1); //o</script><script>malicious()son escapados antes de renderizar
Validación SSRF de URLs CDN
La función validateCdnUrl() valida que las URLs de archivos HTML/CSS/JS apunten exclusivamente a hosts CDN permitidos (R2/MinIO). Esto previene ataques SSRF donde un atacante podria manipular URLs para redirigir requests a servicios internos.
Sanitizacion HTML con DOMPurify
El InvitationRenderer sanitiza el HTML inyectado con DOMPurify:
- Whitelist explicita de atributos
data-*permitidos viaADD_ATTR(sinALLOW_DATA_ATTR: trueque permitiriadata-*arbitrarios) - Extracción de fuentes: Las URLs de Google Fonts se extraen e inyectan via
<link>tags seguros en lugar de ejecutar el CSS directamente
Consideraciones Adicionales
- El modo preview usa
force-dynamicpara evitar servir contenido obsoleto - Las invitaciones no publicadas (status !==
PUBLISHED) retornan 404 - Slugs con caracteres especiales o longitud > 200 caracteres retornan 404
Scripts de Desarrollo
# Desarrollo local (puerto 3001)
npm run dev
# Cambiar ambiente
npm run env:dev # Desarrollo local
npm run env:dev-remote # API remota desarrollo
npm run env:test-remote # API remota testing
# Build de producción
npm run build
# Desarrollo con Bitwarden Secrets Manager
npm run dev:bws
Variables de Entorno
| Variable | Descripción | Default |
|---|---|---|
NEXT_PUBLIC_API_URL | URL base del API | http://localhost:3000 |
NEXT_PUBLIC_CDN_URL | URL del CDN para archivos estáticos | http://localhost:9000 |
REVALIDATE_SECRET | Secreto compartido para webhook de revalidación | (requerido) |
Arquitectura SOLID
El proyecto sigue principios SOLID, especialmente Single Responsibility Principle (SRP). Cada archivo tiene una única razon de cambio.
Descomposición del Route Handler
El route handler /i/[slug]/route.ts originalmente mezclaba 10+ responsabilidades en 158 lineas. Fue descompuesto en servicios con responsabilidad unica:
| Servicio | Archivo | Responsabilidad |
|---|---|---|
invitation-fetcher | lib/services/invitation-fetcher.ts | Fetch de metadata desde API + validación Zod |
html-processor | lib/services/html-processor.ts | Fetch de HTML/CSS/JS desde CDN + inyección de meta tags |
cdn-validator | lib/services/cdn-validator.ts | Validación SSRF de URLs CDN contra whitelist de hosts |
analytics-script | lib/services/analytics-script.ts | Generación del <script> de analitica con escape XSS |
html-responses | lib/html-responses.ts | Respuestas HTML de error reutilizables (404, error generico) |
El route handler quedo como orquestador limpio de ~35 lineas que delega a cada servicio.
Descomposición del InvitationRenderer
| Servicio | Archivo | Responsabilidad |
|---|---|---|
font-injector | lib/dom/font-injector.ts | Extracción de URLs de Google Fonts e inyección via <link> tags |
html-sanitizer | lib/dom/html-sanitizer.ts | Sanitizacion DOMPurify con configuración centralizada |
Testing
El proyecto cuenta con 16 suites de prueba y 180 tests individuales, todos con 100% de tasa de exito.
Cobertura por Area
| Area | Suites | Que prueban |
|---|---|---|
| Route handlers | 3 | /i/[slug], /preview/[token], /api/revalidate |
| Servicios SOLID | 4 | invitation-fetcher, html-processor, cdn-validator, analytics-script |
| DOM utilities | 2 | font-injector, html-sanitizer |
| Schemas Zod | 1 | Validación de datos de invitación y revalidación |
| Lib utilities | 3 | html-responses, error-reporting, web-vitals |
| Existentes | 3 | Tests previos del proyecto |
Tests de Seguridad
Los tests incluyen vectores de ataque reales:
- Timing attacks: Secrets de longitudes diferentes y contenido diferente
- XSS payloads:
</script><script>alert(1),'; DROP TABLE, comillas en invitationId - SSRF: URLs CDN con hosts no permitidos
- Rate limiting: 11+ requests consecutivos verificando respuesta 429
- Input validation: Slugs con caracteres especiales, longitud > 200 chars
Calidad del Código
| Indicador | Valor |
|---|---|
| TypeScript strict | Habilitado |
any explicitos | 0 |
| JSDoc | 100% en métodos públicos |
| Schemas Zod | Validación en runtime de API responses y webhook body |
| Test suites | 16 |
| Tests individuales | 180 |
| Tasa de exito | 100% |
| Vulnerabilidades criticas | 0 (timing-safe, XSS escaped, SSRF validated, HSTS enabled) |
| Violaciones SRP | 0 (todos los archivos con responsabilidad unica) |
Referencias
- Route handler:
app/i/[slug]/route.ts - Renderer:
app/i/[slug]/InvitationRenderer.tsx - Preview:
app/preview/[token]/route.ts - Revalidación:
app/api/revalidate/route.ts - Servicios:
app/lib/services/ - DOM utilities:
app/lib/dom/ - Schemas:
app/lib/schemas.ts - Analytics:
app/lib/analytics.ts - Global Tracking:
app/lib/global-tracking.ts