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:
hacklab 2026-04-07 12:09:54 +02:00
parent 36b918b95d
commit 614d5af397
20 changed files with 2419 additions and 0 deletions

16
api/.env.example Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

19
api/package.json Normal file
View 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
View 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
View 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
View 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 };
};