diff --git a/api/app.js b/api/app.js index e7cf081..efc9230 100644 --- a/api/app.js +++ b/api/app.js @@ -12,6 +12,7 @@ const egosearch = require('./routes/egosearch'); const app = express(); +app.set('trust proxy', 1); // confía en nginx para obtener la IP real del cliente app.disable('x-powered-by'); app.use(helmet()); app.use(express.json({ limit: '10kb' })); diff --git a/api/routes/erase.js b/api/routes/erase.js index 5dfb30f..02d46cc 100644 --- a/api/routes/erase.js +++ b/api/routes/erase.js @@ -7,7 +7,9 @@ const ALLOWED_PROVIDERS = new Set(Object.keys(PROVIDER_DATA)); module.exports = async (req, res) => { try { - const { provider, email, nickname, phone, address, extra } = req.body; + const { provider, email, + nickname: rawNick, phone: rawPhone, + address: rawAddr, extra: rawExtra } = req.body; // Validación mínima if (!provider || !email) { @@ -20,10 +22,16 @@ module.exports = async (req, res) => { } // Validación básica de email - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + if (typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return res.status(400).json({ error: 'Email inválido' }); } + // Límites de longitud en campos opcionales (defensa en profundidad) + const nickname = String(rawNick || '').slice(0, 100).trim(); + const phone = String(rawPhone || '').slice(0, 30).trim(); + const address = String(rawAddr || '').slice(0, 300).trim(); + const extra = String(rawExtra || '').slice(0, 500).trim(); + // Hash irreversible para referencia (auditoría sin almacenar PII) const hash = crypto .createHash('sha256') diff --git a/public/egosurfing.html b/public/egosurfing.html index 80da54c..1c395b0 100644 --- a/public/egosurfing.html +++ b/public/egosurfing.html @@ -240,54 +240,119 @@ } @keyframes spin { to { transform: rotate(360deg); } } - /* ── Modos de búsqueda predefinidos ── */ - .dork-section { padding: 2.5rem 0; } - .dork-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-top: 1.5rem; } - .dork-card { + /* ── OSINT Dorking panel ── */ + .dork-section { padding: 2.5rem 0 3rem; } + .dork-tip { + font-size: 0.82rem; + color: var(--muted); + background: var(--caoba-lt); + border-left: 3px solid var(--caoba); + padding: 0.55rem 1rem; + border-radius: 0 8px 8px 0; + margin-top: 0.9rem; + } + /* Tabs de categoría */ + .dork-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + border-bottom: 2px solid var(--border); + padding-bottom: 0; + margin: 1.2rem 0 0; + } + .dtab { + padding: 0.45rem 0.85rem; + border: 1px solid transparent; + border-bottom: none; + border-radius: 7px 7px 0 0; + background: none; + color: var(--muted); + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + transition: all 120ms ease; + font-family: system-ui, sans-serif; + white-space: nowrap; + position: relative; + bottom: -2px; + letter-spacing: 0.01em; + } + .dtab:hover { background: var(--surface2); color: var(--text); } + .dtab.active { + background: var(--surface2); + border-color: var(--border); + border-bottom-color: var(--surface2); + color: var(--caoba); + } + /* Grid de dorks */ + .dork-pane { display: none; padding-top: 1.1rem; } + .dork-pane.active { display: block; } + .dork-items { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); + gap: 0.55rem; + } + .dork-item { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + gap: 0.2rem 0.5rem; + padding: 0.6rem 0.8rem; background: var(--surface); border: 1px solid var(--border); - border-radius: 12px; - padding: 1.1rem 1.2rem; + border-radius: 9px; box-shadow: var(--shadow-sm); + transition: box-shadow 150ms ease, border-color 150ms ease; } - .dork-card h3 { font-size: 1rem; margin-bottom: 0.6rem; } - .dork-query { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - padding: 0.35rem 0; - border-bottom: 1px solid var(--surface2); - font-size: 0.82rem; + .dork-item:hover { box-shadow: var(--shadow-md); border-color: var(--border-dark); } + .dork-item-desc { + font-size: 0.76rem; color: var(--text); + font-weight: 600; + grid-column: 1; + grid-row: 1; + align-self: center; } - .dork-query:last-child { border-bottom: none; } - .dork-query code { + .dork-item-query { font-family: 'Courier New', monospace; - font-size: 0.78rem; + font-size: 0.72rem; color: var(--caoba); background: var(--caoba-lt); - padding: 0.1rem 0.35rem; + padding: 0.12rem 0.4rem; border-radius: 4px; - flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + grid-column: 1; + grid-row: 2; + display: block; } - .dork-run { + .dork-run-btn { + grid-column: 2; + grid-row: 1 / 3; + align-self: center; + padding: 0.3rem 0.7rem; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--surface2); + color: var(--muted); font-size: 0.72rem; font-weight: 600; - color: var(--muted); cursor: pointer; - background: none; - border: none; - padding: 0.2rem 0.4rem; - border-radius: 4px; + white-space: nowrap; transition: all 120ms ease; - flex-shrink: 0; font-family: system-ui, sans-serif; } - .dork-run:hover { background: var(--surface2); color: var(--text); } + .dork-run-btn:hover { + background: var(--caoba-lt); + border-color: var(--caoba); + color: var(--caoba); + } @media (max-width: 720px) { - .dork-grid { grid-template-columns: 1fr; } + .dork-items { grid-template-columns: 1fr; } + .dork-tabs { gap: 0.2rem; } + .dtab { font-size: 0.68rem; padding: 0.35rem 0.6rem; } .results-header { flex-direction: column; gap: 0.4rem; } } @media (max-width: 540px) { @@ -354,78 +419,20 @@ - +
-

Google dorking — haz clic para buscar

+

OSINT dorking avanzado

- Introduce tus datos en el buscador de arriba y usa estas queries avanzadas. - Haz clic en "Buscar" para lanzarlas directamente. + Queries profesionales organizadas por categoría. Escribe tu dato en el buscador + y lanza cualquier query directamente. Los placeholders se sustituyen con tu input.

- -
- -
-

Nombre completo

-
- "NOMBRE APELLIDOS" - -
-
- "NOMBRE" filetype:pdf - -
-
- "NOMBRE" site:linkedin.com - -
-
- "NOMBRE" site:facebook.com - -
-
- -
-

Email y teléfono

-
- "EMAIL" - -
-
- "EMAIL" -site:gmail.com - -
-
- "TELEFONO" - -
-
- "TELEFONO" filetype:pdf - -
-
- -
-

Usuario / alias

-
- "ALIAS" site:twitter.com - -
-
- "ALIAS" site:instagram.com - -
-
- "ALIAS" site:reddit.com - -
-
- "ALIAS" site:github.com - -
-
- +
+ Escribe tu nombre, email, alias o teléfono en el buscador de arriba antes de lanzar un dork.
+ +
+
@@ -530,28 +537,215 @@ modeBtns.forEach(btn => { egoInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); }); egoBtn.addEventListener('click', doSearch); -/* ── Dorking rápido ─────────────────────────────────────────── */ -document.querySelectorAll('.dork-run').forEach(btn => { - btn.addEventListener('click', () => { +/* ── OSINT Dorking — categorías ──────────────────────────────── */ +const DORK_CATEGORIES = [ + { + id: 'nombre', label: 'Nombre', + dorks: [ + { desc: 'Coincidencia exacta', tpl: '"NOMBRE APELLIDOS"' }, + { desc: 'Documentos PDF', tpl: '"NOMBRE APELLIDOS" filetype:pdf' }, + { desc: 'Documentos Word', tpl: '"NOMBRE APELLIDOS" filetype:doc OR filetype:docx' }, + { desc: 'Hojas de cálculo', tpl: '"NOMBRE APELLIDOS" filetype:xls OR filetype:xlsx OR filetype:csv' }, + { desc: 'Currículum / CV', tpl: '"NOMBRE APELLIDOS" (curriculum OR resume OR CV) filetype:pdf' }, + { desc: 'LinkedIn', tpl: '"NOMBRE APELLIDOS" site:linkedin.com' }, + { desc: 'Facebook', tpl: '"NOMBRE APELLIDOS" site:facebook.com' }, + { desc: 'Instagram', tpl: '"NOMBRE APELLIDOS" site:instagram.com' }, + { desc: 'X / Twitter', tpl: '"NOMBRE APELLIDOS" site:x.com OR site:twitter.com' }, + { desc: 'Actas y registros públicos', tpl: '"NOMBRE APELLIDOS" (acta OR padrón OR registro OR certificado)' }, + { desc: 'Noticias y prensa', tpl: '"NOMBRE APELLIDOS" (noticia OR periódico OR diario OR prensa)' }, + { desc: 'Foros y comunidades', tpl: '"NOMBRE APELLIDOS" (foro OR opinión OR comentario OR review)' }, + ] + }, + { + id: 'email', label: 'Email', + dorks: [ + { desc: 'Exposición directa', tpl: '"EMAIL"' }, + { desc: 'Excluir webmail oficial', tpl: '"EMAIL" -site:gmail.com -site:outlook.com -site:yahoo.com' }, + { desc: 'En documentos PDF', tpl: '"EMAIL" filetype:pdf' }, + { desc: 'En bases de datos expuestas', tpl: '"EMAIL" filetype:sql OR filetype:txt OR filetype:log' }, + { desc: 'En Pastebin', tpl: '"EMAIL" site:pastebin.com' }, + { desc: 'En código fuente (GitHub)', tpl: '"EMAIL" site:github.com' }, + { desc: 'En Reddit / foros', tpl: '"EMAIL" site:reddit.com OR site:forocoches.com' }, + { desc: 'Brechas y filtraciones', tpl: '"EMAIL" (breach OR leak OR dump OR filtración OR hack)' }, + { desc: 'Registros corporativos', tpl: '"EMAIL" (empresa OR DPO OR privacidad OR contacto OR directorio)' }, + ] + }, + { + id: 'telefono', label: 'Teléfono', + dorks: [ + { desc: 'Número exacto', tpl: '"TELEFONO"' }, + { desc: 'Directorios españoles', tpl: '"TELEFONO" site:paginas-amarillas.es OR site:11888.es OR site:axesor.es' }, + { desc: 'En documentos PDF', tpl: '"TELEFONO" filetype:pdf' }, + { desc: 'Vinculado a WhatsApp', tpl: '"TELEFONO" (WhatsApp OR wa.me OR chat)' }, + { desc: 'Vinculado a Telegram', tpl: '"TELEFONO" (Telegram OR t.me)' }, + { desc: 'Anuncios y clasificados', tpl: '"TELEFONO" site:milanuncios.com OR site:wallapop.com OR site:vibbo.com' }, + { desc: 'Registros de empresa', tpl: '"TELEFONO" (empresa OR autónomo OR contacto OR DPO)' }, + { desc: 'Reportes de fraude', tpl: '"TELEFONO" (fraude OR estafa OR scam OR spam OR phishing)' }, + ] + }, + { + id: 'alias', label: 'Usuario / Alias', + dorks: [ + { desc: 'X / Twitter', tpl: '"ALIAS" site:x.com OR site:twitter.com' }, + { desc: 'Instagram', tpl: '"ALIAS" site:instagram.com' }, + { desc: 'Reddit', tpl: '"ALIAS" site:reddit.com' }, + { desc: 'GitHub', tpl: '"ALIAS" site:github.com' }, + { desc: 'Twitch', tpl: '"ALIAS" site:twitch.tv' }, + { desc: 'YouTube', tpl: '"ALIAS" site:youtube.com' }, + { desc: 'TikTok', tpl: '"ALIAS" site:tiktok.com' }, + { desc: 'Discord (servidores públicos)',tpl: '"ALIAS" site:discord.com OR site:discord.gg' }, + { desc: 'Steam', tpl: '"ALIAS" site:steamcommunity.com' }, + { desc: 'Mastodon / Fediverse', tpl: '"ALIAS" site:mastodon.social OR inurl:"/@ALIAS"' }, + { desc: 'Foros y comunidades', tpl: '"ALIAS" (foro OR forum OR usuario OR member OR miembro)' }, + { desc: 'LinkedIn', tpl: '"ALIAS" site:linkedin.com' }, + ] + }, + { + id: 'brokers', label: 'Data brokers', + dorks: [ + { desc: 'Spokeo', tpl: '"NOMBRE APELLIDOS" site:spokeo.com' }, + { desc: 'Whitepages', tpl: '"NOMBRE APELLIDOS" site:whitepages.com' }, + { desc: 'Pipl', tpl: '"NOMBRE APELLIDOS" site:pipl.com' }, + { desc: '192.com', tpl: '"NOMBRE APELLIDOS" site:192.com' }, + { desc: 'Intelius', tpl: '"NOMBRE APELLIDOS" site:intelius.com' }, + { desc: 'Radaris', tpl: '"NOMBRE APELLIDOS" site:radaris.com' }, + { desc: 'PeekYou', tpl: '"NOMBRE APELLIDOS" site:peekyou.com' }, + { desc: 'BeenVerified', tpl: '"NOMBRE APELLIDOS" site:beenverified.com' }, + { desc: 'TruthFinder', tpl: '"NOMBRE APELLIDOS" site:truthfinder.com' }, + { desc: 'Directorios españoles', tpl: '"NOMBRE APELLIDOS" site:paginas-blancas.es OR site:11811.es' }, + ] + }, + { + id: 'pastes', label: 'Pastes / Brechas', + dorks: [ + { desc: 'Email en Pastebin', tpl: '"EMAIL" site:pastebin.com' }, + { desc: 'Nombre en Pastebin', tpl: '"NOMBRE APELLIDOS" site:pastebin.com' }, + { desc: 'Servicios de paste alt.', tpl: '"EMAIL" site:justpaste.it OR site:rentry.co OR site:ghostbin.com' }, + { desc: 'SQL dumps expuestos', tpl: '"EMAIL" filetype:sql (INSERT OR dump OR database OR table)' }, + { desc: 'Credenciales expuestas', tpl: '"EMAIL" (password OR passwd OR contraseña OR hash OR credential)' }, + { desc: 'Logs de acceso expuestos', tpl: '"EMAIL" filetype:log (login OR access OR auth OR failed)' }, + { desc: 'Ficheros de texto con datos', tpl: '"EMAIL" filetype:txt (username OR user OR email OR password)' }, + { desc: 'Nombre en filtraciones', tpl: '"NOMBRE APELLIDOS" (filtración OR brecha OR RGPD OR datos personales)' }, + ] + }, + { + id: 'registros', label: 'Registros oficiales', + dorks: [ + { desc: 'BOE (España)', tpl: '"NOMBRE APELLIDOS" site:boe.es' }, + { desc: 'BORME — Registro Mercantil', tpl: '"NOMBRE APELLIDOS" site:boe.es/borme' }, + { desc: 'Registradores.org', tpl: '"NOMBRE APELLIDOS" site:registradores.org' }, + { desc: 'AEAT — Agencia Tributaria', tpl: '"NOMBRE APELLIDOS" site:agenciatributaria.gob.es' }, + { desc: 'Actos administrativos', tpl: '"NOMBRE APELLIDOS" (acto administrativo OR notificación OR resolución OR sanción)' }, + { desc: 'Organismos públicos', tpl: '"NOMBRE APELLIDOS" site:.gob.es OR site:.gov.es' }, + { desc: 'Donaciones y financiación', tpl: '"NOMBRE APELLIDOS" (donante OR donación OR financiación OR partido político)' }, + { desc: 'Catastro y propiedades', tpl: '"NOMBRE APELLIDOS" (catastro OR propiedad OR inmueble OR hipoteca OR finca)' }, + ] + }, + { + id: 'profesional', label: 'Perfil profesional', + dorks: [ + { desc: 'LinkedIn — perfil', tpl: '"NOMBRE APELLIDOS" site:linkedin.com/in' }, + { desc: 'CV y portfolio online', tpl: '"NOMBRE APELLIDOS" (curriculum OR CV OR portfolio OR resume) site:.es' }, + { desc: 'Publicaciones académicas', tpl: '"NOMBRE APELLIDOS" site:researchgate.net OR site:academia.edu' }, + { desc: 'Google Scholar', tpl: '"NOMBRE APELLIDOS" site:scholar.google.com' }, + { desc: 'Ponencias y conferencias', tpl: '"NOMBRE APELLIDOS" (ponencia OR conferencia OR speaker OR charla)' }, + { desc: 'Cargo directivo', tpl: '"NOMBRE APELLIDOS" (CEO OR director OR administrador OR socio OR gerente)' }, + { desc: 'Menciones en medios', tpl: '"NOMBRE APELLIDOS" (entrevista OR declaraciones OR portavoz)' }, + { desc: 'GitHub / proyectos técnicos', tpl: '"NOMBRE APELLIDOS" site:github.com OR site:gitlab.com' }, + { desc: 'Stack Overflow', tpl: '"NOMBRE APELLIDOS" site:stackoverflow.com OR site:stackexchange.com' }, + ] + }, + { + id: 'geo', label: 'Geolocalización', + dorks: [ + { desc: 'Check-ins y ubicaciones', tpl: '"ALIAS" (check-in OR checkin OR ubicación OR location OR foursquare)' }, + { desc: 'Dirección en documentos', tpl: '"NOMBRE APELLIDOS" (calle OR dirección OR address OR código postal)' }, + { desc: 'Reseñas en Google Maps', tpl: '"NOMBRE APELLIDOS" (reseña OR review) site:maps.google.com' }, + { desc: 'Airbnb / alojamiento', tpl: '"NOMBRE APELLIDOS" site:airbnb.es OR (anfitrión OR host "NOMBRE APELLIDOS")' }, + { desc: 'Inmuebles y propiedades', tpl: '"NOMBRE APELLIDOS" (venta OR alquiler OR inmueble OR piso OR local)' }, + { desc: 'Fotos con geotag (Flickr)', tpl: '"ALIAS" site:flickr.com (geo OR location OR map OR GPS)' }, + { desc: 'Marcadores sociales', tpl: '"ALIAS" (foursquare OR swarm OR yelp OR tripadvisor)' }, + ] + }, + { + id: 'archivo', label: 'Archivo histórico', + dorks: [ + { desc: 'Wayback Machine — nombre', tpl: 'site:web.archive.org "NOMBRE APELLIDOS"' }, + { desc: 'Wayback Machine — email', tpl: 'site:web.archive.org "EMAIL"' }, + { desc: 'Caché de buscadores', tpl: 'cache:"NOMBRE APELLIDOS"' }, + { desc: 'Contenido eliminado', tpl: '"NOMBRE APELLIDOS" (eliminado OR borrado OR deactivated OR removed)' }, + { desc: 'Menciones anteriores a 2020', tpl: '"NOMBRE APELLIDOS" before:2020-01-01' }, + { desc: 'Alias en URL de perfil', tpl: 'inurl:"ALIAS" (about OR bio OR perfil OR contacto OR me)' }, + { desc: 'Página personal / about', tpl: '"NOMBRE APELLIDOS" inurl:about OR inurl:bio OR inurl:me' }, + { desc: 'Dominio propio', tpl: 'site:"ALIAS".com OR site:"ALIAS".es OR site:"ALIAS".net' }, + ] + }, +]; + +/* ── Renderizar tabs y panes ─────────────────────────────────── */ +(function initDorks() { + const tabsEl = document.getElementById('dork-tabs'); + const panesEl = document.getElementById('dork-panes'); + + DORK_CATEGORIES.forEach((cat, i) => { + /* Tab */ + const tab = document.createElement('button'); + tab.className = 'dtab' + (i === 0 ? ' active' : ''); + tab.textContent = cat.label; + tab.dataset.cat = i; + tabsEl.appendChild(tab); + + /* Pane */ + const pane = document.createElement('div'); + pane.className = 'dork-pane' + (i === 0 ? ' active' : ''); + pane.id = 'dpane-' + i; + + const grid = document.createElement('div'); + grid.className = 'dork-items'; + + cat.dorks.forEach(d => { + const item = document.createElement('div'); + item.className = 'dork-item'; + item.innerHTML = ` + ${esc(d.desc)} + ${esc(d.tpl)} + `; + grid.appendChild(item); + }); + + pane.appendChild(grid); + panesEl.appendChild(pane); + }); + + /* Tab switching */ + tabsEl.addEventListener('click', e => { + const tab = e.target.closest('.dtab'); + if (!tab) return; + document.querySelectorAll('.dtab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.dork-pane').forEach(p => p.classList.remove('active')); + tab.classList.add('active'); + document.getElementById('dpane-' + tab.dataset.cat).classList.add('active'); + }); + + /* Lanzar dork al hacer clic en Buscar */ + panesEl.addEventListener('click', e => { + const btn = e.target.closest('.dork-run-btn'); + if (!btn) return; const val = egoInput.value.trim(); - if (!val) { egoInput.focus(); egoInput.placeholder = '← Primero introduce tu dato aquí'; return; } - - /* Rellena el template con el valor del input */ - const template = btn.dataset.template; - const query = template - .replace('NOMBRE APELLIDOS', val) - .replace('NOMBRE', val) - .replace('ALIAS', val) - .replace('EMAIL', val) - .replace('TELEFONO', val); - + if (!val) { egoInput.focus(); egoInput.setAttribute('placeholder', '← Introduce tu dato primero'); return; } + const query = btn.dataset.template + .replace(/NOMBRE APELLIDOS/g, val) + .replace(/NOMBRE/g, val) + .replace(/ALIAS/g, val) + .replace(/EMAIL/g, val) + .replace(/TELEFONO/g, val); egoInput.value = query; currentMode = 'libre'; modeBtns.forEach(b => b.classList.toggle('active', b.dataset.mode === 'libre')); modeHint.textContent = MODES.libre.hint; doSearch(); }); -}); +})(); /* ── Búsqueda ───────────────────────────────────────────────── */ async function doSearch() { diff --git a/public/index.html b/public/index.html index 30bcbb4..686b2ee 100644 --- a/public/index.html +++ b/public/index.html @@ -7,6 +7,92 @@ + @@ -119,13 +205,21 @@
0 acciones completadas
-
+ +
+ + + + + + +
- -
-

Cuentas base

+ +
+
-
+
Descarga datos @@ -134,7 +228,7 @@
-
+
Privacidad @@ -143,7 +237,7 @@
-
+
Portal privacidad @@ -152,7 +246,7 @@
-
+
Política privacidad @@ -160,13 +254,14 @@ Carta GDPR
+
+
- -
-

Redes sociales

+
+
-
+
Descarga datos @@ -175,7 +270,7 @@
-
+
Descarga datos @@ -184,7 +279,7 @@
-
+
Descarga datos @@ -193,7 +288,7 @@
-
+
Descarga datos @@ -202,7 +297,7 @@
-
+
Privacidad @@ -211,7 +306,7 @@
-
+
Descarga datos @@ -220,7 +315,7 @@
-
+
Privacidad @@ -228,7 +323,7 @@
-
+
Descarga datos @@ -237,7 +332,7 @@
-
+
Privacidad @@ -245,13 +340,14 @@ Carta GDPR
+
+
- -
-

Mensajería

+
+
-
+
Solicitar datos @@ -259,7 +355,7 @@
-
+
Eliminar cuenta @@ -267,19 +363,20 @@
-
+
+
+
- -
-

Entretenimiento y streaming

+
+
-
+
Privacidad @@ -287,7 +384,7 @@
-
+
Cancelar suscripción @@ -295,7 +392,7 @@
-
+
Eliminar cuenta @@ -303,20 +400,21 @@
- +
- -
-

Buscadores e indexadores

+
+
-
+
Formulario RTBF @@ -324,75 +422,76 @@
-
+
-
- +
+
-
+
-
+
+
+
- -
-

Data brokers y directorios

+
+
-
+
-
+
-
+
-
+
-
+
-
+
@@ -564,6 +663,17 @@ function addResult(name, type, msg) { results.appendChild(div); } +/* ── Panel tabs ────────────────────────────── */ +document.querySelectorAll('.ptab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.ptab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.ptab-pane').forEach(p => p.classList.remove('active')); + tab.classList.add('active'); + const pane = document.getElementById('ptab-' + tab.dataset.tab); + if (pane) pane.classList.add('active'); + }); +}); + /* ── Progreso checklist ─────────────────────── */ const cbs = document.querySelectorAll('.progress-cb'); const fill = document.getElementById('progress-fill');