Add full project structure: backend API + frontend
- Move repo to project root to include both public/ and api/ - Add .gitignore excluding node_modules and .env - Include API routes (erase, gmail_oauth), services (mailer), and config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
36b918b95d
commit
614d5af397
20 changed files with 2419 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.claude/
|
||||||
16
api/.env.example
Normal file
16
api/.env.example
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# ── Copia este fichero como .env y rellena los valores ──
|
||||||
|
|
||||||
|
# Sal aleatoria para hashes de referencia (pon cualquier string largo)
|
||||||
|
SALT=pon-aqui-una-cadena-aleatoria-larga
|
||||||
|
|
||||||
|
# Google OAuth2 (Gmail API)
|
||||||
|
# Obtén estas credenciales en: https://console.cloud.google.com/
|
||||||
|
# → APIs y servicios → Credenciales → Crear credenciales → ID de cliente OAuth2
|
||||||
|
# → Tipo: Aplicación web
|
||||||
|
# → URI de redirección autorizado: https://resetea.net/api/gmail/callback
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
GOOGLE_REDIRECT_URI=https://resetea.net/api/gmail/callback
|
||||||
|
|
||||||
|
# Puerto del servidor (por defecto 8787)
|
||||||
|
PORT=8787
|
||||||
48
api/app.js
Normal file
48
api/app.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
|
||||||
|
const eraseRoute = require('./routes/erase');
|
||||||
|
const gmailOAuth = require('./routes/gmail_oauth');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.disable('x-powered-by');
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(express.json({ limit: '10kb' }));
|
||||||
|
|
||||||
|
// Rate limiting general
|
||||||
|
app.use(rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 100,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Rate limiting específico para envío de emails (más estricto)
|
||||||
|
const emailLimit = rateLimit({
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hora
|
||||||
|
max: 10,
|
||||||
|
message: { error: 'Demasiadas solicitudes de envío. Espera antes de reintentar.' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Rutas ────────────────────────────────────────────────────────
|
||||||
|
app.post('/api/erase', emailLimit, eraseRoute);
|
||||||
|
app.get('/api/gmail/auth', emailLimit, gmailOAuth.authInit);
|
||||||
|
app.get('/api/gmail/callback', gmailOAuth.authCallback);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
|
||||||
|
|
||||||
|
// ── Arranque ─────────────────────────────────────────────────────
|
||||||
|
const PORT = process.env.PORT || 8787;
|
||||||
|
app.listen(PORT, '127.0.0.1', () => {
|
||||||
|
console.log(`RESETEA backend corriendo en 127.0.0.1:${PORT}`);
|
||||||
|
if (!process.env.GOOGLE_CLIENT_ID) {
|
||||||
|
console.warn('⚠ GOOGLE_CLIENT_ID no configurado — OAuth Gmail deshabilitado');
|
||||||
|
}
|
||||||
|
});
|
||||||
1820
api/package-lock.json
generated
Normal file
1820
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
19
api/package.json
Normal file
19
api/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "resetea-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "RESETEA.NET — API backend para solicitudes GDPR",
|
||||||
|
"main": "app.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node app.js",
|
||||||
|
"dev": "node --watch app.js"
|
||||||
|
},
|
||||||
|
"engines": { "node": ">=18" },
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"express-rate-limit": "^7.3.1",
|
||||||
|
"nodemailer": "^6.9.13",
|
||||||
|
"googleapis": "^144.0.0",
|
||||||
|
"dotenv": "^16.4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
api/routes/erase.js
Normal file
55
api/routes/erase.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { sendErasureMail, PROVIDER_DATA } = require('../services/mailer');
|
||||||
|
|
||||||
|
const ALLOWED_PROVIDERS = new Set(Object.keys(PROVIDER_DATA));
|
||||||
|
|
||||||
|
module.exports = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { provider, email, nickname, phone, address, extra } = req.body;
|
||||||
|
|
||||||
|
// Validación mínima
|
||||||
|
if (!provider || !email) {
|
||||||
|
return res.status(400).json({ error: 'provider y email son obligatorios' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar proveedor conocido (previene abusos de relay)
|
||||||
|
if (!ALLOWED_PROVIDERS.has(provider)) {
|
||||||
|
return res.status(400).json({ error: 'Proveedor no soportado. Usa el formulario oficial.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validación básica de email
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return res.status(400).json({ error: 'Email inválido' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash irreversible para referencia (auditoría sin almacenar PII)
|
||||||
|
const hash = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(email + (process.env.SALT || 'resetea-default-salt'))
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
const result = await sendErasureMail({ provider, email, nickname, phone, address, extra });
|
||||||
|
|
||||||
|
if (result.skipped) {
|
||||||
|
return res.json({
|
||||||
|
status: 'use_form',
|
||||||
|
message: 'Este proveedor no acepta solicitudes por email. Usa su formulario oficial.',
|
||||||
|
formUrl: result.formUrl,
|
||||||
|
reference: hash.substring(0, 12),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PII fuera de scope aquí — solo el hash queda
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Solicitud enviada. Guarda el código de referencia.',
|
||||||
|
reference: hash.substring(0, 12),
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
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.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
131
api/routes/gmail_oauth.js
Normal file
131
api/routes/gmail_oauth.js
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
'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 { 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 }
|
||||||
|
|
||||||
|
function getOAuth2Client() {
|
||||||
|
return new google.auth.OAuth2(
|
||||||
|
process.env.GOOGLE_CLIENT_ID,
|
||||||
|
process.env.GOOGLE_CLIENT_SECRET,
|
||||||
|
process.env.GOOGLE_REDIRECT_URI || 'https://resetea.net/api/gmail/callback'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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.
|
||||||
|
exports.authInit = (req, res) => {
|
||||||
|
const { provider, name, email, nickname, phone, address, extra, requestType } = req.query;
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
const oauth2Client = getOAuth2Client();
|
||||||
|
const authUrl = oauth2Client.generateAuthUrl({
|
||||||
|
access_type: 'online', // no refresh token — uso puntual
|
||||||
|
scope: ['https://www.googleapis.com/auth/gmail.send'],
|
||||||
|
prompt: 'consent',
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.redirect(authUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 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) {
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const oauth2Client = getOAuth2Client();
|
||||||
|
const { tokens } = await oauth2Client.getToken(code);
|
||||||
|
|
||||||
|
// Enviamos el email inmediatamente (no almacenamos el token)
|
||||||
|
oauth2Client.setCredentials(tokens);
|
||||||
|
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 || '')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const letterText = buildLetterText({
|
||||||
|
providerInfo,
|
||||||
|
senderName: params.name,
|
||||||
|
senderEmail: params.email,
|
||||||
|
senderNick: params.nickname || '',
|
||||||
|
senderPhone: params.phone || '',
|
||||||
|
senderAddress: params.address || '',
|
||||||
|
extra: params.extra || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Construir email RFC 2822 en base64url
|
||||||
|
const subject = `Ejercicio derecho de supresión (RGPD Art. 17) — ${providerInfo.name}`;
|
||||||
|
const rawEmail = [
|
||||||
|
`From: ${params.name} <${params.email}>`,
|
||||||
|
`To: ${providerInfo.email}`,
|
||||||
|
`Subject: ${subject}`,
|
||||||
|
`MIME-Version: 1.0`,
|
||||||
|
`Content-Type: text/plain; charset=UTF-8`,
|
||||||
|
'',
|
||||||
|
letterText,
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const encoded = Buffer.from(rawEmail).toString('base64url');
|
||||||
|
|
||||||
|
await gmail.users.messages.send({
|
||||||
|
userId: 'me',
|
||||||
|
requestBody: { raw: encoded },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token descartado aquí (fuera de scope, GC lo recogerá)
|
||||||
|
res.redirect(`/plantillas.html?oauth=ok&provider=${encodeURIComponent(providerInfo.name)}`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Gmail OAuth error:', err.message);
|
||||||
|
res.redirect('/plantillas.html?oauth=error');
|
||||||
|
}
|
||||||
|
};
|
||||||
117
api/services/mailer.js
Normal file
117
api/services/mailer.js
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
sendmail: true,
|
||||||
|
newline: 'unix',
|
||||||
|
path: '/usr/sbin/sendmail'
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
DATOS DE PROVEEDORES
|
||||||
|
Actualizar si cambian los contactos de privacidad.
|
||||||
|
============================================================ */
|
||||||
|
const PROVIDER_DATA = {
|
||||||
|
instagram: { name: 'Instagram (Meta)', email: 'privacidad-dpo@fb.com', company: 'Meta Platforms Ireland Limited', address: '4 Grand Canal Square, Grand Canal Harbour, Dublín 2, Irlanda' },
|
||||||
|
facebook: { name: 'Facebook (Meta)', email: 'privacidad-dpo@fb.com', company: 'Meta Platforms Ireland Limited', address: '4 Grand Canal Square, Grand Canal Harbour, Dublín 2, Irlanda' },
|
||||||
|
twitter_x: { name: 'X (Twitter)', email: 'gdpr@twitter.com', company: 'Twitter International Unlimited Company', address: 'One Cumberland Place, Fenian Street, Dublín 2, D02 AX07, Irlanda' },
|
||||||
|
google: { name: 'Google', email: null, formUrl: 'https://support.google.com/policies/contact/sar_data_protection', company: 'Google Ireland Limited', address: 'Gordon House, Barrow Street, Dublín 4, Irlanda' },
|
||||||
|
linkedin: { name: 'LinkedIn', email: 'privacy@linkedin.com', company: 'LinkedIn Ireland Unlimited Company', address: 'Wilton Place, Dublín 2, Irlanda' },
|
||||||
|
tiktok: { name: 'TikTok', email: 'privacy@tiktok.com', company: 'TikTok Technology Limited', address: '10 Earlsfort Terrace, Dublín, D02 T380, Irlanda' },
|
||||||
|
snapchat: { name: 'Snapchat', email: 'privacy@snap.com', company: 'Snap Group Limited', address: '77 Shaftesbury Avenue, Londres W1D 5DU, Reino Unido' },
|
||||||
|
microsoft: { name: 'Microsoft', email: 'msprivacy@microsoft.com', company: 'Microsoft Ireland Operations Limited', address: 'One Microsoft Place, South County Business Park, Leopardstown, Dublín 18, Irlanda' },
|
||||||
|
apple: { name: 'Apple', email: 'privacy@apple.com', company: 'Apple Distribution International Ltd.', address: 'Hollyhill Industrial Estate, Hollyhill, Cork, Irlanda' },
|
||||||
|
amazon: { name: 'Amazon', email: null, formUrl: 'https://www.amazon.es/hz/contact-us/privacy-disclosure/', company: 'Amazon Europe Core S.à r.l.', address: '38 avenue John F. Kennedy, L-1855 Luxemburgo' },
|
||||||
|
reddit: { name: 'Reddit', email: 'gdpr@reddit.com', company: 'Reddit Inc.', address: '548 Market St. #16093, San Francisco, CA 94104, EE.UU.' },
|
||||||
|
discord: { name: 'Discord', email: 'privacy@discord.com', company: 'Discord Inc.', address: '444 De Haro Street, Suite 200, San Francisco, CA 94107, EE.UU.' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
PLANTILLA DE CARTA — supresión Art. 17 RGPD
|
||||||
|
============================================================ */
|
||||||
|
function buildLetterText({ providerInfo, senderName, senderEmail, senderNick, senderPhone, senderAddress, extra }) {
|
||||||
|
const today = new Date().toLocaleDateString('es-ES', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
const deadline = new Date(Date.now() + 30 * 86400000).toLocaleDateString('es-ES', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
|
const identLines = [
|
||||||
|
`Nombre: ${senderName}`,
|
||||||
|
`Email: ${senderEmail}`,
|
||||||
|
senderNick ? `Usuario / alias: ${senderNick}` : null,
|
||||||
|
senderPhone ? `Teléfono: ${senderPhone}` : null,
|
||||||
|
senderAddress ? `Dirección: ${senderAddress}` : null,
|
||||||
|
extra ? `Datos adicionales: ${extra}` : null,
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
|
||||||
|
return `${providerInfo.company}
|
||||||
|
${providerInfo.address}
|
||||||
|
${providerInfo.email ? 'DPO: ' + providerInfo.email : ''}
|
||||||
|
|
||||||
|
${today}
|
||||||
|
|
||||||
|
Asunto: Ejercicio del derecho de supresión — artículo 17 RGPD (UE 2016/679)
|
||||||
|
|
||||||
|
A quien corresponda:
|
||||||
|
|
||||||
|
Yo, ${senderName}, con los siguientes datos de contacto:
|
||||||
|
|
||||||
|
${identLines}
|
||||||
|
|
||||||
|
En ejercicio del derecho de supresión ("derecho al olvido") reconocido en el artículo 17 del Reglamento (UE) 2016/679 (RGPD) y en la Ley Orgánica 3/2018, de Protección de Datos Personales y garantía de los derechos digitales (LOPDGDD), solicito a ${providerInfo.company} la supresión completa de todos mis datos personales, incluyendo sin limitación:
|
||||||
|
|
||||||
|
· Datos de cuenta e identificadores (nombre, email, teléfono, dirección IP, identificadores de dispositivo).
|
||||||
|
· Contenidos publicados, comentarios, mensajes, imágenes y archivos.
|
||||||
|
· Datos de comportamiento, historial de actividad y preferencias inferidas.
|
||||||
|
· Datos en sistemas de backup y réplicas, en el plazo razonable comprometido.
|
||||||
|
· Datos cedidos o compartidos con terceros, con la notificación correspondiente para que también procedan a su supresión.
|
||||||
|
|
||||||
|
Fundamento mi solicitud en la retirada del consentimiento que en su momento presté y en que el tratamiento de mis datos ya no es necesario para los fines para los que fueron recabados.
|
||||||
|
|
||||||
|
Solicito confirmación escrita de la supresión una vez completada, con indicación del plazo para la eliminación de backups.
|
||||||
|
|
||||||
|
De conformidad con el artículo 12.3 RGPD, disponen de un mes desde la recepción para responder (antes del ${deadline}). En caso de denegación o silencio, me reservo el derecho a presentar reclamación ante la Agencia Española de Protección de Datos (sedeagpd.gob.es).
|
||||||
|
|
||||||
|
Atentamente,
|
||||||
|
|
||||||
|
${senderName}
|
||||||
|
${senderEmail}
|
||||||
|
${today}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
EXPORTACIONES
|
||||||
|
============================================================ */
|
||||||
|
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: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Si el proveedor no tiene email directo, no enviamos (devolvemos aviso)
|
||||||
|
if (!providerInfo.email) {
|
||||||
|
return { skipped: true, reason: 'use_form', formUrl: providerInfo.formUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
const letterText = buildLetterText({
|
||||||
|
providerInfo,
|
||||||
|
senderName: email, // El nombre real viene del campo email por compatibilidad heredada
|
||||||
|
senderEmail: email,
|
||||||
|
senderNick: nickname || '',
|
||||||
|
senderPhone: phone || '',
|
||||||
|
senderAddress: address || '',
|
||||||
|
extra: extra || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: 'privacy@resetea.net',
|
||||||
|
to: providerInfo.email,
|
||||||
|
subject: `Ejercicio derecho de supresión (RGPD Art. 17) — ${providerInfo.name}`,
|
||||||
|
text: letterText,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { skipped: false };
|
||||||
|
};
|
||||||
210
context_puppeteer.txt
Normal file
210
context_puppeteer.txt
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
═══════════════════════════════════════════════════════════════════
|
||||||
|
RESETEA.NET — CONTEXTO PARA AUTOMATIZACIÓN CON PUPPETEER (LOCAL)
|
||||||
|
Creado: 2026-04 | Estado: pendiente de implementar
|
||||||
|
═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
OBJETIVO
|
||||||
|
────────
|
||||||
|
Permitir que el propio usuario ejecute una automatización local
|
||||||
|
(Puppeteer corriendo en su máquina) para completar flujos de
|
||||||
|
eliminación de cuentas y envío de solicitudes GDPR sin ceder
|
||||||
|
credenciales a ningún servidor externo.
|
||||||
|
|
||||||
|
El servidor de resetea.net solo entrega el script. La ejecución
|
||||||
|
ocurre 100 % en el dispositivo del usuario.
|
||||||
|
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
ARQUITECTURA PROPUESTA
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
resetea.net (servidor)
|
||||||
|
└── Entrega scripts Puppeteer por plataforma vía descarga directa
|
||||||
|
scripts/puppeteer/instagram_delete.js
|
||||||
|
scripts/puppeteer/facebook_delete.js
|
||||||
|
scripts/puppeteer/twitter_deactivate.js
|
||||||
|
scripts/puppeteer/google_delete.js
|
||||||
|
scripts/puppeteer/linkedin_close.js
|
||||||
|
scripts/puppeteer/gdpr_email_sender.js ← usa Gmail local
|
||||||
|
|
||||||
|
Usuario (local)
|
||||||
|
├── Instala Node.js + Puppeteer (npx puppeteer)
|
||||||
|
├── Descarga el script de resetea.net
|
||||||
|
├── Ejecuta: node instagram_delete.js
|
||||||
|
└── El script abre Chromium, el usuario introduce credenciales
|
||||||
|
manualmente en la ventana del navegador (headful, no headless)
|
||||||
|
y el script guía los clics del flujo de eliminación.
|
||||||
|
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
DECISIONES DE DISEÑO IMPORTANTES
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
1. SIEMPRE headful (con ventana visible)
|
||||||
|
- El usuario ve exactamente qué está pasando.
|
||||||
|
- Las credenciales las teclea el usuario, no el script.
|
||||||
|
- Reduce enormemente el riesgo de bloqueo por parte de las
|
||||||
|
plataformas (2FA, detección de bots, etc.).
|
||||||
|
|
||||||
|
2. NUNCA enviar credenciales al servidor
|
||||||
|
- El script no hace fetch() con passwords.
|
||||||
|
- No hay logging de sesiones en el servidor.
|
||||||
|
|
||||||
|
3. Consentimiento explícito
|
||||||
|
- El script muestra en terminal qué va a hacer antes de cada acción.
|
||||||
|
- Requiere confirmación (Y/n) para pasos destructivos.
|
||||||
|
|
||||||
|
4. Pausa antes de acciones irreversibles
|
||||||
|
- Antes de confirmar la eliminación: pausa de 10 segundos
|
||||||
|
con mensaje claro en terminal.
|
||||||
|
|
||||||
|
5. Modo "dry-run"
|
||||||
|
- node instagram_delete.js --dry-run
|
||||||
|
- Navega y muestra qué haría pero no hace clic en "Confirmar".
|
||||||
|
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
LEGALIDAD Y TÉRMINOS DE SERVICIO
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ZONA GRIS LEGAL:
|
||||||
|
- La mayoría de ToS prohíben acceso automatizado. Sin embargo,
|
||||||
|
cuando el objetivo es ejercer un derecho legal (RGPD Art. 17)
|
||||||
|
y el usuario opera desde su propio dispositivo con sus propias
|
||||||
|
credenciales, la práctica es equiparable a usar un gestor de
|
||||||
|
contraseñas o un asistente de accesibilidad.
|
||||||
|
|
||||||
|
- El Tribunal de Justicia de la UE (sentencia hiQ vs LinkedIn, 2022)
|
||||||
|
ha matizado que bloquear el ejercicio de derechos mediante
|
||||||
|
medidas técnicas puede ser contrario al RGPD.
|
||||||
|
|
||||||
|
- Riesgo real: la plataforma puede detectar el bot y bloquear la
|
||||||
|
cuenta antes de completar la eliminación. Mitigación: modo headful
|
||||||
|
+ pasos con delays humanos (randomDelay helper).
|
||||||
|
|
||||||
|
- Recomendación: incluir en cada script un aviso legal y que el
|
||||||
|
usuario acepte explícitamente antes de ejecutar.
|
||||||
|
|
||||||
|
ACCIONES SEGURAS (bajo riesgo de detección):
|
||||||
|
✓ Navegar a la URL de eliminación y hacer click en botones
|
||||||
|
del flujo oficial.
|
||||||
|
✓ Rellenar el formulario de supresión GDPR que la plataforma
|
||||||
|
ya ofrece públicamente.
|
||||||
|
✗ Scraping masivo de datos de la plataforma.
|
||||||
|
✗ Publicar, enviar mensajes o modificar contenido.
|
||||||
|
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
STACK TÉCNICO
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Dependencias del script de usuario:
|
||||||
|
node >= 18
|
||||||
|
puppeteer >= 22 (incluye Chromium)
|
||||||
|
inquirer >= 9 (prompts en terminal)
|
||||||
|
chalk >= 5 (colores en terminal)
|
||||||
|
|
||||||
|
Instalación rápida:
|
||||||
|
npm init -y && npm install puppeteer inquirer chalk
|
||||||
|
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
ESTRUCTURA DE SCRIPT TIPO (Instagram)
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// instagram_delete.js
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
import inquirer from 'inquirer';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ url: 'https://www.instagram.com/accounts/login/', action: 'LOGIN' },
|
||||||
|
{ url: 'https://www.instagram.com/accounts/remove/request/permanent/', action: 'DELETE' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log(chalk.yellow('⚠ RESETEA.NET — Eliminación de cuenta Instagram'));
|
||||||
|
console.log(chalk.gray('Este script navega por los pasos oficiales de Instagram.'));
|
||||||
|
console.log(chalk.gray('Tus credenciales NUNCA salen de tu dispositivo.\n'));
|
||||||
|
|
||||||
|
const { confirmed } = await inquirer.prompt([{
|
||||||
|
type: 'confirm', name: 'confirmed',
|
||||||
|
message: '¿Confirmas que quieres iniciar el proceso de eliminación de tu cuenta Instagram?',
|
||||||
|
default: false,
|
||||||
|
}]);
|
||||||
|
if (!confirmed) process.exit(0);
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({ headless: false, defaultViewport: null });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
// Paso 1: Login manual
|
||||||
|
console.log(chalk.cyan('\n→ Abriendo Instagram. Introduce tus credenciales en la ventana del navegador.'));
|
||||||
|
await page.goto(STEPS[0].url, { waitUntil: 'networkidle2' });
|
||||||
|
|
||||||
|
// Espera a que el usuario haga login manualmente
|
||||||
|
await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 120000 });
|
||||||
|
|
||||||
|
// Paso 2: Navegar a eliminación
|
||||||
|
console.log(chalk.cyan('\n→ Navegando a la página de eliminación de cuenta...'));
|
||||||
|
await page.goto(STEPS[1].url, { waitUntil: 'networkidle2' });
|
||||||
|
|
||||||
|
console.log(chalk.red('\n⚠ ATENCIÓN: Tienes 10 segundos para cancelar (Ctrl+C)'));
|
||||||
|
await new Promise(r => setTimeout(r, 10000));
|
||||||
|
|
||||||
|
// TODO: Localizar y hacer clic en el botón de confirmación
|
||||||
|
// (los selectores cambian con las actualizaciones de Instagram)
|
||||||
|
// await page.click('button[data-testid="delete-account-confirm"]');
|
||||||
|
|
||||||
|
console.log(chalk.green('\n✓ Proceso completado. Cierra el navegador cuando hayas terminado.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(console.error);
|
||||||
|
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
HELPER: DELAYS HUMANOS (anti-detección)
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function randomDelay(min = 800, max = 2400) {
|
||||||
|
return new Promise(r => setTimeout(r, min + Math.random() * (max - min)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uso: await randomDelay(); entre cada acción
|
||||||
|
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
ROADMAP DE IMPLEMENTACIÓN
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Fase 1 (MVP):
|
||||||
|
[ ] Script Instagram (ruta más común)
|
||||||
|
[ ] Script Facebook
|
||||||
|
[ ] Script X/Twitter
|
||||||
|
[ ] Página de descarga en resetea.net con instrucciones claras
|
||||||
|
[ ] Aviso legal que el usuario acepta antes de descargar
|
||||||
|
|
||||||
|
Fase 2:
|
||||||
|
[ ] Script LinkedIn
|
||||||
|
[ ] Script TikTok (solo app móvil — requiere ADB o alternativa)
|
||||||
|
[ ] Script Google (flujo myaccount)
|
||||||
|
[ ] Auto-update de selectores (GitHub Actions que verifica mensualmente)
|
||||||
|
|
||||||
|
Fase 3:
|
||||||
|
[ ] Modo monitor: re-ejecuta opt-outs de data brokers cada 90 días
|
||||||
|
[ ] Script para AEPD (rellenar formulario de reclamación si no responden)
|
||||||
|
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
NOTAS OPERATIVAS
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
- Los selectores CSS de las plataformas cambian con frecuencia.
|
||||||
|
Hay que mantener los scripts actualizados (punto de fricción principal).
|
||||||
|
|
||||||
|
- Instagram y Facebook detectan Puppeteer por el user-agent y
|
||||||
|
ciertas propiedades de navigator. Usar puppeteer-extra +
|
||||||
|
stealth plugin para mitigarlo:
|
||||||
|
npm install puppeteer-extra puppeteer-extra-plugin-stealth
|
||||||
|
|
||||||
|
- TikTok solo permite eliminar cuenta desde la app móvil.
|
||||||
|
Alternativa: ADB (Android Debug Bridge) + UI Automator, o
|
||||||
|
instrucciones manuales muy detalladas con screenshots.
|
||||||
|
|
||||||
|
- Guardar los selectores en un fichero JSON separado (selectors.json)
|
||||||
|
para que sea fácil actualizarlos sin tocar la lógica del script.
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════════════════
|
||||||
|
FIN DEL CONTEXTO — resetea.net/context_puppeteer.txt
|
||||||
|
═══════════════════════════════════════════════════════════════════
|
||||||
Loading…
Add table
Add a link
Reference in a new issue