From 2ecd052da3d5fccdb5a8488afe60950664d2bf73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Arnulfo=20R=2E=20H=2E?= Date: Sun, 27 Sep 2026 10:08:28 -0500 Subject: [PATCH 1/2] =?UTF-8?q?security:=20observar=20CSP=20de=202026=20y?= =?UTF-8?q?=20externalizar=20c=C3=B3digo=20en=20l=C3=ADnea?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 2026/about.html | 4 +- 2026/convence-a-tu-jefe.html | 58 +------- 2026/css/convence-a-tu-jefe.css | 32 +++++ 2026/css/ponentes.css | 128 ++++++++++++++++++ 2026/css/registro.css | 137 +++++++++++++++++++ 2026/css/style.css | 4 + 2026/css/voluntariado.css | 27 ++++ 2026/js/convence-a-tu-jefe.js | 17 +++ 2026/js/ponentes.js | 64 +++++++++ 2026/js/registro.js | 82 ++++++++++++ 2026/ponentes.html | 198 +--------------------------- 2026/registro.html | 225 +------------------------------- 2026/sedes.html | 4 +- 2026/voluntariado/index.html | 30 +---- netlify.toml | 7 + tests/registro-page.cjs | 6 +- 16 files changed, 517 insertions(+), 506 deletions(-) create mode 100644 2026/css/convence-a-tu-jefe.css create mode 100644 2026/css/ponentes.css create mode 100644 2026/css/registro.css create mode 100644 2026/css/voluntariado.css create mode 100644 2026/js/convence-a-tu-jefe.js create mode 100644 2026/js/ponentes.js create mode 100644 2026/js/registro.js diff --git a/2026/about.html b/2026/about.html index ee5fe5e..9792ad0 100644 --- a/2026/about.html +++ b/2026/about.html @@ -125,10 +125,10 @@

Conexiones

-
+

¿Te interesa participar?

Seguimos trabajando en la programación final, pero ya puedes conocer la edición 2026, la modalidad, la sede y la experiencia del evento.

- diff --git a/2026/convence-a-tu-jefe.html b/2026/convence-a-tu-jefe.html index 5416a11..8eb4d08 100644 --- a/2026/convence-a-tu-jefe.html +++ b/2026/convence-a-tu-jefe.html @@ -27,35 +27,7 @@ - + @@ -126,7 +98,7 @@

22 y 23 de octubre de 2026

-
+

El argumento en una frase: son medio día laboral entre las dos jornadas, sede fija en la ciudad, entrada gratuita, y el equipo vuelve con networking, capacitación en Python e Inteligencia Artificial, y nuevos contactos profesionales.

@@ -163,7 +135,7 @@

Contenido aplicable al trabajo

-
+

¿Y si mandas a más de una persona?

Enviar a varias personas del equipo puede salir más rentable para la empresa que la salida de una sola persona por el día completo: el networking, el aprendizaje y la visibilidad de marca se reparten entre más personas, en lugar de depender de que una sola los aproveche y luego los comparta con el resto.

Así el equipo completo se beneficia, no solo quien asiste.

@@ -237,10 +209,10 @@

Preguntas frecuentes

-
+

¿Te dieron el permiso?

Regístrate para asegurar tu cupo en PyCon Panamá 2026.

- @@ -263,25 +235,7 @@

¿Te dieron el permiso?

- + diff --git a/2026/css/convence-a-tu-jefe.css b/2026/css/convence-a-tu-jefe.css new file mode 100644 index 0000000..41d98a0 --- /dev/null +++ b/2026/css/convence-a-tu-jefe.css @@ -0,0 +1,32 @@ + .plantilla-box { + display: grid; + gap: 1rem; + max-width: 760px; + margin: 0 auto; + } + + .plantilla-box textarea { + min-height: 320px; + font-family: inherit; + line-height: var(--lh-body); + white-space: pre-wrap; + } + + .plantilla-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; + } + + @media (max-width: 600px) { + .plantilla-actions { + flex-direction: column; + align-items: stretch; + } + } + +.prose--narrow-760 { max-width: 760px; margin-inline: auto; } +.hero-card--centered-820 { max-width: 820px; margin-inline: auto; } +.hero-card--centered-720 { max-width: 720px; margin-inline: auto; text-align: center; } +.hero-actions--centered-top { justify-content: center; margin-top: 1rem; } diff --git a/2026/css/ponentes.css b/2026/css/ponentes.css new file mode 100644 index 0000000..28f43a7 --- /dev/null +++ b/2026/css/ponentes.css @@ -0,0 +1,128 @@ + #btnSubmit:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + } + + .registro-layout { + display: grid; + grid-template-columns: 1fr 1.3fr; + gap: clamp(1.5rem, 2vw, 2.5rem); + align-items: start; + } + + .registro-aside, + .registro-panel { + background: var(--surface); + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + padding: clamp(1.5rem, 2vw, 2rem); + } + + .registro-aside { + background: linear-gradient(180deg, rgba(47, 140, 255, 0.08), rgba(255, 255, 255, 0.95)); + } + + .registro-aside ul { + margin: 1rem 0 0; + padding-left: 1.1rem; + color: var(--text-secondary); + } + + .registro-aside li + li { + margin-top: 0.6rem; + } + + .registro-form { + display: grid; + gap: 1.1rem; + } + + .field-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + } + + .field { + display: grid; + gap: 0.45rem; + } + + .field label { + font-weight: 700; + color: var(--text); + } + + .field input, + .field select, + .field textarea { + width: 100%; + padding: 0.8rem 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: #fff; + font: inherit; + color: var(--text); + } + + .field textarea { + min-height: 110px; + resize: vertical; + } + + .checkbox-item input { + width: 1.1rem; + height: 1.1rem; + padding: 0; + margin-top: 0.2rem; + } + + .checkbox-note { + margin: 0; + padding-left: 1.7rem; + color: var(--text-secondary); + font-size: var(--fs-xs); + } + + .field input:focus, + .field select:focus, + .field textarea:focus { + outline: 3px solid rgba(47, 140, 255, 0.18); + border-color: var(--accent); + } + + .checkboxes { + display: grid; + gap: 0.6rem; + } + + .checkbox-item { + display: grid; + grid-template-columns: 1.1rem minmax(0, 1fr); + align-items: flex-start; + gap: 0.6rem; + color: var(--text-secondary); + } + .status-message { + min-height: 1.5rem; + margin-top: 0.25rem; + font-weight: 600; + color: var(--accent); + } + + .status-message.error { + color: #d9534f; + } + + .status-message.success { + color: #28a745; + } + + @media (max-width: 768px) { + .registro-layout, + .field-grid { + grid-template-columns: 1fr; + } + } diff --git a/2026/css/registro.css b/2026/css/registro.css new file mode 100644 index 0000000..48fd7f1 --- /dev/null +++ b/2026/css/registro.css @@ -0,0 +1,137 @@ + .registro-layout { + display: grid; + grid-template-columns: 1fr 1.3fr; + gap: clamp(1.5rem, 2vw, 2.5rem); + align-items: start; + } + + .registro-aside, + .registro-panel { + background: var(--surface); + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + padding: clamp(1.5rem, 2vw, 2rem); + } + + .registro-aside { + background: linear-gradient(180deg, rgba(47, 140, 255, 0.08), rgba(255, 255, 255, 0.95)); + } + + .registro-aside ul { + margin: 1rem 0 0; + padding-left: 1.1rem; + color: var(--text-secondary); + } + + .registro-aside li + li { + margin-top: 0.6rem; + } + + .registro-form { + display: grid; + gap: 1.1rem; + } + + .field-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + } + + .field { + display: grid; + gap: 0.45rem; + } + + fieldset.field { + min-width: 0; + margin: 0; + padding: 0; + border: 0; + } + + .field label, + .field legend { + font-weight: 700; + color: var(--text); + } + + .field-hint { + margin: 0; + color: var(--text-secondary); + font-size: var(--fs-xs); + } + + .field input, + .field select, + .field textarea { + width: 100%; + padding: 0.8rem 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: #fff; + font: inherit; + color: var(--text); + } + + .field textarea { + min-height: 110px; + resize: vertical; + } + + .field input:focus, + .field select:focus, + .field textarea:focus { + outline: 3px solid rgba(47, 140, 255, 0.18); + border-color: var(--accent); + } + + .checkboxes { + display: grid; + gap: 0.6rem; + } + + .checkbox-item { + display: grid; + grid-template-columns: 1.1rem minmax(0, 1fr); + align-items: flex-start; + gap: 0.6rem; + color: var(--text-secondary); + } + + .checkbox-item input { + width: 1.1rem; + height: 1.1rem; + padding: 0; + margin-top: 0.2rem; + } + + .checkbox-note { + margin: 0; + padding-left: 1.7rem; + color: var(--text-secondary); + font-size: var(--fs-xs); + } + + .status-message { + min-height: 1.5rem; + margin-top: 0.25rem; + font-weight: 600; + color: var(--accent); + } + + .status-message.error { + color: #d9534f; + } + + .status-message.success { + color: #28a745; + } + + @media (max-width: 768px) { + .registro-layout, + .field-grid { + grid-template-columns: 1fr; + } + } diff --git a/2026/css/style.css b/2026/css/style.css index af2a2b7..7e6266f 100644 --- a/2026/css/style.css +++ b/2026/css/style.css @@ -1187,3 +1187,7 @@ a.sponsor-card:focus-visible { .sponsor-empty > * + * { margin-top: var(--space-3); } + +/* Variantes de composición antes declaradas como estilos en atributos HTML. */ +.hero-card--centered-900 { max-width: 900px; margin-inline: auto; } +.hero-actions--top { margin-top: 1rem; } diff --git a/2026/css/voluntariado.css b/2026/css/voluntariado.css new file mode 100644 index 0000000..5ac8920 --- /dev/null +++ b/2026/css/voluntariado.css @@ -0,0 +1,27 @@ + .volunteer-layout { display: grid; grid-template-columns: 1fr 1.35fr; gap: clamp(1.5rem, 2vw, 2.5rem); align-items: start; } + .volunteer-aside, .volunteer-panel { background: var(--surface); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); box-shadow: var(--shadow-md); padding: clamp(1.5rem, 2vw, 2rem); } + .volunteer-aside { background: linear-gradient(180deg, rgba(47, 140, 255, 0.08), rgba(255, 255, 255, 0.95)); } + .volunteer-window { margin: 0.75rem 0 0; padding: 0.9rem 1rem; border-radius: var(--radius-sm); background: rgba(47, 140, 255, 0.12); border: 1px solid var(--border-soft); color: var(--text); } + .volunteer-aside ul { margin: 1rem 0 0; padding-left: 1.1rem; color: var(--text-secondary); } + .volunteer-aside li + li { margin-top: 0.6rem; } + .volunteer-form, .field, .checkboxes { display: grid; } + .volunteer-form { gap: 1.15rem; } + .field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } + .field, .checkboxes { gap: 0.5rem; } + .field > label, .group-label { font-weight: 700; color: var(--text); } + .field input, .field select, .field textarea { width: 100%; padding: 0.8rem 0.9rem; border: 1px solid var(--border); border-radius: var(--radius-sm); background: #fff; font: inherit; color: var(--text); } + .field textarea { min-height: 105px; resize: vertical; } + .field input:focus, .field select:focus, .field textarea:focus { outline: 3px solid rgba(47, 140, 255, 0.18); border-color: var(--accent); } + fieldset { margin: 0; padding: 0; border: 0; } + fieldset legend { margin-bottom: 0.55rem; } + .checkbox-item { display: grid; grid-template-columns: 1.1rem minmax(0, 1fr); align-items: flex-start; gap: 0.65rem; color: var(--text-secondary); } + .checkbox-item input { width: 1.1rem; height: 1.1rem; margin-top: 0.2rem; } + .checkbox-note { margin: 0; padding-left: 1.75rem; color: var(--text-secondary); font-size: var(--fs-xs); } + .role-categories { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.8rem; } + .role-category { padding: 1rem; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); background: rgba(47, 140, 255, 0.04); } + .role-category .checkbox-item { color: var(--text); font-weight: 700; } + .status-message { min-height: 1.5rem; margin: 0.25rem 0 0; font-weight: 600; color: var(--accent); } + .status-message.error { color: #b42318; } + .status-message.success { color: #18763b; } + @media (max-width: 900px) { .role-categories { grid-template-columns: 1fr; } } + @media (max-width: 768px) { .volunteer-layout, .field-grid { grid-template-columns: 1fr; } } diff --git a/2026/js/convence-a-tu-jefe.js b/2026/js/convence-a-tu-jefe.js new file mode 100644 index 0000000..232f7d8 --- /dev/null +++ b/2026/js/convence-a-tu-jefe.js @@ -0,0 +1,17 @@ + const btnCopiar = document.getElementById('btnCopiarPlantilla'); + const plantilla = document.getElementById('plantillaCorreo'); + const copiarStatus = document.getElementById('copiarStatus'); + + btnCopiar.addEventListener('click', async function () { + try { + await navigator.clipboard.writeText(plantilla.value); + copiarStatus.className = 'status-message success'; + copiarStatus.textContent = 'Plantilla copiada. Ya puedes pegarla en tu correo.'; + } catch (err) { + plantilla.removeAttribute('readonly'); + plantilla.focus(); + plantilla.select(); + copiarStatus.className = 'status-message error'; + copiarStatus.textContent = 'No pudimos copiarla automáticamente. Está seleccionada: usa Ctrl+C o Cmd+C.'; + } + }); diff --git a/2026/js/ponentes.js b/2026/js/ponentes.js new file mode 100644 index 0000000..91ed995 --- /dev/null +++ b/2026/js/ponentes.js @@ -0,0 +1,64 @@ + const form = document.getElementById('speakerForm'); + const statusMessage = document.getElementById('statusMessage'); + const btnSubmit = document.getElementById('btnSubmit'); + + form.addEventListener('submit', async function (event) { + event.preventDefault(); + + if (btnSubmit.disabled) return; + + if (!form.checkValidity()) { + statusMessage.className = 'status-message error'; + // Si lo único que falta es la casilla del Código de Conducta, se dice; + // si falta algo más, el mensaje genérico y reportValidity() lleva el + // foco al primer campo con problema. + if (!document.getElementById('consent_coc').checked) { + statusMessage.textContent = 'Para enviar tu propuesta tienes que aceptar el Código de Conducta.'; + } else if (!document.getElementById('consent_privacy').checked) { + statusMessage.textContent = 'Para enviar tu propuesta tienes que aceptar el Aviso de Privacidad.'; + } else { + statusMessage.textContent = 'Revisa los campos obligatorios del formulario.'; + } + form.reportValidity(); + return; + } + + btnSubmit.disabled = true; + btnSubmit.textContent = 'Enviando propuesta...'; + statusMessage.className = 'status-message'; + statusMessage.textContent = 'Procesando tu propuesta...'; + + const datos = { + nombre: document.getElementById('nombre').value.trim(), + email: document.getElementById('email').value.trim(), + telefono: document.getElementById('telefono').value.trim(), + organizacion: document.getElementById('organizacion').value.trim(), + bio: document.getElementById('bio').value.trim(), + titulo_propuesta: document.getElementById('titulo_propuesta').value.trim(), + descripcion_propuesta: document.getElementById('descripcion_propuesta').value.trim(), + nivel: document.getElementById('nivel').value, + modalidad: document.getElementById('modalidad').value, + redes_sociales: document.getElementById('redes_sociales').value.trim(), + consent_privacy: document.getElementById('consent_privacy').checked, + consent_coc: document.getElementById('consent_coc').checked + }; + + try { + const res = await PyConSupabase.registrarSpeaker(datos); + + if (res.success) { + statusMessage.className = 'status-message success'; + statusMessage.textContent = `¡Gracias, ${datos.nombre}! Tu propuesta "${datos.titulo_propuesta}" ha sido registrada exitosamente para PyCon Panamá 2026.`; + form.reset(); + } else { + statusMessage.className = 'status-message error'; + statusMessage.textContent = res.friendlyMessage || 'Ocurrió un inconveniente al enviar tu propuesta. Por favor intenta de nuevo.'; + } + } catch (err) { + statusMessage.className = 'status-message error'; + statusMessage.textContent = 'Error de conexión. Por favor intenta de nuevo.'; + } finally { + btnSubmit.disabled = false; + btnSubmit.textContent = 'Enviar propuesta de ponencia'; + } + }); diff --git a/2026/js/registro.js b/2026/js/registro.js new file mode 100644 index 0000000..3f9aa06 --- /dev/null +++ b/2026/js/registro.js @@ -0,0 +1,82 @@ + const form = document.getElementById('registroForm'); + const statusMessage = document.getElementById('statusMessage'); + const btnSubmit = document.getElementById('btnSubmit'); + const dayInputs = Array.from(document.querySelectorAll('input[name="dias"]')); + const diaJueves = document.getElementById('dia_jueves'); + const modalidadJuevesField = document.getElementById('modalidadJuevesField'); + const modalidadJuevesInputs = Array.from(document.querySelectorAll('input[name="modalidad_jueves"]')); + + function syncAttendanceFields() { + const hasSelectedDay = dayInputs.some(option => option.checked); + dayInputs[0].setCustomValidity(hasSelectedDay ? '' : 'Selecciona al menos un día.'); + + modalidadJuevesField.hidden = !diaJueves.checked; + modalidadJuevesInputs.forEach(option => { + option.required = diaJueves.checked; + if (!diaJueves.checked) option.checked = false; + }); + } + + dayInputs.forEach(option => option.addEventListener('change', syncAttendanceFields)); + syncAttendanceFields(); + + form.addEventListener('submit', async function (event) { + event.preventDefault(); + syncAttendanceFields(); + + if (!form.checkValidity()) { + statusMessage.className = 'status-message error'; + if (!dayInputs.some(option => option.checked)) { + statusMessage.textContent = 'Selecciona al menos un día para asistir.'; + } else if (diaJueves.checked && !modalidadJuevesInputs.some(option => option.checked)) { + statusMessage.textContent = 'Indica cómo participarás el jueves 22.'; + } else if (!document.getElementById('consent_coc').checked) { + statusMessage.textContent = 'Para registrarte tienes que aceptar el Código de Conducta.'; + } else if (!document.getElementById('consent_privacy').checked) { + statusMessage.textContent = 'Para registrarte tienes que aceptar el Aviso de Privacidad.'; + } else { + statusMessage.textContent = 'Revisa los campos obligatorios del formulario.'; + } + form.reportValidity(); + return; + } + + btnSubmit.disabled = true; + btnSubmit.textContent = 'Enviando registro...'; + statusMessage.className = 'status-message'; + statusMessage.textContent = 'Procesando tu registro...'; + + const nombre = document.getElementById('nombre').value.trim(); + const email = document.getElementById('email').value.trim(); + const telefono = document.getElementById('telefono').value.trim(); + const organizacion = document.getElementById('organizacion') ? document.getElementById('organizacion').value.trim() : ''; + const rol = document.getElementById('rol').value; + const dias = dayInputs.filter(option => option.checked).map(option => option.value); + const modalidad_jueves = modalidadJuevesInputs.find(option => option.checked)?.value || ''; + const expectativas = document.getElementById('expectativas').value.trim(); + const accesibilidad = document.getElementById('accesibilidad').value.trim(); + const consent_privacy = document.getElementById('consent_privacy').checked; + const consent_coc = document.getElementById('consent_coc').checked; + + const datos = { nombre, email, telefono, organizacion, rol, dias, modalidad_jueves, expectativas, accesibilidad, consent_privacy, consent_coc }; + + try { + const res = await PyConSupabase.registrarAsistente(datos); + + if (res.success) { + statusMessage.className = 'status-message success'; + statusMessage.textContent = `¡Gracias, ${nombre}! Recibimos tu registro para ${dias.join(' y ')}. Te enviaremos una confirmación por correo.`; + form.reset(); + syncAttendanceFields(); + } else { + statusMessage.className = 'status-message error'; + statusMessage.textContent = res.friendlyMessage || 'Ocurrió un inconveniente al procesar tu registro. Por favor intenta de nuevo.'; + } + } catch (err) { + statusMessage.className = 'status-message error'; + statusMessage.textContent = 'Error de conexión. Por favor intenta de nuevo.'; + } finally { + btnSubmit.disabled = false; + btnSubmit.textContent = 'Enviar registro'; + } + }); diff --git a/2026/ponentes.html b/2026/ponentes.html index 2f077cd..dd7e6c9 100644 --- a/2026/ponentes.html +++ b/2026/ponentes.html @@ -27,136 +27,7 @@ - + @@ -336,72 +207,7 @@

¿Por qué presentar en PyCon?

- + diff --git a/2026/registro.html b/2026/registro.html index 297c5aa..eb03880 100644 --- a/2026/registro.html +++ b/2026/registro.html @@ -27,145 +27,7 @@ - + @@ -342,90 +204,7 @@

PyCon Panamá 2026

- + diff --git a/2026/sedes.html b/2026/sedes.html index dafa233..4d878af 100644 --- a/2026/sedes.html +++ b/2026/sedes.html @@ -126,10 +126,10 @@

Universidad del Istmo · Sede Metromall

-
+

Antes de venir

Si necesitas información de accesibilidad, estacionamiento o cómo llegar a cualquiera de las dos sedes, escríbenos a pyconpanama@gmail.com y te respondemos.

- diff --git a/2026/voluntariado/index.html b/2026/voluntariado/index.html index 3d2dc05..6c1c5db 100644 --- a/2026/voluntariado/index.html +++ b/2026/voluntariado/index.html @@ -27,35 +27,7 @@ - + diff --git a/netlify.toml b/netlify.toml index f3a3ad7..b0e0f3b 100644 --- a/netlify.toml +++ b/netlify.toml @@ -59,6 +59,13 @@ for = "/*" Referrer-Policy = "strict-origin-when-cross-origin" Permissions-Policy = "camera=(), geolocation=(), microphone=()" +# Política inicialmente en observación para la edición activa. Las ediciones +# archivadas conservan recursos distintos y necesitan una revisión separada. +[[headers]] +for = "/2026/*" + [headers.values] + Content-Security-Policy-Report-Only = "default-src 'self'; base-uri 'self'; object-src 'none'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data:; connect-src 'self' https://*.supabase.co; frame-src 'none'; frame-ancestors 'self'; form-action 'self'" + # CSS: caché de una semana. [[headers]] for = "/*.css" diff --git a/tests/registro-page.cjs b/tests/registro-page.cjs index 2276c9b..ae7571f 100644 --- a/tests/registro-page.cjs +++ b/tests/registro-page.cjs @@ -3,14 +3,16 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const page = fs.readFileSync('2026/registro.html', 'utf8'); +const pageScript = fs.readFileSync('2026/js/registro.js', 'utf8'); test('el registro exige al menos un día y modalidad cuando corresponde', () => { assert.match(page, /id="dia_jueves"[^>]+value="Jueves 22"/); assert.match(page, /id="dia_viernes"[^>]+value="Viernes 23"/); assert.match(page, /name="modalidad_jueves" value="Presencial"/); assert.match(page, /name="modalidad_jueves" value="Google Meet"/); - assert.match(page, /setCustomValidity\(hasSelectedDay/); - assert.match(page, /option\.required = diaJueves\.checked/); + assert.match(page, /src="js\/registro\.js"/); + assert.match(pageScript, /setCustomValidity\(hasSelectedDay/); + assert.match(pageScript, /option\.required = diaJueves\.checked/); }); test('el registro recoge accesibilidad y consentimiento de privacidad explícito', () => { From 9edcadeb8adc2b5f2464fb9a8e530cfe93e1d60a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Arnulfo=20R=2E=20H=2E?= Date: Sun, 27 Sep 2026 10:10:53 -0500 Subject: [PATCH 2/2] security: aplicar CSP a 2026 y validar recursos externos --- .github/workflows/site-validation.yml | 6 +++-- netlify.toml | 6 ++--- scripts/validate_site.py | 5 +++++ tests/csp.cjs | 32 +++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 tests/csp.cjs diff --git a/.github/workflows/site-validation.yml b/.github/workflows/site-validation.yml index d446a4f..17c512b 100644 --- a/.github/workflows/site-validation.yml +++ b/.github/workflows/site-validation.yml @@ -33,12 +33,14 @@ jobs: - name: Validate JavaScript syntax run: | node --check 2026/js/main.js + node --check 2026/js/registro.js + node --check 2026/js/ponentes.js + node --check 2026/js/convence-a-tu-jefe.js node --check 2026/js/supabase-config.js node --check 2026/js/voluntariado.js node --check tests/consent-coc.cjs - name: Check consent persistence and Code of Conduct version run: | - node --test tests/consent-coc.cjs - node --test tests/voluntariado-page.cjs + node --test tests/*.cjs - name: Check whitespace errors run: git diff --check HEAD^ HEAD diff --git a/netlify.toml b/netlify.toml index b0e0f3b..aa1573b 100644 --- a/netlify.toml +++ b/netlify.toml @@ -59,12 +59,12 @@ for = "/*" Referrer-Policy = "strict-origin-when-cross-origin" Permissions-Policy = "camera=(), geolocation=(), microphone=()" -# Política inicialmente en observación para la edición activa. Las ediciones -# archivadas conservan recursos distintos y necesitan una revisión separada. +# CSP aplicada solo a la edición activa: las ediciones archivadas usan otros +# recursos y requieren una revisión independiente. [[headers]] for = "/2026/*" [headers.values] - Content-Security-Policy-Report-Only = "default-src 'self'; base-uri 'self'; object-src 'none'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data:; connect-src 'self' https://*.supabase.co; frame-src 'none'; frame-ancestors 'self'; form-action 'self'" + Content-Security-Policy = "default-src 'self'; base-uri 'self'; object-src 'none'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data:; connect-src 'self' https://*.supabase.co; frame-src 'none'; frame-ancestors 'self'; form-action 'self'" # CSS: caché de una semana. [[headers]] diff --git a/scripts/validate_site.py b/scripts/validate_site.py index 59812f5..5b303d5 100644 --- a/scripts/validate_site.py +++ b/scripts/validate_site.py @@ -89,6 +89,11 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None if tag == "link" and "canonical" in (values.get("rel") or "").split(): if values.get("href"): self.canonicals.append(values["href"]) + # preconnect y dns-prefetch nombran un origen, no un recurso: la raíz + # de fonts.googleapis.com responde 404 aunque el servicio funcione. + link_rels = set((values.get("rel") or "").lower().split()) + if tag == "link" and link_rels & {"preconnect", "dns-prefetch"}: + return if tag in {"a", "img", "iframe", "link", "script", "source"}: reference = values.get("href") or values.get("src") if reference: diff --git a/tests/csp.cjs b/tests/csp.cjs new file mode 100644 index 0000000..4180356 --- /dev/null +++ b/tests/csp.cjs @@ -0,0 +1,32 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +function pages(directory) { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const file = path.join(directory, entry.name); + return entry.isDirectory() ? pages(file) : file.endsWith('.html') ? [file] : []; + }); +} + +test('la edición cubierta por CSP no contiene ejecución ni estilos en línea', () => { + for (const file of pages('2026')) { + const html = fs.readFileSync(file, 'utf8'); + assert.doesNotMatch(html, /]*)?>(?!\s*<\/script>)/i, file); + assert.doesNotMatch(html, /]*)?>/i, file); + assert.doesNotMatch(html, /\s(?:style|on\w+)\s*=/i, file); + } +}); + +test('la CSP de Netlify bloquea código en línea y limita recursos activos', () => { + const config = fs.readFileSync('netlify.toml', 'utf8'); + const block = config.match(/\[\[headers\]\]\s*for = "\/2026\/\*"([\s\S]*?)(?=\n\[\[headers\]\]|$)/)?.[1]; + assert.ok(block); + assert.match(block, /Content-Security-Policy = /); + assert.match(block, /script-src 'self' https:\/\/cdn\.jsdelivr\.net/); + assert.match(block, /connect-src 'self' https:\/\/\*\.supabase\.co/); + assert.match(block, /object-src 'none'/); + assert.match(block, /frame-ancestors 'self'/); + assert.doesNotMatch(block, /unsafe-inline|unsafe-eval|Report-Only/); +});