feat: rediseño UI completo + infra email + stats
This commit is contained in:
parent
93d75ddafe
commit
24401c0ee5
37 changed files with 2162 additions and 412 deletions
10
api/app.js
10
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' }));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
55
api/routes/stats.js
Normal file
55
api/routes/stats.js
Normal file
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
55
api/services/stats.js
Normal file
55
api/services/stats.js
Normal file
|
|
@ -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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue