From 24401c0ee5bbb2112dd252d298982d972238de2a Mon Sep 17 00:00:00 2001 From: hacklab Date: Mon, 20 Apr 2026 00:46:00 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20redise=C3=B1o=20UI=20completo=20+=20inf?= =?UTF-8?q?ra=20email=20+=20stats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.txt | 209 ++++++++++ DOCS.txt | 248 ++++++++++++ api/app.js | 10 + api/routes/egosearch.js | 7 +- api/routes/erase.js | 5 +- api/routes/gmail_oauth.js | 143 ++++--- api/routes/stats.js | 55 +++ api/services/mailer.js | 14 +- api/services/stats.js | 55 +++ infra/set-relay-credentials.sh | 39 ++ infra/setup-mail.sh | 183 +++++++++ mejoraegosurfingyfuncionalidad.txt | 80 ++++ public/concienciacion.html | 1 + public/egosurfing.html | 1 + public/icons/apple.svg | 1 + public/icons/discord.svg | 1 + public/icons/facebook.svg | 1 + public/icons/gmail.svg | 1 + public/icons/google.svg | 1 + public/icons/haveibeenpwned.svg | 1 + public/icons/instagram.svg | 1 + public/icons/netflix.svg | 1 + public/icons/pinterest.svg | 1 + public/icons/reddit.svg | 1 + public/icons/snapchat.svg | 1 + public/icons/spotify.svg | 1 + public/icons/telegram.svg | 1 + public/icons/tiktok.svg | 1 + public/icons/twitch.svg | 1 + public/icons/whatsapp.svg | 1 + public/icons/x.svg | 1 + public/icons/youtube.svg | 1 + public/index.css | 591 +++++++++++++++++++++++------ public/index.html | 489 +++++++++++++----------- public/plantillas.html | 51 ++- public/stats.html | 374 ++++++++++++++++++ public/tipos.html | 1 + 37 files changed, 2162 insertions(+), 412 deletions(-) create mode 100644 CONTEXT.txt create mode 100644 DOCS.txt create mode 100644 api/routes/stats.js create mode 100644 api/services/stats.js create mode 100755 infra/set-relay-credentials.sh create mode 100755 infra/setup-mail.sh create mode 100644 mejoraegosurfingyfuncionalidad.txt create mode 100644 public/icons/apple.svg create mode 100644 public/icons/discord.svg create mode 100644 public/icons/facebook.svg create mode 100644 public/icons/gmail.svg create mode 100644 public/icons/google.svg create mode 100644 public/icons/haveibeenpwned.svg create mode 100644 public/icons/instagram.svg create mode 100644 public/icons/netflix.svg create mode 100644 public/icons/pinterest.svg create mode 100644 public/icons/reddit.svg create mode 100644 public/icons/snapchat.svg create mode 100644 public/icons/spotify.svg create mode 100644 public/icons/telegram.svg create mode 100644 public/icons/tiktok.svg create mode 100644 public/icons/twitch.svg create mode 100644 public/icons/whatsapp.svg create mode 100644 public/icons/x.svg create mode 100644 public/icons/youtube.svg create mode 100644 public/stats.html diff --git a/CONTEXT.txt b/CONTEXT.txt new file mode 100644 index 0000000..2a4d554 --- /dev/null +++ b/CONTEXT.txt @@ -0,0 +1,209 @@ +══════════════════════════════════════════════════════════════════════ +RESETEA.NET — CONTEXTO DEL PROYECTO +Actualizado: 2026-04-20 +══════════════════════════════════════════════════════════════════════ + +QUÉ ES +────── +Herramienta web de privacidad digital. Ayuda a los usuarios a reducir +su huella digital: enviar cartas GDPR, eliminar cuentas en redes +sociales, desindexar datos de buscadores y contactar con data brokers. + +Principio fundamental: PRIVACY-FIRST. + · No se almacena ningún dato personal (PII). + · Sin cookies, sin tracking, sin analytics. + · La generación de cartas en plantillas.html es 100% local (JS en navegador). + · El único dato que pasa por el servidor es el email para reenvío GDPR, + y solo se guarda un hash SHA-256 truncado (12 chars) como referencia. + + +ARQUITECTURA GENERAL +──────────────────── + Internet + └── Nginx (puerto 80 → redirect HTTPS / puerto 443 SSL) + ├── / → archivos estáticos en public/ + └── /api/* → proxy → Node.js en 127.0.0.1:8787 + └── Nodemailer → sendmail (MTA local) + └── Postfix → relay Brevo SMTP (smtp-relay.brevo.com:587) + └── opendkim (firma DKIM, selector: mail, 2048-bit RSA) + └── Google APIs (opcional: Gmail OAuth, CSE) + └── SearXNG (instancias públicas, fallback egosearch) + + +INFRAESTRUCTURA DE EMAIL +──────────────────────── +Problema: IP dinámica del router → reputación de envío comprometida. +Solución: relay via Brevo (free tier, 300 emails/día). + · Postfix configurado como relay hacia smtp-relay.brevo.com:587 + · Credenciales en /etc/postfix/sasl_passwd (script: infra/set-relay-credentials.sh) + · DKIM signing con opendkim — selector "mail", clave en /etc/opendkim/keys/resetea.net/ + · DNS gestionado via Gandi LiveDNS API — script: /home/capitansito/HOST/managedns.sh + +Registros DNS configurados: + SPF: "v=spf1 include:spf.brevo.com include:_mailcust.gandi.net ~all" + DKIM: mail._domainkey.resetea.net TXT (clave pública 2048-bit RSA) + DMARC: _dmarc.resetea.net TXT "v=DMARC1; p=none; rua=mailto:dmarc@resetea.net" + → Cuando el envío sea estable varios días, subir p=none a p=quarantine + +Scripts de infraestructura: + infra/setup-mail.sh — instala Postfix, opendkim, genera clave DKIM + infra/set-relay-credentials.sh — escribe /etc/postfix/sasl_passwd con login Brevo + HOST/managedns.sh — actualiza IP dinámica en Gandi (NO toca registros mail) + Comando: setup-mail-dns para configurar SPF/DKIM/DMARC + +Proceso Node.js: + Servicio: /etc/systemd/system/resetea.service + Node binary: /home/capitansito/.nvm/versions/node/v18.20.8/bin/node + Arranque: sudo systemctl restart resetea + + +PÁGINAS PÚBLICAS (public/) +─────────────────────────── +index.html — Página principal. REDISEÑADA EN ESTA SESIÓN. + Secciones: + 1. Hero: título "RESETEA" + lema "La información es poder. Quitémosles poder." + Carrusel de citas (5 frases, rota cada 6s): + - "Matamos gente basándonos en metadatos." (Gen. Hayden, NSA) + - Edward Snowden sobre privacidad + - Parafraseando Orwell, 1984 + - "No somos clientes, somos el producto." (Serra & Schoolman, 1973) + - "La vigilancia es el modelo de negocio de internet." (Schneier) + 2. Formulario GDPR (DISEÑO DOS COLUMNAS — nuevo): + Columna izquierda (Paso 1): email input + lista de privacidad + Columna derecha (Paso 2): selección de redes + · Grid de chips AUTO-GDPR (instagram, facebook, twitter_x, linkedin, + tiktok, snapchat, discord, reddit, microsoft, apple, google, amazon) + → seleccionables, al enviar se manda carta GDPR via API o se abre form + · Grid de chips MANUALES (whatsapp, telegram, spotify, youtube, + netflix, twitch, pinterest) + → ACCIÓN DIRECTA: clic abre el enlace oficial inmediatamente + → NO son seleccionables, NO cuentan para el botón "Enviar" + → Borde discontinuo, flash verde al hacer clic + · Botón "Seleccionar todas" (solo auto-chips) + · Contador "X redes seleccionadas" + Botón "Enviar cartas GDPR (X)" — solo activo con email válido + al menos 1 auto-chip + 3. Panel de acciones completo (REDISEÑADO — secciones visibles, sin tabs): + Barra de progreso global (todos los checkboxes) + 6 secciones siempre visibles apiladas verticalmente, cada una con: + · Badge pill con nombre en fuente Recion (Cuentas base, Redes sociales, + Mensajería, Streaming, Buscadores, Data brokers) + · Botón "Marcar todas" (header) y "✓ Marcar sección completada" (footer) + · Grid de tarjetas con: logo oficial + nombre + botones de acción + · Al marcar checkbox: tarjeta se vuelve verde irlandés (sage) + 4. Sección "Cómo funciona" (3 pasos) + 5. Sección "Plantillas legales" + 6. Aviso legal + + Logos: Simple Icons CDN (cdn.simpleicons.org) — pendiente mover a /public/icons/ + PENDIENTE: Descargar los SVGs localmente (linkedin, microsoft, amazon, + microsoftbing fallaron — buscar slug correcto en Simple Icons) + +stats.html — Dashboard de estadísticas anónimas. NUEVO. + · 4 KPI cards: emails enviados, redireccionados, búsquedas, total + · Gráfico de barras por proveedor + · Explicación detallada de privacidad (sección
expandible) + · Fetch a GET /api/stats con credentials:'omit' + · Todo el HTML de servidor pasa por esc() antes de innerHTML (anti-XSS) + +plantillas.html — Generador de cartas GDPR (browser-side). + · esc() + safeHref() aplicados a todo HTML dinámico + · Nav: enlace añadido a stats.html + +concienciacion.html, tipos.html, egosurfing.html — sin cambios en esta sesión. + + +SERVICIOS DEL BACKEND (api/) +───────────────────────────── +POST /api/erase + Envía carta GDPR Art. 17 al DPO. + Entrada: { provider, email, [nickname, phone, address, extra] } + Salida: { status: 'ok'|'use_form'|error, reference: hash12chars } + Proveedores API directa (10): instagram, facebook, twitter_x, linkedin, + tiktok, snapchat, microsoft, apple, reddit, discord + Proveedores formulario oficial (2): google, amazon + SEGURIDAD: sin fallback a proveedor desconocido (eliminado open-relay) + +GET /api/stats + Devuelve estadísticas anónimas en JSON. + Rate limit: 30 req/min + Datos: total_sent, total_redirected, total_errors, total_searches, + by_provider (solo proveedores whitelisteados) + Almacenamiento: data/stats.json (escritura atómica con .tmp + rename) + SIN PII — solo contadores agregados. + +GET /api/egosearch?q= + Busca con SearXNG o Google CSE. Límite: 500.000 bytes respuesta. + Registra stats.record({ type: 'search' }) por cada búsqueda. + +GET /api/gmail/auth → GET /api/gmail/callback + OAuth2 Google. Estado firmado con HMAC-SHA256 + nonce (anti-forgery). + Token de un solo uso, sin refresh, sin persistencia. + + +SEGURIDAD IMPLEMENTADA +────────────────────── +FRONTEND: + · esc() en todos los datos del servidor antes de innerHTML + · safeHref() para validar URLs (solo http/https) + · credentials:'omit' en todos los fetch + +BACKEND: + · Rate limiting Nginx: 20 req/s, burst 40 para /api/ + · Rate limiting Express: general 100/15min, email 10/1h, búsqueda 8/1min, stats 30/1min + · IP real via X-Real-IP desde Nginx ($remote_addr — no spoofeable) + · Helmet: HSTS, X-Frame-Options DENY, CSP estricta, Referrer-Policy, Permissions-Policy + · Anti-CRLF: sanitizeHeader() en campos opcionales del email + · Anti prototype pollution: Object.create(null) en stats.by_provider + · Timeout Slowloris: headersTimeout 10s, requestTimeout 15s en Node + · Timeout Nginx: proxy_read_timeout 15s, connect_timeout 5s + · OAuth state: HMAC-SHA256 con nonce random, base64url, max 4096 bytes + · Mailer: sin fallback a proveedor desconocido + · Stats: safeInt() valida numéricos, VALID_DATE regex valida fecha + + +DISEÑO Y UI +─────────── +Sistema de diseño (index.css): + Variables CSS: --caoba (marrón oscuro), --sage (#1a7a4a, verde irlandés), + --acid (#c8ff00, amarillo ácido), --surface, --bg, --border + Fuentes: Recion/Italiana (serif, para títulos y badges), system-ui (para texto) + +Formulario principal: + · Layout dos columnas (grid 340px + 1fr) — sticky email column + · Botones de red: system-ui, 0.97rem, grid auto-fill minmax(130px, 1fr) + · Auto-chips seleccionados: verde irlandés (#1a7a4a) + · Manual chips: borde discontinuo, clic directo + · Mobile: stacks a columna única en ≤720px + +Panel de acciones: + · Secciones visibles sin tabs + · Badges: fuente Recion, background caoba, border-radius 20px + · Cards: border 1.5px, border-radius 14px, checked = sage-lt background + · Botones de acción: 0.82rem, padding 0.35rem 0.85rem, border-radius 8px + · Logos: Simple Icons CDN (pendiente mover a /public/icons/ local) + + +PENDIENTES PARA PRÓXIMA SESIÓN +─────────────────────────────── +1. Logos locales: descargar SVGs de Simple Icons con slugs correctos + Fallaron: linkedin, microsoft, amazon, microsoftbing + Intentar desde: https://simpleicons.org/ o el paquete npm simple-icons + Guardar en: public/icons/{slug}.svg + Actualizar src en HTML de /icons/{slug}.svg + +2. DNS/mail: revisar logs de envío con journalctl -u postfix + Cuando envío sea estable varios días → subir DMARC a p=quarantine: + Comando: bash /home/capitansito/HOST/managedns.sh setup-mail-dns + (editar el script para cambiar p=none a p=quarantine) + +3. Systemd: verificar que resetea.service arranca bien con: + sudo systemctl status resetea + sudo systemctl enable resetea (si no está enabled) + +4. Probar flujo completo de email desde la web en producción. + +5. Gmail OAuth: requiere credenciales Google Cloud Console en .env + GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI + +6. Revisar si el ícono de Snapchat (amarillo) es visible sobre fondo claro — + puede necesitar ajuste de contraste. diff --git a/DOCS.txt b/DOCS.txt new file mode 100644 index 0000000..4c0a6a0 --- /dev/null +++ b/DOCS.txt @@ -0,0 +1,248 @@ +══════════════════════════════════════════════════════════════════════ +RESETEA.NET — DOCUMENTACIÓN TÉCNICA +Actualizado: 2026-04 +══════════════════════════════════════════════════════════════════════ + + +PUERTOS +─────── +80 Nginx — HTTP (redirige a HTTPS automáticamente) +443 Nginx — HTTPS (SSL/TLS, certificado Let's Encrypt) +8787 Node.js/Express — Solo escucha en 127.0.0.1 (loopback, no expuesto) + Nginx hace de proxy inverso: /api/* → 127.0.0.1:8787 + + ⚠ El puerto 8787 NO debe abrirse en el firewall. Solo Nginx lo usa. + + +ENDPOINTS DE LA API +──────────────────── +Base: https://resetea.net/api/ + +POST /api/erase + Descripción: Envía carta GDPR Art. 17 al DPO de la plataforma. + Content-Type: application/json + Rate limit: 10 peticiones / hora / IP + Body: + { + "provider": "instagram", ← obligatorio + "email": "tu@email.com",← obligatorio + "nickname": "usuario123", ← opcional + "phone": "+34 600...", ← opcional + "address": "Calle...", ← opcional + "extra": "Texto libre" ← opcional + } + Respuesta OK: + { "status": "ok", "message": "...", "reference": "abc123def456" } + Respuesta proveedor sin email: + { "status": "use_form", "formUrl": "https://...", "reference": "..." } + Respuesta error: + { "error": "descripción del error" } + +GET /api/egosearch?q= + Descripción: Busca en la web menciones del término indicado. + Rate limit: 8 peticiones / minuto / IP + Parámetros: + q (string, 2-150 chars) — nombre o alias a buscar + Respuesta: + { "results": [ { "title", "url", "snippet", "domain", "engine" } ], "query", "total" } + Nota: sin operadores especiales, la query se envuelve en comillas automáticamente. + +GET /api/gmail/auth?provider=&name=&email=&... + Descripción: Inicia flujo OAuth2 para enviar carta desde Gmail del usuario. + Parámetros: provider, name, email (obligatorios) + nickname, phone, address, extra, requestType (opcionales) + Respuesta: Redirect a Google OAuth2. + Requiere: GOOGLE_CLIENT_ID y GOOGLE_CLIENT_SECRET en .env + +GET /api/gmail/callback?code=&state=&error= + Descripción: Callback OAuth2 de Google. Envía el email y descarta el token. + Respuesta: Redirect a /plantillas.html?oauth=ok|cancelled|error|no_email + +GET /api/health + Descripción: Health check. + Respuesta: { "status": "ok" } + + +VARIABLES DE ENTORNO (.env en api/) +───────────────────────────────────── +Obligatorias: + SALT Cadena aleatoria larga para hash de referencia de auditoría. + Ejemplo: openssl rand -hex 32 + PORT Puerto del servidor Node.js. Por defecto: 8787 + +Opcionales — Gmail OAuth2 (sin esto el botón "Enviar desde mi Gmail" no funciona): + GOOGLE_CLIENT_ID ID de cliente OAuth2 de Google Cloud Console + GOOGLE_CLIENT_SECRET Secreto del cliente OAuth2 + GOOGLE_REDIRECT_URI URI de callback. Valor: https://resetea.net/api/gmail/callback + +Opcionales — Google Custom Search (sin esto egosearch usa SearXNG): + GOOGLE_API_KEY API Key de Google Cloud Console (Custom Search API habilitada) + GOOGLE_CSE_ID ID del motor de búsqueda de Programmable Search Engine + +Plantilla del archivo .env: + SALT= + PORT=8787 + GOOGLE_CLIENT_ID= + GOOGLE_CLIENT_SECRET= + GOOGLE_REDIRECT_URI=https://resetea.net/api/gmail/callback + GOOGLE_API_KEY= + GOOGLE_CSE_ID= + + +DEPENDENCIAS DEL BACKEND (npm) +──────────────────────────────── +Producción (api/package.json): + express ^4.19.2 Framework HTTP + helmet ^7.1.0 Cabeceras de seguridad HTTP + express-rate-limit ^7.3.1 Rate limiting por IP + nodemailer ^6.9.13 Envío de emails via sendmail local + googleapis ^144.0.0 Gmail API + OAuth2 (para envío desde Gmail del usuario) + dotenv ^16.4.5 Carga de variables de entorno desde .env + +Sin dependencias de frontend (HTML/CSS/JS puro, sin frameworks). + + +SOFTWARE DEL SISTEMA NECESARIO +──────────────────────────────── +Obligatorio: + Node.js >= 18 Servidor backend + npm >= 8 Gestor de paquetes Node + Nginx Servidor web / proxy inverso + Certbot (Let's Encrypt) Certificados SSL — certbot --nginx + +Para el envío de emails (POST /api/erase): + Un MTA (Mail Transfer Agent) local que provea /usr/sbin/sendmail. + Opciones comunes: + · Postfix: apt install postfix (recomendado, configurar como "Internet Site") + · msmtp: apt install msmtp msmtp-mta (más simple, relay a servidor externo) + · Exim4: apt install exim4 + + ⚠ SIN MTA el endpoint POST /api/erase fallará silenciosamente. + El binario /usr/sbin/sendmail debe existir en el sistema. + Para verificar: which sendmail + + Dominio de envío configurado en mailer.js: + From: privacy@resetea.net + El dominio resetea.net debe tener registros SPF/DKIM/DMARC correctos + para que los emails no lleguen a spam. + + +CONFIGURACIÓN DE NGINX +─────────────────────── +Archivo: infra/nginx-resetea.conf +Copiar en: /etc/nginx/sites-available/resetea.net +Activar: ln -s /etc/nginx/sites-available/resetea.net /etc/nginx/sites-enabled/ + nginx -t && nginx -s reload + +Lo que hace: + · Puerto 80 → redirect 301 a HTTPS. + · Puerto 443 → sirve public/ como estático. + · /api/* → proxy a 127.0.0.1:8787 con X-Real-IP = $remote_addr. + · Rate limit Nginx: 20 req/s por IP, burst 40 en /api/. + · Headers de seguridad: HSTS, X-Frame-Options, CSP, Referrer-Policy, + Permissions-Policy, COOP, X-Content-Type-Options. + · Timeout proxy: read 15s, connect 5s. + · server_tokens off (oculta versión de Nginx). + +SSL (Let's Encrypt): + certbot --nginx -d resetea.net + Rutas de certificados: + /etc/letsencrypt/live/resetea.net/fullchain.pem + /etc/letsencrypt/live/resetea.net/privkey.pem + + +FUENTES TIPOGRÁFICAS (public/fonts/) +────────────────────────────────────── +RECION.otf / RECION.ttf Títulos (h1, h2, h3, chips, logo) +Italiana-Regular.ttf Alternativa serif +CormorantGaramond-Regular.woff2 Cuerpo de texto elegante +CormorantGaramond-Italic.woff2 Cursiva + + +ARRANCAR EL BACKEND +──────────────────── +cd /var/www/resetea.net/api +npm install ← solo la primera vez o tras cambiar package.json +node app.js ← arranque simple + +Para producción (sin systemd todavía): + npm install -g pm2 + pm2 start app.js --name resetea + pm2 save + pm2 startup ← genera el comando para arranque automático + +Para crear un servicio systemd (recomendado): + Crear /etc/systemd/system/resetea.service: + [Unit] + Description=RESETEA.NET backend + After=network.target + + [Service] + Type=simple + User=www-data + WorkingDirectory=/var/www/resetea.net/api + ExecStart=/usr/bin/node app.js + Restart=on-failure + EnvironmentFile=/var/www/resetea.net/api/.env + + [Install] + WantedBy=multi-user.target + + systemctl enable resetea + systemctl start resetea + + +APIS EXTERNAS UTILIZADAS +───────────────────────── +SearXNG (instancias públicas — sin key, gratuito): + Se usa como motor de búsqueda para egosurfing cuando no hay Google CSE. + Instancias hardcodeadas en egosearch.js: + https://searx.be + https://priv.au + https://search.mdosch.de + https://searxng.site + Se prueban en orden hasta obtener resultados. Timeout: 7s por instancia. + +Google Custom Search API (opcional — mejora calidad de resultados): + https://programmablesearchengine.google.com/ + https://console.cloud.google.com/ → Custom Search API + Requiere: GOOGLE_API_KEY + GOOGLE_CSE_ID en .env + +Google Gmail API + OAuth2 (opcional — envío desde Gmail del usuario): + https://console.cloud.google.com/ → APIs y servicios → Gmail API + Tipo de credencial: OAuth2, Aplicación web + URI de redirección autorizado: https://resetea.net/api/gmail/callback + Scope usado: https://www.googleapis.com/auth/gmail.send + Requiere: GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET en .env + El token es de un solo uso (access_type: 'online', no refresh token). + +Let's Encrypt (Certbot): + Renovación automática via cron o systemd timer. + Verificar: certbot renew --dry-run + + +ESTRUCTURA DE ARCHIVOS +─────────────────────── +/var/www/resetea.net/ + public/ + index.html Panel principal + concienciacion.html Derechos GDPR + plantillas.html Generador de cartas (JS local) + tipos.html Tipos de información personal + egosurfing.html Guía OSINT + buscador integrado + index.css Design system completo (tema cálido, caoba/salvia) + fonts/ Tipografías locales + api/ + app.js Entrada: Express + helmet + rate-limit + rutas + routes/ + erase.js POST /api/erase — validación + envío GDPR + egosearch.js GET /api/egosearch — búsqueda SearXNG/Google CSE + gmail_oauth.js GET /api/gmail/auth y /callback — OAuth2 Gmail + services/ + mailer.js Datos de 12 DPOs + plantilla carta Art.17 + nodemailer + .env Variables de entorno (NO subir a git) + package.json Dependencias npm + infra/ + nginx-resetea.conf Config Nginx lista para copiar + CONTEXT.txt Contexto del proyecto (este documento complementario) + DOCS.txt Este archivo + context_puppeteer.txt Diseño de automatización Puppeteer (pendiente) diff --git a/api/app.js b/api/app.js index ff5fd6d..10bf75b 100644 --- a/api/app.js +++ b/api/app.js @@ -9,6 +9,7 @@ const rateLimit = require('express-rate-limit'); const eraseRoute = require('./routes/erase'); const gmailOAuth = require('./routes/gmail_oauth'); const egosearch = require('./routes/egosearch'); +const statsRoute = require('./routes/stats'); const app = express(); @@ -57,6 +58,15 @@ app.get('/api/egosearch', searchLimit, egosearch); app.get('/api/gmail/auth', emailLimit, gmailOAuth.authInit); app.get('/api/gmail/callback', gmailOAuth.authCallback); +// Stats públicas (solo contadores anónimos, sin PII) +const statsLimit = rateLimit({ + windowMs: 60 * 1000, + max: 30, + message: { error: 'Demasiadas peticiones.' }, + keyGenerator: realIp, +}); +app.get('/api/stats', statsLimit, statsRoute); + // Health check app.get('/api/health', (req, res) => res.json({ status: 'ok' })); diff --git a/api/routes/egosearch.js b/api/routes/egosearch.js index 2bdb07c..0d6d68f 100644 --- a/api/routes/egosearch.js +++ b/api/routes/egosearch.js @@ -94,7 +94,9 @@ async function searchGoogle(query) { const err = await res.json().catch(() => ({})); throw new Error(err.error?.message || `HTTP ${res.status}`); } - const data = await res.json(); + const text = await res.text(); + if (text.length > 500_000) throw new Error('Respuesta Google CSE demasiado grande'); + const data = JSON.parse(text); return (data.items || []).map(item => ({ title: String(item.title || '').slice(0, 300), url: String(item.link || ''), @@ -103,6 +105,8 @@ async function searchGoogle(query) { })); } +const stats = require('../services/stats'); + /* ── Handler principal ──────────────────────────────────────── */ module.exports = async (req, res) => { const raw = req.query.q; @@ -150,5 +154,6 @@ module.exports = async (req, res) => { engine: r.engine, })); + stats.record({ type: 'search' }); res.json({ results, query, total: raw_results.length }); }; diff --git a/api/routes/erase.js b/api/routes/erase.js index afbf95c..af1ac9f 100644 --- a/api/routes/erase.js +++ b/api/routes/erase.js @@ -2,6 +2,7 @@ const crypto = require('crypto'); const { sendErasureMail, PROVIDER_DATA } = require('../services/mailer'); +const stats = require('../services/stats'); const ALLOWED_PROVIDERS = new Set(Object.keys(PROVIDER_DATA)); @@ -50,6 +51,7 @@ module.exports = async (req, res) => { const result = await sendErasureMail({ provider, email, nickname, phone, address, extra }); if (result.skipped) { + stats.record({ type: 'redirected', provider }); return res.json({ status: 'use_form', message: 'Este proveedor no acepta solicitudes por email. Usa su formulario oficial.', @@ -58,7 +60,7 @@ module.exports = async (req, res) => { }); } - // PII fuera de scope aquí — solo el hash queda + stats.record({ type: 'sent', provider }); res.json({ status: 'ok', message: 'Solicitud enviada. Guarda el código de referencia.', @@ -66,6 +68,7 @@ module.exports = async (req, res) => { }); } catch (e) { + stats.record({ type: 'error' }); console.error('erase route error:', e.message); res.status(500).json({ error: 'Error interno. Inténtalo de nuevo o usa el formulario oficial del proveedor.' }); } diff --git a/api/routes/gmail_oauth.js b/api/routes/gmail_oauth.js index 098e0b8..225e6cc 100644 --- a/api/routes/gmail_oauth.js +++ b/api/routes/gmail_oauth.js @@ -1,30 +1,49 @@ 'use strict'; -/** - * RESETEA.NET — OAuth Gmail - * - * Flujo: - * GET /api/gmail/auth → redirige a Google para autorización - * GET /api/gmail/callback → intercambia code por token de un solo uso - * POST /api/gmail/send → envía la carta GDPR desde el Gmail del usuario - * (el token se usa y se descarta — nunca se persiste) - * - * PREREQUISITO: - * Crea un proyecto en Google Cloud Console: - * https://console.cloud.google.com/ - * → Habilita "Gmail API" - * → Crea credenciales OAuth2 (tipo "Aplicación web") - * → URI de redirección: https://resetea.net/api/gmail/callback - * → Copia GOOGLE_CLIENT_ID y GOOGLE_CLIENT_SECRET en .env - */ - -const { google } = require('googleapis'); +const crypto = require('crypto'); +const { google } = require('googleapis'); const { buildLetterText, PROVIDER_DATA } = require('../services/mailer'); -// ── Estado temporal en memoria (un objeto por token de sesión) ── -// NO se persiste en disco. Si el servidor reinicia, se pierden los -// tokens pendientes (el usuario debe repetir el flujo OAuth). -const pendingSends = new Map(); // sessionId → { token, letterParams } +const ALLOWED_PROVIDERS = new Set(Object.keys(PROVIDER_DATA)); +const STATE_MAX_BYTES = 4096; +const SALT = () => process.env.SALT || 'resetea-oauth-default-insecure'; + +/* ── Elimina CRLF y tabuladores — previene header injection en RFC 2822 ── */ +function sanitizeHeader(str, maxLen = 300) { + return String(str || '').replace(/[\r\n\t\x00-\x1F\x7F]/g, ' ').trim().slice(0, maxLen); +} + +/* ── Firma HMAC-SHA256 del state — previene state forgery en OAuth callback ── */ +function signState(payload) { + const nonce = crypto.randomBytes(16).toString('hex'); + const data = { ...payload, _nonce: nonce }; + const sig = crypto.createHmac('sha256', SALT()) + .update(JSON.stringify(data)) + .digest('hex'); + return Buffer.from(JSON.stringify({ ...data, _sig: sig })).toString('base64url'); +} + +function verifyState(raw) { + if (!raw || typeof raw !== 'string') throw new Error('State ausente'); + if (Buffer.byteLength(raw, 'utf8') > STATE_MAX_BYTES) throw new Error('State demasiado grande'); + + let obj; + try { obj = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')); } + catch { throw new Error('State malformado'); } + + const { _sig, ...data } = obj; + if (typeof _sig !== 'string' || _sig.length !== 64) throw new Error('Firma ausente o malformada'); + + const expected = crypto.createHmac('sha256', SALT()).update(JSON.stringify(data)).digest('hex'); + const sigBuf = Buffer.from(_sig, 'hex'); + const expectedBuf = Buffer.from(expected, 'hex'); + + if (!crypto.timingSafeEqual(sigBuf, expectedBuf)) throw new Error('Firma inválida'); + + /* Devuelve payload sin los campos internos */ + const { _nonce, ...params } = data; + return params; +} function getOAuth2Client() { return new google.auth.OAuth2( @@ -34,25 +53,31 @@ function getOAuth2Client() { ); } -// ── GET /api/gmail/auth ────────────────────────────────────────── -// El frontend envía los parámetros de la carta como query params -// para que los podamos recuperar en el callback. +/* ── GET /api/gmail/auth ─────────────────────────────────────────── */ exports.authInit = (req, res) => { const { provider, name, email, nickname, phone, address, extra, requestType } = req.query; - if (!provider || !email || !name) { + if (!provider || !email || !name) return res.status(400).json({ error: 'Faltan parámetros obligatorios (provider, name, email).' }); - } - if (!PROVIDER_DATA[provider]) { - return res.status(400).json({ error: 'Proveedor no soportado.' }); - } - // Guardamos el state en base64 (no sensible: no contiene credenciales) - const state = Buffer.from(JSON.stringify({ provider, name, email, nickname, phone, address, extra, requestType })).toString('base64url'); + if (!ALLOWED_PROVIDERS.has(provider)) + return res.status(400).json({ error: 'Proveedor no soportado.' }); + + /* Sanitizamos los campos que luego irán a cabeceras de email */ + const state = signState({ + provider, + name: sanitizeHeader(name, 200), + email: sanitizeHeader(email, 200), + nickname: sanitizeHeader(nickname, 100), + phone: sanitizeHeader(phone, 30), + address: sanitizeHeader(address, 300), + extra: sanitizeHeader(extra, 500), + requestType: sanitizeHeader(requestType, 20), + }); const oauth2Client = getOAuth2Client(); const authUrl = oauth2Client.generateAuthUrl({ - access_type: 'online', // no refresh token — uso puntual + access_type: 'online', scope: ['https://www.googleapis.com/auth/gmail.send'], prompt: 'consent', state, @@ -61,48 +86,55 @@ exports.authInit = (req, res) => { res.redirect(authUrl); }; -// ── GET /api/gmail/callback ────────────────────────────────────── +/* ── GET /api/gmail/callback ─────────────────────────────────────── */ exports.authCallback = async (req, res) => { const { code, state, error } = req.query; - if (error) { - return res.redirect('/plantillas.html?oauth=cancelled'); - } - if (!code || !state) { + if (error) return res.redirect('/plantillas.html?oauth=cancelled'); + + if (!code || !state) return res.status(400).send('Parámetros OAuth inválidos.'); - } let params; try { - params = JSON.parse(Buffer.from(state, 'base64url').toString()); - } catch { - return res.status(400).send('State OAuth inválido.'); + params = verifyState(state); + } catch (e) { + console.warn('OAuth state rejection:', e.message); + return res.status(400).send('State OAuth inválido o expirado.'); + } + + /* Validar provider contra whitelist (por si el state fue manipulado antes de que + implementáramos la firma, o si la firma falla silenciosamente en el futuro) */ + if (!ALLOWED_PROVIDERS.has(params.provider)) { + return res.status(400).send('Proveedor no soportado.'); } try { const oauth2Client = getOAuth2Client(); - const { tokens } = await oauth2Client.getToken(code); - - // Enviamos el email inmediatamente (no almacenamos el token) + const { tokens } = await oauth2Client.getToken(code); oauth2Client.setCredentials(tokens); - const gmail = google.gmail({ version: 'v1', auth: oauth2Client }); + const gmail = google.gmail({ version: 'v1', auth: oauth2Client }); const providerInfo = PROVIDER_DATA[params.provider]; + if (!providerInfo.email) { - return res.redirect(`/plantillas.html?oauth=no_email&provider=${params.provider}&formUrl=${encodeURIComponent(providerInfo.formUrl || '')}`); + return res.redirect( + `/plantillas.html?oauth=no_email&provider=${encodeURIComponent(params.provider)}` + + `&formUrl=${encodeURIComponent(providerInfo.formUrl || '')}` + ); } const letterText = buildLetterText({ providerInfo, senderName: params.name, senderEmail: params.email, - senderNick: params.nickname || '', - senderPhone: params.phone || '', - senderAddress: params.address || '', - extra: params.extra || '', + senderNick: params.nickname || '', + senderPhone: params.phone || '', + senderAddress: params.address || '', + extra: params.extra || '', }); - // Construir email RFC 2822 en base64url + /* Cabeceras RFC 2822 — name y email ya vienen sanitizados desde authInit */ const subject = `Ejercicio derecho de supresión (RGPD Art. 17) — ${providerInfo.name}`; const rawEmail = [ `From: ${params.name} <${params.email}>`, @@ -114,14 +146,11 @@ exports.authCallback = async (req, res) => { letterText, ].join('\r\n'); - const encoded = Buffer.from(rawEmail).toString('base64url'); - await gmail.users.messages.send({ userId: 'me', - requestBody: { raw: encoded }, + requestBody: { raw: Buffer.from(rawEmail).toString('base64url') }, }); - // Token descartado aquí (fuera de scope, GC lo recogerá) res.redirect(`/plantillas.html?oauth=ok&provider=${encodeURIComponent(providerInfo.name)}`); } catch (err) { diff --git a/api/routes/stats.js b/api/routes/stats.js new file mode 100644 index 0000000..c691c1e --- /dev/null +++ b/api/routes/stats.js @@ -0,0 +1,55 @@ +'use strict'; + +const { get } = require('../services/stats'); + +// Whitelist estricta: solo claves conocidas salen en la respuesta +const PROVIDER_LABELS = { + instagram: 'Instagram', + facebook: 'Facebook', + twitter_x: 'X (Twitter)', + linkedin: 'LinkedIn', + tiktok: 'TikTok', + snapchat: 'Snapchat', + microsoft: 'Microsoft', + apple: 'Apple', + google: 'Google', + amazon: 'Amazon', + reddit: 'Reddit', + discord: 'Discord', +}; + +const VALID_DATE = /^\d{4}-\d{2}-\d{2}$/; + +function safeInt(v) { + const n = Math.floor(Number(v)); + return Number.isFinite(n) && n >= 0 ? n : 0; +} + +module.exports = (req, res) => { + const raw = get(); + + // Solo proveedores de la whitelist — cualquier clave extraña en stats.json se descarta + const providers = Object.entries(raw.by_provider || {}) + .filter(([key]) => Object.prototype.hasOwnProperty.call(PROVIDER_LABELS, key)) + .map(([key, v]) => ({ + name: PROVIDER_LABELS[key], // nombre viene de whitelist, no del fichero + icon_key: key, // clave corta para iconos en cliente (solo a-z_) + sent: safeInt(v.sent), + redirected: safeInt(v.redirected), + total: safeInt(v.sent) + safeInt(v.redirected), + })) + .sort((a, b) => b.total - a.total); + + // updated: solo si es una fecha válida YYYY-MM-DD, si no null + const updated = typeof raw.updated === 'string' && VALID_DATE.test(raw.updated) + ? raw.updated + : null; + + res.json({ + total_sent: safeInt(raw.total_sent), + total_redirected: safeInt(raw.total_redirected), + total_searches: safeInt(raw.total_searches), + providers, + updated, + }); +}; diff --git a/api/services/mailer.js b/api/services/mailer.js index 22baf80..634eb80 100644 --- a/api/services/mailer.js +++ b/api/services/mailer.js @@ -84,12 +84,14 @@ ${today}`; exports.PROVIDER_DATA = PROVIDER_DATA; exports.sendErasureMail = async ({ provider, email, nickname, phone, address, extra }) => { - const providerInfo = PROVIDER_DATA[provider] || { - name: provider, - email: `privacy@${provider}.com`, - company: provider, - address: '', - }; + const providerInfo = PROVIDER_DATA[provider]; + + /* Sin fallback: el provider ya fue validado en erase.js contra ALLOWED_PROVIDERS. + Si llegara algo desconocido aquí sería un bug, no un caso legítimo. + No construir email destino dinámico — eso sería un open relay. */ + if (!providerInfo) { + throw new Error(`Proveedor desconocido: ${provider}`); + } // Si el proveedor no tiene email directo, no enviamos (devolvemos aviso) if (!providerInfo.email) { diff --git a/api/services/stats.js b/api/services/stats.js new file mode 100644 index 0000000..82aa6a7 --- /dev/null +++ b/api/services/stats.js @@ -0,0 +1,55 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const DATA_FILE = path.join(__dirname, '..', 'data', 'stats.json'); + +const EMPTY = () => ({ + total_sent: 0, + total_redirected: 0, + total_errors: 0, + total_searches: 0, + by_provider: Object.create(null), // null prototype — previene prototype pollution + updated: null, +}); + +function load() { + try { + return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); + } catch { + return EMPTY(); + } +} + +function save(data) { + const dir = path.dirname(DATA_FILE); + fs.mkdirSync(dir, { recursive: true }); + const tmp = DATA_FILE + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8'); + fs.renameSync(tmp, DATA_FILE); // atómico en mismo filesystem +} + +function record({ type, provider }) { + const s = load(); + + if (type === 'sent') s.total_sent++; + else if (type === 'redirected') s.total_redirected++; + else if (type === 'error') s.total_errors++; + else if (type === 'search') s.total_searches++; + + if (provider && (type === 'sent' || type === 'redirected')) { + if (!s.by_provider[provider]) s.by_provider[provider] = { sent: 0, redirected: 0 }; + if (type === 'sent') s.by_provider[provider].sent++; + if (type === 'redirected') s.by_provider[provider].redirected++; + } + + s.updated = new Date().toISOString().slice(0, 10); + save(s); +} + +function get() { + return load(); +} + +module.exports = { record, get }; diff --git a/infra/set-relay-credentials.sh b/infra/set-relay-credentials.sh new file mode 100755 index 0000000..6c59137 --- /dev/null +++ b/infra/set-relay-credentials.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# ================================================================= +# set-relay-credentials.sh — Configura credenciales Brevo en Postfix +# Uso: sudo bash /var/www/resetea.net/infra/set-relay-credentials.sh LOGIN SMTP_KEY +# +# LOGIN = email con que te registraste en Brevo +# SMTP_KEY = clave SMTP de Brevo (SMTP & API → SMTP → Generate key) +# ================================================================= + +if [[ $EUID -ne 0 ]]; then + echo "ERROR: Ejecuta como root: sudo bash $0 LOGIN SMTP_KEY" + exit 1 +fi + +if [[ -z "$1" || -z "$2" ]]; then + echo "Uso: sudo bash $0 LOGIN SMTP_KEY" + echo "Ejemplo: sudo bash $0 user@email.com xsmtpsib-abc123..." + exit 1 +fi + +LOGIN="$1" +SMTP_KEY="$2" + +echo "[smtp-relay.brevo.com]:587 ${LOGIN}:${SMTP_KEY}" > /etc/postfix/sasl_passwd +chmod 600 /etc/postfix/sasl_passwd +postmap /etc/postfix/sasl_passwd +echo " → sasl_passwd actualizado" + +systemctl restart postfix +echo " → Postfix arrancado" + +# Test de envío +echo "" +echo "Haciendo test de envío..." +echo "Test resetea.net SMTP relay $(date)" | sendmail -v -f privacy@resetea.net privacy@resetea.net 2>&1 | head -10 + +echo "" +echo "Verifica el log: sudo journalctl -u postfix -n 20" +echo "O el log de mail: sudo tail -20 /var/log/mail.log" diff --git a/infra/setup-mail.sh b/infra/setup-mail.sh new file mode 100755 index 0000000..b32e652 --- /dev/null +++ b/infra/setup-mail.sh @@ -0,0 +1,183 @@ +#!/bin/bash +# ================================================================= +# setup-mail.sh — Postfix (relay Brevo) + opendkim para resetea.net +# Uso: sudo bash /var/www/resetea.net/infra/setup-mail.sh +# ================================================================= +set -e + +DOMAIN="resetea.net" +SELECTOR="mail" +DKIM_DIR="/etc/opendkim/keys/${DOMAIN}" +NODE_BIN="/home/capitansito/.nvm/versions/node/v18.20.8/bin/node" +APP_DIR="/var/www/resetea.net/api" +APP_USER="capitansito" + +# ── Verificaciones previas ──────────────────────────────────────── +if [[ $EUID -ne 0 ]]; then + echo "ERROR: Ejecuta como root: sudo bash $0" + exit 1 +fi + +if [[ ! -f "${NODE_BIN}" ]]; then + echo "ERROR: node no encontrado en ${NODE_BIN}" + echo "Ajusta NODE_BIN al inicio del script." + exit 1 +fi + +echo "" +echo "╔══════════════════════════════════════════════╗" +echo "║ SETUP MAIL — resetea.net ║" +echo "╚══════════════════════════════════════════════╝" + +# ── [1/6] Instalar paquetes ─────────────────────────────────────── +echo "" +echo "[1/6] Instalando postfix, opendkim, opendkim-tools..." +DEBIAN_FRONTEND=noninteractive apt-get install -y \ + postfix libsasl2-modules opendkim opendkim-tools + +# ── [2/6] Configurar Postfix ────────────────────────────────────── +echo "" +echo "[2/6] Configurando Postfix..." + +postconf -e "myhostname = ${DOMAIN}" +postconf -e "myorigin = ${DOMAIN}" +postconf -e "inet_interfaces = loopback-only" +postconf -e "mydestination = localhost" +postconf -e "mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128" + +# Relay Brevo — credenciales se añaden con set-relay-credentials.sh +postconf -e "relayhost = [smtp-relay.brevo.com]:587" +postconf -e "smtp_sasl_auth_enable = yes" +postconf -e "smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd" +postconf -e "smtp_sasl_security_options = noanonymous" +postconf -e "smtp_tls_security_level = encrypt" +postconf -e "smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt" +postconf -e "smtp_use_tls = yes" + +# Integración opendkim via milter +postconf -e "milter_protocol = 6" +postconf -e "milter_default_action = accept" +postconf -e "smtpd_milters = inet:localhost:12301" +postconf -e "non_smtpd_milters = inet:localhost:12301" + +# Placeholder de credenciales (vacío hasta ejecutar set-relay-credentials.sh) +if [[ ! -f /etc/postfix/sasl_passwd ]]; then + echo "[smtp-relay.brevo.com]:587 BREVO_LOGIN:BREVO_SMTP_KEY" > /etc/postfix/sasl_passwd + chmod 600 /etc/postfix/sasl_passwd + postmap /etc/postfix/sasl_passwd +fi + +# ── [3/6] Generar claves DKIM ───────────────────────────────────── +echo "" +echo "[3/6] Generando claves DKIM (2048 bits)..." + +mkdir -p "${DKIM_DIR}" + +if [[ -f "${DKIM_DIR}/${SELECTOR}.private" ]]; then + echo " → Clave ya existente, se mantiene (no se regenera)." +else + opendkim-genkey -b 2048 -d "${DOMAIN}" -D "${DKIM_DIR}" -s "${SELECTOR}" -v + echo " → Clave generada en ${DKIM_DIR}/" +fi + +chown -R opendkim:opendkim /etc/opendkim/ +chmod 711 "${DKIM_DIR}" # traversable pero no listable por otros +chmod 600 "${DKIM_DIR}/${SELECTOR}.private" +chmod 644 "${DKIM_DIR}/${SELECTOR}.txt" # clave pública — legible por el script + +# ── [4/6] Configurar opendkim ───────────────────────────────────── +echo "" +echo "[4/6] Configurando opendkim..." + +cat > /etc/opendkim.conf << EOF +AutoRestart Yes +AutoRestartRate 10/1h +UMask 002 +Syslog yes +SyslogSuccess Yes +LogWhy Yes +Canonicalization relaxed/simple +ExternalIgnoreList refile:/etc/opendkim/TrustedHosts +InternalHosts refile:/etc/opendkim/TrustedHosts +KeyTable refile:/etc/opendkim/KeyTable +SigningTable refile:/etc/opendkim/SigningTable +Mode sv +PidFile /run/opendkim/opendkim.pid +SignatureAlgorithm rsa-sha256 +UserID opendkim +Socket inet:12301@localhost +EOF + +cat > /etc/opendkim/TrustedHosts << EOF +127.0.0.1 +localhost +${DOMAIN} +EOF + +cat > /etc/opendkim/KeyTable << EOF +${SELECTOR}._domainkey.${DOMAIN} ${DOMAIN}:${SELECTOR}:${DKIM_DIR}/${SELECTOR}.private +EOF + +cat > /etc/opendkim/SigningTable << EOF +*@${DOMAIN} ${SELECTOR}._domainkey.${DOMAIN} +EOF + +# ── [5/6] Servicio systemd para resetea backend ─────────────────── +echo "" +echo "[5/6] Creando servicio systemd resetea..." + +cat > /etc/systemd/system/resetea.service << EOF +[Unit] +Description=RESETEA.NET backend Node.js +After=network.target + +[Service] +Type=simple +User=${APP_USER} +WorkingDirectory=${APP_DIR} +ExecStart=${NODE_BIN} app.js +Restart=on-failure +RestartSec=5 +EnvironmentFile=${APP_DIR}/.env + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable resetea +systemctl start resetea && echo " → resetea backend arrancado" || echo " ⚠ Error arrancando resetea — revisa: journalctl -u resetea -n 20" + +# ── [6/6] Arrancar opendkim y postfix ───────────────────────────── +echo "" +echo "[6/6] Arrancando opendkim y postfix..." + +systemctl enable opendkim +systemctl restart opendkim && echo " → opendkim OK" || echo " ⚠ Error en opendkim" +sleep 1 + +# Postfix NO se arranca hasta que haya credenciales reales en sasl_passwd +echo " → Postfix: esperando credenciales Brevo antes de arrancar." +echo " Ejecuta set-relay-credentials.sh cuando tengas las credenciales." + +# ── Resumen final ───────────────────────────────────────────────── +echo "" +echo "════════════════════════════════════════════════════════" +echo " REGISTRO DKIM — añadir en DNS de Gandi:" +echo "────────────────────────────────────────────────────────" +echo " Nombre: ${SELECTOR}._domainkey" +echo " Tipo: TXT" +DKIM_P=$(cat "${DKIM_DIR}/${SELECTOR}.txt" | grep -o '"p=.*"' | tr -d '"' | tr -d ' ') +echo " Valor: v=DKIM1; k=rsa; ${DKIM_P}" +echo "" +echo " (archivo completo en ${DKIM_DIR}/${SELECTOR}.txt)" +echo "════════════════════════════════════════════════════════" +echo "" +echo "SIGUIENTE PASO:" +echo " 1. Crea cuenta gratis en https://app.brevo.com" +echo " 2. Ve a: SMTP & API → SMTP → 'Generate a new SMTP Key'" +echo " 3. Ejecuta:" +echo " sudo bash /var/www/resetea.net/infra/set-relay-credentials.sh TU_EMAIL_BREVO TU_SMTP_KEY" +echo " 4. Añade el registro DKIM de arriba en Gandi" +echo " 5. Ejecuta el managedns.sh setup-mail-dns para SPF y DMARC" +echo "" diff --git a/mejoraegosurfingyfuncionalidad.txt b/mejoraegosurfingyfuncionalidad.txt new file mode 100644 index 0000000..cd835da --- /dev/null +++ b/mejoraegosurfingyfuncionalidad.txt @@ -0,0 +1,80 @@ +CONTEXTO — Tarea pendiente de completar (requiere sudo) +========================================================== + +PROBLEMA +-------- +El backend Node.js (api/app.js, puerto 8787) no está corriendo +y nginx no tiene el proxy /api/ configurado. Esto causa que: + - El botón "Enviar solicitudes GDPR" del panel principal dé error de conexión + - El egosurfing real (/api/egosearch) tampoco funcione + +LO QUE YA ESTÁ HECHO +--------------------- +1. Backend preparado: /var/www/resetea.net/api/app.js +2. .env creado: /var/www/resetea.net/api/.env (con SALT generado) +3. Seguridad egosearch mejorada (sanitización, validación URLs, límite tamaño) +4. Config nginx correcta preparada en: /tmp/resetea-nginx.conf +5. Servicio systemd preparado en: /tmp/resetea-api.service + +LO QUE FALTA (necesita sudo) +----------------------------- +Comando 1 — Activar proxy nginx: + sudo cp /tmp/resetea-nginx.conf /etc/nginx/sites-enabled/resetea.net && sudo nginx -t && sudo systemctl reload nginx + +Comando 2 — Arrancar backend como servicio: + sudo cp /tmp/resetea-api.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now resetea-api + +Comando 3 — Verificar: + sudo systemctl status resetea-api + curl -s http://127.0.0.1:8787/api/health + +CONTENIDO DE /tmp/resetea-nginx.conf +-------------------------------------- +server { + listen 80; + listen [::]:80; + server_name resetea.net; + return 301 https://$host$request_uri; +} +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name resetea.net; + root /var/www/resetea.net/public; + index index.html; + ssl_certificate /etc/letsencrypt/live/resetea.net/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/resetea.net/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + location /api/ { + proxy_pass http://127.0.0.1:8787; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 15s; + proxy_connect_timeout 5s; + } + location / { + try_files $uri $uri/ =404; + } +} + +CONTENIDO DE /tmp/resetea-api.service +--------------------------------------- +[Unit] +Description=RESETEA.NET API Backend +After=network.target + +[Service] +Type=simple +User=capitansito +WorkingDirectory=/var/www/resetea.net/api +ExecStart=/home/capitansito/.nvm/versions/node/v18.20.8/bin/node app.js +Restart=on-failure +RestartSec=5 +Environment=NODE_ENV=production + +[Install] +WantedBy=multi-user.target diff --git a/public/concienciacion.html b/public/concienciacion.html index fc6ca4b..b8b055e 100644 --- a/public/concienciacion.html +++ b/public/concienciacion.html @@ -112,6 +112,7 @@ Concienciación Resetea Egosurfing + Estadísticas diff --git a/public/egosurfing.html b/public/egosurfing.html index c558f17..869d55e 100644 --- a/public/egosurfing.html +++ b/public/egosurfing.html @@ -377,6 +377,7 @@ Tipos de info Concienciación Egosurfing + Estadísticas diff --git a/public/icons/apple.svg b/public/icons/apple.svg new file mode 100644 index 0000000..5dacefa --- /dev/null +++ b/public/icons/apple.svg @@ -0,0 +1 @@ +Apple \ No newline at end of file diff --git a/public/icons/discord.svg b/public/icons/discord.svg new file mode 100644 index 0000000..729f596 --- /dev/null +++ b/public/icons/discord.svg @@ -0,0 +1 @@ +Discord \ No newline at end of file diff --git a/public/icons/facebook.svg b/public/icons/facebook.svg new file mode 100644 index 0000000..da8e434 --- /dev/null +++ b/public/icons/facebook.svg @@ -0,0 +1 @@ +Facebook \ No newline at end of file diff --git a/public/icons/gmail.svg b/public/icons/gmail.svg new file mode 100644 index 0000000..16c040e --- /dev/null +++ b/public/icons/gmail.svg @@ -0,0 +1 @@ +Gmail \ No newline at end of file diff --git a/public/icons/google.svg b/public/icons/google.svg new file mode 100644 index 0000000..2c5419d --- /dev/null +++ b/public/icons/google.svg @@ -0,0 +1 @@ +Google \ No newline at end of file diff --git a/public/icons/haveibeenpwned.svg b/public/icons/haveibeenpwned.svg new file mode 100644 index 0000000..831b5f2 --- /dev/null +++ b/public/icons/haveibeenpwned.svg @@ -0,0 +1 @@ +Have I Been Pwned \ No newline at end of file diff --git a/public/icons/instagram.svg b/public/icons/instagram.svg new file mode 100644 index 0000000..a0a9189 --- /dev/null +++ b/public/icons/instagram.svg @@ -0,0 +1 @@ +Instagram \ No newline at end of file diff --git a/public/icons/netflix.svg b/public/icons/netflix.svg new file mode 100644 index 0000000..f211fce --- /dev/null +++ b/public/icons/netflix.svg @@ -0,0 +1 @@ +Netflix \ No newline at end of file diff --git a/public/icons/pinterest.svg b/public/icons/pinterest.svg new file mode 100644 index 0000000..f14668e --- /dev/null +++ b/public/icons/pinterest.svg @@ -0,0 +1 @@ +Pinterest \ No newline at end of file diff --git a/public/icons/reddit.svg b/public/icons/reddit.svg new file mode 100644 index 0000000..e015be5 --- /dev/null +++ b/public/icons/reddit.svg @@ -0,0 +1 @@ +Reddit \ No newline at end of file diff --git a/public/icons/snapchat.svg b/public/icons/snapchat.svg new file mode 100644 index 0000000..af36ac9 --- /dev/null +++ b/public/icons/snapchat.svg @@ -0,0 +1 @@ +Snapchat \ No newline at end of file diff --git a/public/icons/spotify.svg b/public/icons/spotify.svg new file mode 100644 index 0000000..8345ef2 --- /dev/null +++ b/public/icons/spotify.svg @@ -0,0 +1 @@ +Spotify \ No newline at end of file diff --git a/public/icons/telegram.svg b/public/icons/telegram.svg new file mode 100644 index 0000000..fe467cd --- /dev/null +++ b/public/icons/telegram.svg @@ -0,0 +1 @@ +Telegram \ No newline at end of file diff --git a/public/icons/tiktok.svg b/public/icons/tiktok.svg new file mode 100644 index 0000000..5c1b249 --- /dev/null +++ b/public/icons/tiktok.svg @@ -0,0 +1 @@ +TikTok \ No newline at end of file diff --git a/public/icons/twitch.svg b/public/icons/twitch.svg new file mode 100644 index 0000000..9ade5a7 --- /dev/null +++ b/public/icons/twitch.svg @@ -0,0 +1 @@ +Twitch \ No newline at end of file diff --git a/public/icons/whatsapp.svg b/public/icons/whatsapp.svg new file mode 100644 index 0000000..7546eda --- /dev/null +++ b/public/icons/whatsapp.svg @@ -0,0 +1 @@ +WhatsApp \ No newline at end of file diff --git a/public/icons/x.svg b/public/icons/x.svg new file mode 100644 index 0000000..dbb5550 --- /dev/null +++ b/public/icons/x.svg @@ -0,0 +1 @@ +X \ No newline at end of file diff --git a/public/icons/youtube.svg b/public/icons/youtube.svg new file mode 100644 index 0000000..88ecb7f --- /dev/null +++ b/public/icons/youtube.svg @@ -0,0 +1 @@ +YouTube \ No newline at end of file diff --git a/public/index.css b/public/index.css index fc740a3..f461fcd 100644 --- a/public/index.css +++ b/public/index.css @@ -36,8 +36,8 @@ --caoba-mid: #a0522d; /* siena */ --caoba-lt: #f0e6df; /* caoba muy claro */ - --sage: #4a7c59; /* verde salvia */ - --sage-lt: #e8f2eb; /* verde salvia muy claro */ + --sage: #1a7a4a; /* verde irlandés */ + --sage-lt: #e0f4ea; /* verde irlandés muy claro */ --lime: #96c21a; /* verde lima */ --lime-lt: #f4fae0; /* verde lima muy claro */ @@ -379,14 +379,199 @@ a:hover { color: var(--caoba-mid); text-decoration: underline; } PANEL (checklist grid) ═══════════════════════════════════════════ */ .panel { - padding: 4rem 0; + padding: 3.5rem 0 4rem; background: var(--bg); + border-top: 1px solid var(--border); +} + +.panel-title { + font-size: clamp(1.3rem, 3vw, 1.9rem); + margin-bottom: 0.4rem; } .section-desc { color: var(--muted); - margin-bottom: 2rem; - font-size: 0.95rem; + margin-bottom: 0; + font-size: 0.93rem; + max-width: 72ch; + line-height: 1.5; +} + +/* Barra de progreso compacta */ +.progress-track { + display: flex; + align-items: center; + gap: 1rem; + margin: 1.2rem 0 1.8rem; +} +.progress-track .progress-bar { flex: 1; margin: 0; } +.progress-track .progress-label { white-space: nowrap; } + +/* ── Secciones del panel ── */ +.panel-global-header { + margin-bottom: 0.5rem; +} +.panel-title { + font-size: clamp(1.3rem, 3vw, 1.9rem); + margin-bottom: 0.4rem; +} + +.panel-section { + background: var(--surface); + border: 1.5px solid var(--border); + border-radius: 18px; + padding: 1.6rem 1.8rem 1.4rem; + box-shadow: var(--shadow-sm); + margin-bottom: 1.4rem; +} +.panel-section:last-child { margin-bottom: 0; } + +.panel-section-header { + display: flex; + align-items: center; + gap: 0.9rem; + margin-bottom: 1.2rem; + flex-wrap: wrap; +} + +.panel-section-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 1.2rem; + border-radius: 20px; + background: var(--caoba); + color: #fff; + font-family: 'Recion', 'Georgia', serif; + font-size: 1.05rem; + font-weight: 400; + letter-spacing: 0.04em; + flex-shrink: 0; +} + +.btn-mark-section { + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font-size: 0.78rem; + font-weight: 600; + padding: 0.3rem 0.85rem; + border-radius: 20px; + border: 1.5px solid var(--sage); + background: var(--sage-lt); + color: var(--sage); + cursor: pointer; + transition: background 150ms ease, color 150ms ease; + white-space: nowrap; + margin-left: auto; +} +.btn-mark-section:hover { + background: var(--sage); + color: #fff; +} + +.panel-section-footer { + margin-top: 1.1rem; + display: flex; + justify-content: flex-end; +} + +.btn--section-send { + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font-size: 0.88rem; + font-weight: 600; + padding: 0.55rem 1.4rem; +} + +/* ── Grid de cards ── */ +.pitem-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.9rem; +} + +/* ── Card individual ── */ +.pitem-card { + background: var(--surface); + border: 1.5px solid var(--border); + border-radius: 14px; + padding: 1.1rem 1.1rem 0.9rem; + box-shadow: 0 2px 0 0 var(--border-dark); + display: flex; + flex-direction: column; + gap: 0.75rem; + transition: + border-color 150ms ease, + background 150ms ease, + box-shadow 150ms ease; +} +.pitem-card:hover { + border-color: #a8d4bc; + box-shadow: 0 3px 0 0 #a8d4bc; +} + +/* Checked state — verde irlandés */ +.pitem-card:has(.progress-cb:checked) { + background: var(--sage-lt); + border-color: var(--sage); + box-shadow: 0 2px 0 0 #0f5c34; +} + +/* Label del checkbox */ +.pitem-label { + display: flex; + align-items: flex-start; + gap: 0.55rem; + cursor: pointer; + line-height: 1.3; +} +.pitem-label input[type="checkbox"] { + accent-color: var(--sage); + width: 17px; + height: 17px; + flex-shrink: 0; + margin-top: 0.1rem; + cursor: pointer; +} +.pitem-icon { + width: 18px; + height: 18px; + object-fit: contain; + flex-shrink: 0; + vertical-align: middle; + display: inline-block; +} +.pitem-card:has(.progress-cb:checked) .pitem-icon { filter: brightness(0) saturate(100%) invert(30%) sepia(80%) saturate(400%) hue-rotate(110deg); } + +.pitem-name { + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font-size: 0.97rem; + font-weight: 600; + color: var(--text); + transition: color 150ms ease; + display: flex; + align-items: center; + gap: 0.45rem; +} +.pitem-card:has(.progress-cb:checked) .pitem-name { + text-decoration: line-through; + color: var(--sage); +} + +/* ── Action links dentro de card ── */ +.pitem-card .actions { + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +/* ── Responsive del panel ── */ +@media (max-width: 900px) { + .pitem-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); } +} +@media (max-width: 600px) { + .pitem-grid { grid-template-columns: 1fr 1fr; } + .progress-track { flex-direction: column; align-items: flex-start; gap: 0.4rem; } + .progress-track .progress-bar { width: 100%; } } .grid { @@ -452,14 +637,14 @@ a:hover { color: var(--caoba-mid); text-decoration: underline; } .actions a { display: inline-block; - padding: 0.18rem 0.55rem; - font-size: 0.72rem; + padding: 0.35rem 0.85rem; + font-size: 0.82rem; font-weight: 600; - letter-spacing: 0.03em; - border-radius: 5px; + letter-spacing: 0.02em; + border-radius: 8px; background: var(--surface2); color: var(--caoba); - border: 1px solid var(--border); + border: 1.5px solid var(--border); transition: background 120ms ease, border-color 120ms ease, transform 100ms ease, box-shadow 100ms ease; text-decoration: none; box-shadow: 0 2px 0 0 var(--border-dark); @@ -488,8 +673,8 @@ a:hover { color: var(--caoba-mid); text-decoration: underline; } .actions a[href*="privacy.apple"] { background: var(--sage-lt); color: var(--sage); - border-color: #c0d9c8; - box-shadow: 0 2px 0 0 #c0d9c8; + border-color: #a8d4bc; + box-shadow: 0 2px 0 0 #a8d4bc; } .actions a[href*="download"]:hover, .actions a[href*="data-and-privacy"]:hover, @@ -619,12 +804,64 @@ a:hover { color: var(--caoba-mid); text-decoration: underline; } .main-tagline { font-family: 'Recion', 'Georgia', serif; - font-size: clamp(1.1rem, 2.8vw, 1.7rem); + font-size: clamp(1.05rem, 2.6vw, 1.6rem); color: var(--acid); - letter-spacing: 0.06em; + letter-spacing: 0.05em; font-weight: normal; - margin-bottom: 2.2rem; + margin-bottom: 1.4rem; text-shadow: 0 0 20px rgba(200,255,0,0.25); + line-height: 1.4; +} + +/* ── Carrusel de citas ── */ +.quotes-strip { + width: 100%; + max-width: 680px; + margin: 0 auto 2rem; + min-height: 4.5rem; + display: flex; + align-items: center; + justify-content: center; +} + +.quote-carousel { + position: relative; + width: 100%; + text-align: center; +} + +.hero-quote { + display: none; + flex-direction: column; + gap: 0.35rem; + font-size: clamp(0.78rem, 1.6vw, 0.92rem); + color: var(--muted); + font-style: italic; + line-height: 1.55; + padding: 0 0.5rem; + animation: quoteIn 600ms ease; +} +.hero-quote.active { display: flex; } + +@keyframes quoteIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.q-mark { + font-family: 'Recion', Georgia, serif; + font-size: 1.1em; + color: var(--sage); + font-style: normal; + line-height: 1; +} + +.hero-quote cite { + font-size: 0.75rem; + font-style: normal; + font-weight: 600; + color: var(--subtle); + letter-spacing: 0.03em; } .landing-nav { @@ -698,37 +935,80 @@ a:hover { color: var(--caoba-mid); text-decoration: underline; } } .erase-form-wrap { - max-width: 780px; + max-width: 1100px; margin: 0 auto; } -.form-header { - text-align: center; - margin-bottom: 1.8rem; +/* ── Layout dos columnas ── */ +.form-two-col { + display: grid; + grid-template-columns: 340px 1fr; + gap: 2.5rem; + align-items: start; } -.form-header h2 { margin-bottom: 0.4rem; font-size: clamp(1.4rem, 3vw, 1.9rem); } -.form-header p { color: var(--muted); font-size: 1.05rem; } -.email-row { - display: flex; +.form-col-email { + background: var(--surface); + border: 1.5px solid var(--border); + border-radius: 18px; + padding: 2rem 1.8rem; + box-shadow: var(--shadow-sm); + position: sticky; + top: 1.5rem; +} + +.form-col-networks { + background: var(--surface); + border: 1.5px solid var(--border); + border-radius: 18px; + padding: 2rem 1.8rem; + box-shadow: var(--shadow-sm); +} + +/* ── Step badge ── */ +.form-step-badge { + display: inline-flex; + align-items: center; justify-content: center; - margin-bottom: 2rem; + width: 2rem; + height: 2rem; + border-radius: 50%; + background: var(--caoba); + color: #fff; + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font-size: 0.95rem; + font-weight: 700; + margin-bottom: 0.75rem; } +.form-col-title { + font-size: clamp(1.2rem, 2.5vw, 1.55rem); + margin-bottom: 0.5rem; + line-height: 1.2; +} + +.form-col-sub { + font-size: 0.9rem; + color: var(--muted); + margin-bottom: 1.2rem; + line-height: 1.5; +} + +/* ── Email input ── */ .email-input { width: 100%; - max-width: 500px; - padding: 0.9rem 1.2rem; - font-size: 1.1rem; + padding: 0.9rem 1.1rem; + font-size: 1.05rem; border: 2px solid var(--border); border-radius: 12px; - background: var(--surface); + background: var(--surface2); color: var(--text); outline: none; box-shadow: var(--shadow-sm); transition: border-color 150ms ease, box-shadow 150ms ease; font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; letter-spacing: 0.01em; + box-sizing: border-box; } .email-input:focus { border-color: var(--caoba); @@ -736,134 +1016,229 @@ a:hover { color: var(--caoba-mid); text-decoration: underline; } } .email-input::placeholder { color: var(--subtle); } -.networks-label { - font-family: 'Recion', 'Georgia', serif; - font-size: 1.5rem; - color: var(--text); - margin-bottom: 1.2rem; - letter-spacing: 0.03em; - text-align: center; -} - -.networks-section { margin-bottom: 2rem; } - -.networks-sublabel { - font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--muted); - margin-bottom: 0.7rem; - margin-top: 1.4rem; - text-align: center; -} -.networks-sublabel:first-child { margin-top: 0; } -.networks-sublabel--manual { color: var(--text); } - -.networks-grid { +.form-privacy-list { + list-style: none; + padding: 0; + margin: 1.1rem 0 0; display: flex; - flex-wrap: wrap; - gap: 0.55rem; - margin-bottom: 0.25rem; - justify-content: center; + flex-direction: column; + gap: 0.4rem; +} +.form-privacy-list li { + font-size: 0.82rem; + color: var(--muted); + line-height: 1.4; } -/* Chip base — fuente de título, grande */ -.net-chip { - font-family: 'Recion', 'Georgia', serif; - font-size: 1.1rem; - letter-spacing: 0.04em; - padding: 0.7rem 1.45rem; - border-radius: 12px; +/* ── Networks header ── */ +.form-col-networks-header { + display: flex; + align-items: center; + gap: 0.8rem; + flex-wrap: wrap; + margin-bottom: 1.2rem; +} +.form-col-networks-header .form-col-title { margin-bottom: 0; flex: 1; } +.form-col-networks-header .form-step-badge { margin-bottom: 0; flex-shrink: 0; } + +.btn-select-all { + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font-size: 0.8rem; + font-weight: 600; + padding: 0.35rem 0.9rem; + border-radius: 20px; + border: 1.5px solid var(--sage); + background: var(--sage-lt); + color: var(--sage); cursor: pointer; - background: var(--surface); + transition: background 150ms ease, color 150ms ease; + white-space: nowrap; + flex-shrink: 0; +} +.btn-select-all:hover { + background: var(--sage); + color: #fff; +} + +/* ── Group labels ── */ +.networks-group-label { + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--sage); + margin: 0 0 0.6rem; +} +.networks-group-label--manual { + color: var(--muted); + margin-top: 1.4rem; +} + +/* ── Grid de botones ── */ +.networks-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 0.5rem; + margin-bottom: 0.2rem; +} + +/* ── Logos oficiales dentro de chips ── */ +.chip-icon { + width: 17px; + height: 17px; + object-fit: contain; + flex-shrink: 0; + vertical-align: middle; + display: inline-block; +} +.net-chip.selected .chip-icon { filter: brightness(0) invert(1); } + +/* ── Botones de red — grandes, legibles ── */ +.net-chip { + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font-size: 0.97rem; + font-weight: 500; + padding: 0.7rem 0.6rem; + border-radius: 10px; + cursor: pointer; + background: var(--surface2); color: var(--text); border: 1.5px solid var(--border); - box-shadow: 0 3px 0 0 var(--border-dark), 0 4px 10px rgba(26,23,20,0.07); + box-shadow: 0 2px 0 0 var(--border-dark); transition: - transform 130ms cubic-bezier(.2,.8,.4,1), - box-shadow 130ms ease, - background 130ms ease, - border-color 130ms ease, - color 130ms ease; + transform 120ms cubic-bezier(.2,.8,.4,1), + box-shadow 120ms ease, + background 120ms ease, + border-color 120ms ease, + color 120ms ease; user-select: none; - line-height: 1.2; - -webkit-font-smoothing: subpixel-antialiased; + line-height: 1.3; + text-align: center; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 0.4rem; } .net-chip:hover { border-color: var(--acid); - color: var(--acid-dark); background: var(--acid-lt); - transform: translateY(-3px); - box-shadow: 0 6px 0 0 #9dcc00, 0 8px 20px rgba(200,255,0,0.20); + color: var(--acid-dark); + transform: translateY(-2px); + box-shadow: 0 4px 0 0 #9dcc00, 0 6px 16px rgba(200,255,0,0.18); } +.net-chip:hover .chip-icon { filter: brightness(0) saturate(100%) invert(40%) sepia(90%) saturate(600%) hue-rotate(40deg); } .net-chip:active { transform: translateY(1px); box-shadow: 0 1px 0 0 var(--border-dark); } +/* Auto-GDPR seleccionado — verde irlandés */ .net-chip.selected { - background: var(--caoba); + background: var(--sage); color: #fff; - border-color: #5c2d1e; - box-shadow: 0 3px 0 0 #5c2d1e, 0 4px 12px rgba(123,63,46,0.25); + border-color: #0f5c34; + box-shadow: 0 3px 0 0 #0f5c34, 0 4px 12px rgba(26,122,74,0.28); } .net-chip.selected:hover { - background: var(--caoba-mid); + background: #168a52; color: #fff; border-color: var(--acid); - box-shadow: 0 6px 0 0 #9dcc00, 0 8px 20px rgba(200,255,0,0.25); - transform: translateY(-3px); + box-shadow: 0 5px 0 0 #9dcc00, 0 7px 18px rgba(200,255,0,0.22); + transform: translateY(-2px); } -/* Chips manuales — texto negro, hover ácido */ +/* Chips manuales — acción directa, no seleccionables */ .net-chip--manual { background: var(--surface); - color: var(--text); + color: var(--muted); border-color: var(--border); - box-shadow: 0 3px 0 0 var(--border-dark), 0 4px 10px rgba(26,23,20,0.06); + border-style: dashed; } .net-chip--manual:hover { - background: var(--acid-lt); - border-color: var(--acid); - color: var(--acid-dark); - box-shadow: 0 6px 0 0 #9dcc00, 0 8px 20px rgba(200,255,0,0.20); - transform: translateY(-3px); + background: var(--surface2); + border-color: var(--sage); + border-style: solid; + color: var(--sage); + transform: translateY(-2px); + box-shadow: 0 3px 0 0 #0f5c34; } -.net-chip--manual.selected { - background: var(--text); - color: var(--acid); - border-color: var(--text); - box-shadow: 0 3px 0 0 #000, 0 4px 12px rgba(26,23,20,0.25); +.net-chip--manual:hover .chip-icon { filter: brightness(0) saturate(100%) invert(30%) sepia(80%) saturate(400%) hue-rotate(110deg); } +.net-chip--manual:active { transform: translateY(0); } + +/* Flash al abrir enlace manual */ +@keyframes chipFlash { + 0% { background: var(--sage-lt); border-color: var(--sage); } + 100% { background: var(--surface); border-color: var(--border); } } -.net-chip--manual.selected:hover { - border-color: var(--acid); - box-shadow: 0 6px 0 0 #9dcc00, 0 8px 20px rgba(200,255,0,0.25); - transform: translateY(-3px); +.chip--opened { animation: chipFlash 1.2s ease forwards; } + +.chip-arrow { + font-size: 0.75rem; + opacity: 0.5; + flex-shrink: 0; } -/* Footer del form */ -.form-footer-row { +/* Nota explicativa sección manual */ +.networks-manual-note { + font-size: 0.78rem; + color: var(--subtle); + margin: -0.2rem 0 0.6rem; + font-style: italic; + line-height: 1.4; +} + +/* ── Contador ── */ +.form-counter { + margin-top: 1.1rem; + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font-size: 0.88rem; + font-weight: 600; + color: var(--sage); + text-align: right; + letter-spacing: 0.01em; +} + +/* ── Fila de envío ── */ +.form-send-row { + margin-top: 2rem; display: flex; flex-direction: column; align-items: center; - gap: 0.8rem; + gap: 0.9rem; } -.btn--lg { - padding: 0.85rem 2rem; - font-size: 1rem; +.btn--send-main { + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font-size: 1.15rem; + font-weight: 700; + padding: 1rem 2.8rem; + border-radius: 14px; + letter-spacing: 0.01em; + min-width: 280px; + justify-content: center; } .form-note { font-size: 0.77rem; color: var(--subtle); text-align: center; - max-width: 48ch; + max-width: 52ch; line-height: 1.55; } +/* ── Responsive: una columna en móvil ── */ +@media (max-width: 720px) { + .form-two-col { + grid-template-columns: 1fr; + gap: 1.2rem; + } + .form-col-email { position: static; } + .networks-grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); } + .btn--send-main { width: 100%; min-width: 0; } +} + /* Resultados */ .results-wrap { margin-top: 1.5rem; @@ -886,7 +1261,7 @@ a:hover { color: var(--caoba-mid); text-decoration: underline; } border-radius: 3px; } -.result-ok { background: var(--sage-lt); color: var(--sage); border: 1px solid #c0d9c8; } +.result-ok { background: var(--sage-lt); color: var(--sage); border: 1px solid #a8d4bc; } .result-form, .result-manual { background: var(--caoba-lt); color: var(--caoba); border: 1px solid #e0c4b8; } .result-error { background: #fef2f2; color: #c0392b; border: 1px solid #f5c6c6; } diff --git a/public/index.html b/public/index.html index 686b2ee..4aff8b3 100644 --- a/public/index.html +++ b/public/index.html @@ -3,96 +3,10 @@ - RESETEA.NET · Reduce tu huella digital + RESETEA.NET · La información es poder, quitémosles poder - @@ -106,12 +20,39 @@

RESETEA

-

Reduce tu huella digital.

+

La información es poder.
Quitémosles poder.

+ +
+ +
+
@@ -123,65 +64,80 @@
-
-

Introduce tu correo electrónico

-

Selecciona las redes y enviamos la carta GDPR por ti. Sin guardar nada.

-
+
- - -

Selecciona las redes que quieres eliminar:

- -
-

Envío automático de carta GDPR

-
- - - - - - - - - - - - + + -

Acceso rápido — acción manual

-
- - - - - - - + +
+
+
2
+

Elige las redes

+ +
+ +

Envío automático de carta GDPR

+
+ + + + + + + + + + + + +
+ +

Acceso directo — clic abre el enlace oficial

+

Estas plataformas no permiten solicitud automática. Clic en el botón abre su página oficial directamente.

+
+ + + + + + + +
+ +
0 redes seleccionadas
+
- @@ -196,31 +152,30 @@ ════════════════════════════════════════════ -->
-

Panel de acciones completo

-

- Marca cada acción completada para seguir tu progreso. Todos los enlaces apuntan a páginas oficiales. - Recuerda: descarga primero tus datos antes de eliminar nada. -

-
-
0 acciones completadas
- - -
- - - - - - +
+

Panel de acciones completo

+

+ Marca cada acción completada para seguir tu progreso. Todos los enlaces apuntan a páginas oficiales. + Recuerda: descarga primero tus datos antes de eliminar nada. +

- -
-
+
+
+ 0 acciones completadas +
+ + +
+
+ Cuentas base + +
+
- +
Descarga datos Eliminar cuenta @@ -229,7 +184,7 @@
- +
Privacidad Cerrar cuenta @@ -238,7 +193,7 @@
- +
Portal privacidad Eliminar cuenta @@ -247,7 +202,7 @@
- +
Política privacidad Cerrar cuenta @@ -256,13 +211,21 @@
+
-
-
+ +
+
+ Redes sociales + +
+
- +
Descarga datos Eliminar cuenta @@ -271,7 +234,7 @@
- +
Descarga datos Eliminar cuenta @@ -280,7 +243,7 @@
- +
Descarga datos Desactivar cuenta @@ -289,7 +252,7 @@
- +
Descarga datos Cerrar cuenta @@ -298,7 +261,7 @@
- +
Privacidad Eliminar cuenta @@ -307,7 +270,7 @@
- +
Descarga datos Eliminar cuenta @@ -316,7 +279,7 @@
- +
Privacidad Eliminar cuenta @@ -324,7 +287,7 @@
- +
Descarga datos Eliminar cuenta @@ -333,7 +296,7 @@
- +
Privacidad Eliminar cuenta @@ -342,13 +305,21 @@
+
-
-
+ +
+
+ Mensajería + +
+
- +
Solicitar datos Eliminar cuenta @@ -356,7 +327,7 @@
- +
Eliminar cuenta Política privacidad @@ -364,20 +335,28 @@
- +
+
-
-
+ +
+
+ Streaming + +
+
- +
Privacidad Cerrar cuenta @@ -385,7 +364,7 @@
- +
- +
- +
Gestionar datos Cerrar canal @@ -409,13 +388,21 @@
+
-
-
+ +
+
+ Buscadores + +
+
- +
Formulario RTBF Carta GDPR @@ -423,76 +410,88 @@
- +
- +
- +
- +
+
-
-
+ +
+
+ Data brokers + +
+
- +
- +
- +
- +
- +
+
+
@@ -579,25 +578,56 @@ 'use strict'; /* ── Chips de redes ─────────────────────────── */ -const chips = document.querySelectorAll('.net-chip'); -const sendBtn = document.getElementById('gdpr-send'); -const emailIn = document.getElementById('gdpr-email'); -const results = document.getElementById('gdpr-results'); +/* Solo chips seleccionables (auto + form) — los manuales tienen acción directa */ +const chips = document.querySelectorAll('.net-chip:not(.net-chip--manual)'); +const manualChips = document.querySelectorAll('.net-chip--manual'); +const sendBtn = document.getElementById('gdpr-send'); +const emailIn = document.getElementById('gdpr-email'); +const results = document.getElementById('gdpr-results'); +const counter = document.getElementById('form-counter'); +const selectAllBtn = document.getElementById('btn-select-all'); +/* Chips seleccionables: toggle + actualiza botón */ chips.forEach(chip => { chip.addEventListener('click', () => { chip.classList.toggle('selected'); syncBtn(); }); }); + +/* Chips manuales: acción directa, abren el enlace oficial sin selección */ +manualChips.forEach(chip => { + chip.addEventListener('click', () => { + window.open(chip.dataset.link, '_blank', 'noopener noreferrer'); + chip.classList.add('chip--opened'); + setTimeout(() => chip.classList.remove('chip--opened'), 1200); + }); +}); + emailIn.addEventListener('input', syncBtn); +selectAllBtn.addEventListener('click', () => { + const allSelected = [...chips].every(c => c.classList.contains('selected')); + chips.forEach(c => c.classList.toggle('selected', !allSelected)); + selectAllBtn.textContent = allSelected ? 'Seleccionar todas' : 'Deseleccionar todas'; + syncBtn(); +}); + function validEmail(v) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); } function syncBtn() { - const ok = validEmail(emailIn.value) && - [...chips].some(c => c.classList.contains('selected')); + const selected = [...chips].filter(c => c.classList.contains('selected')); + const count = selected.length; + counter.textContent = count === 0 ? '0 redes seleccionadas' + : count === 1 ? '1 red seleccionada' + : count + ' redes seleccionadas'; + const allSel = count === chips.length; + selectAllBtn.textContent = allSel ? 'Deseleccionar todas' : 'Seleccionar todas'; + const ok = validEmail(emailIn.value) && count > 0; sendBtn.disabled = !ok; + sendBtn.textContent = ok + ? `Enviar cartas GDPR (${count})` + : 'Enviar cartas GDPR'; } /* ── Envío ──────────────────────────────────── */ @@ -613,13 +643,7 @@ sendBtn.addEventListener('click', async () => { for (const chip of selected) { const provider = chip.dataset.provider; const type = chip.dataset.type; - const name = chip.textContent.trim(); - - if (type === 'link') { - window.open(chip.dataset.link, '_blank', 'noopener noreferrer'); - addResult(name, 'manual', 'Enlace oficial abierto — completa la eliminación manualmente.'); - continue; - } + const name = chip.querySelector('.chip-label')?.textContent.trim() || chip.textContent.trim(); if (type === 'form') { window.open(chip.dataset.formUrl, '_blank', 'noopener noreferrer'); @@ -650,10 +674,7 @@ sendBtn.addEventListener('click', async () => { } sendBtn.textContent = 'Solicitudes enviadas ✓'; - setTimeout(() => { - sendBtn.textContent = 'Enviar solicitudes GDPR'; - syncBtn(); - }, 4000); + setTimeout(() => { syncBtn(); }, 4000); }); function addResult(name, type, msg) { @@ -663,18 +684,7 @@ 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 ─────────────────────── */ +/* ── Panel checklist ────────────────────────── */ const cbs = document.querySelectorAll('.progress-cb'); const fill = document.getElementById('progress-fill'); const label = document.getElementById('progress-text'); @@ -687,6 +697,35 @@ function updateProgress() { } cbs.forEach(cb => cb.addEventListener('change', updateProgress)); updateProgress(); + +/* ── Marcar sección completa ─────────────────── */ +document.querySelectorAll('.btn-mark-section, .btn--section-send').forEach(btn => { + btn.addEventListener('click', () => { + const grid = document.getElementById('psec-' + btn.dataset.section); + if (!grid) return; + const boxes = grid.querySelectorAll('.progress-cb'); + const allDone = [...boxes].every(cb => cb.checked); + boxes.forEach(cb => { cb.checked = !allDone; }); + updateProgress(); + /* Actualizar texto del botón "Marcar todas" de esta sección */ + const secHeader = grid.closest('.panel-section').querySelector('.btn-mark-section'); + if (secHeader) secHeader.textContent = allDone ? 'Marcar todas' : 'Desmarcar todas'; + const secFooter = grid.closest('.panel-section').querySelector('.btn--section-send'); + if (secFooter) secFooter.textContent = allDone ? '✓ Marcar sección completada' : '↩ Desmarcar sección'; + }); +}); + +/* ── Carrusel de citas — rota cada 6 s ── */ +(function () { + const quotes = document.querySelectorAll('.hero-quote'); + if (!quotes.length) return; + let current = 0; + setInterval(() => { + quotes[current].classList.remove('active'); + current = (current + 1) % quotes.length; + quotes[current].classList.add('active'); + }, 6000); +})(); diff --git a/public/plantillas.html b/public/plantillas.html index acbc8d7..01a0828 100644 --- a/public/plantillas.html +++ b/public/plantillas.html @@ -217,6 +217,7 @@ Concienciación Resetea Egosurfing + Estadísticas
@@ -637,6 +638,28 @@ En aplicación del artículo 21.3 RGPD, en el caso de tratamiento con fines de m } }; +/* ============================================================ + UTILIDADES DE SEGURIDAD + esc() — escapa HTML para inserción en innerHTML + safeHref() — valida que una URL sea http/https (bloquea javascript:) + ============================================================ */ +function esc(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function safeHref(url) { + try { + const u = new URL(String(url || '')); + if (u.protocol !== 'http:' && u.protocol !== 'https:') return '#'; + return u.href; + } catch { return '#'; } +} + /* ============================================================ ESTADO ============================================================ */ @@ -654,8 +677,8 @@ document.addEventListener('DOMContentLoaded', () => { function renderPlatformGrid() { const grid = document.getElementById('platform-grid'); grid.innerHTML = Object.entries(PLATFORMS).map(([key, p]) => ` - `).join(''); } @@ -663,8 +686,8 @@ function renderPlatformGrid() { function renderTypeGrid() { const grid = document.getElementById('type-grid'); grid.innerHTML = Object.entries(REQUEST_TYPES).map(([key, t]) => ` - `).join(''); } @@ -681,14 +704,14 @@ function showPlatformInfo(key) { const p = PLATFORMS[key]; const info = document.getElementById('platform-info'); info.innerHTML = ` -
Empresa:${p.company}
-
Dirección:${p.address}
+
Empresa:${esc(p.company)}
+
Dirección:${esc(p.address)}
DPO / Privacidad:${ - p.dpoEmail ? `${p.dpoEmail}` : '—' + p.dpoEmail ? `${esc(p.dpoEmail)}` : '—' }
-
Eliminar cuenta:Abrir enlace oficial
- ${p.formUrl ? `
Formulario GDPR:Formulario oficial
` : ''} - ${p.notes ? `
Nota:${p.notes}
` : ''} +
Eliminar cuenta:Abrir enlace oficial
+ ${p.formUrl ? `
Formulario GDPR:Formulario oficial
` : ''} + ${p.notes ? `
Nota:${esc(p.notes)}
` : ''} `; info.classList.add('visible'); } @@ -788,12 +811,12 @@ ${address ? address + '\n' : ''}${today}`;
  1. Copia la carta con el botón "Copiar carta".
  2. ${p.dpoEmail - ? `Envíala por email a ${p.dpoEmail}.` - : `Usa el formulario oficial: abrir formulario.` + ? `Envíala por email a ${esc(p.dpoEmail)}.` + : `Usa el formulario oficial: abrir formulario.` }
  3. Guarda el acuse de recibo (captura o email de confirmación). El plazo de 30 días empieza desde la recepción.
  4. -
  5. Si no responden antes del ${deadline}, presenta reclamación en la sede AEPD.
  6. - ${p.deleteUrl ? `
  7. Si también quieres eliminar tu cuenta, accede aquí: ${p.name} — eliminación de cuenta.
  8. ` : ''} +
  9. Si no responden antes del ${esc(deadline)}, presenta reclamación en la sede AEPD.
  10. + ${p.deleteUrl ? `
  11. Si también quieres eliminar tu cuenta, accede aquí: ${esc(p.name)} — eliminación de cuenta.
  12. ` : ''}
`; diff --git a/public/stats.html b/public/stats.html new file mode 100644 index 0000000..a3f3cab --- /dev/null +++ b/public/stats.html @@ -0,0 +1,374 @@ + + + + + + RESETEA.NET · Estadísticas + + + + + + + + + + +
+

ESTADÍSTICAS

+

Actividad anónima de resetea.net — contadores agregados, sin ningún dato personal.

+
+ +
+
+
+

Cargando datos... +
+
+
+ + + + diff --git a/public/tipos.html b/public/tipos.html index 57e3bff..96ca1fa 100644 --- a/public/tipos.html +++ b/public/tipos.html @@ -26,6 +26,7 @@ Concienciación Resetea Egosurfing + Estadísticas