/** * ============================================================ * SERVER.JS * Backend "cero fricción" para subida de fotos de evento. * * Flujo: * 1. El cliente envía fotos/videos vía multipart/form-data (campo "photos"). * 2. Multer las guarda temporalmente en disco (./tmp_uploads). * 3. El servidor se autentica en Google Drive vía OAuth2 (cuenta personal). * 4. Cada archivo se sube a una carpeta específica de Drive (FOLDER_ID). * 5. El archivo temporal local se borra (limpieza automática). * 6. Se responde al cliente con el resultado. * * Requisitos de entorno (.env): * GOOGLE_CLIENT_ID = Client ID del OAuth client (Web application) * GOOGLE_CLIENT_SECRET = Client Secret del mismo OAuth client * GOOGLE_OAUTH_REFRESH_TOKEN = refresh token inicial (opcional si ya existe token.json) * (también se acepta GOOGLE_REFRESH_TOKEN como alias) * OAUTH_REDIRECT_URI = https://tu-dominio/admin/oauth/callback * DRIVE_FOLDER_ID = ID de la carpeta compartida de Drive * PORT = 3000 (opcional) * ADMIN_PASSWORD = contraseña para el panel /admin * * Panel de administración: * GET /admin -> vista con toggle ON/OFF + botón de token * GET /status -> { enabled } (pública, la consulta el frontend) * POST /admin/toggle -> { password, enabled } activa/desactiva subidas * GET /admin/oauth/start -> ?password=... inicia el flujo de reautorización * GET /admin/oauth/callback -> recibe el "code" de Google, guarda refresh token nuevo * POST /admin/oauth/token-info -> { password } devuelve cuándo se generó el token actual * * El estado de subidas se persiste en upload_status.json. * El refresh token vigente se persiste en token.json (sobrescribe el de .env). * ============================================================ */ 'use strict'; require('dotenv').config(); const express = require('express'); const cors = require('cors'); const multer = require('multer'); const fs = require('fs'); const path = require('path'); const { google } = require('googleapis'); // ------------------------------------------------------------------ // CONFIGURACIÓN GENERAL // ------------------------------------------------------------------ const PORT = process.env.PORT || 3000; const FOLDER_ID = process.env.DRIVE_FOLDER_ID; const TMP_DIR = path.join(__dirname, 'tmp_uploads'); const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'cambia-esta-clave'; const STATUS_FILE = path.join(__dirname, 'upload_status.json'); const TOKEN_FILE = path.join(__dirname, 'token.json'); const CLIENT_ID = process.env.GOOGLE_CLIENT_ID; const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; const OAUTH_REDIRECT_URI = process.env.OAUTH_REDIRECT_URI || `http://localhost:${PORT}/admin/oauth/callback`; const OAUTH_SCOPES = ['https://www.googleapis.com/auth/drive']; if (!process.env.ADMIN_PASSWORD) { console.warn('⚠️ ADVERTENCIA: ADMIN_PASSWORD no está definida, usando valor por defecto inseguro. Configúrala en producción.'); } if (!FOLDER_ID) { console.warn('⚠️ ADVERTENCIA: la variable de entorno DRIVE_FOLDER_ID no está definida.'); } if (!CLIENT_ID || !CLIENT_SECRET) { console.warn('⚠️ ADVERTENCIA: GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET no están definidas.'); } // Aseguramos que exista el directorio temporal if (!fs.existsSync(TMP_DIR)) { fs.mkdirSync(TMP_DIR, { recursive: true }); } // ------------------------------------------------------------------ // TOKEN DE GOOGLE (refresh token) - persistido en disco // ------------------------------------------------------------------ // Prioridad: token.json (generado por el botón /admin) > .env (bootstrap inicial) function readRefreshToken() { try { const raw = fs.readFileSync(TOKEN_FILE, 'utf-8'); const data = JSON.parse(raw); if (data.refresh_token) return data.refresh_token; } catch (err) { // token.json no existe aún, caemos al valor de .env } return process.env.GOOGLE_OAUTH_REFRESH_TOKEN || process.env.GOOGLE_REFRESH_TOKEN || null; } function writeRefreshToken(refreshToken) { fs.writeFileSync(TOKEN_FILE, JSON.stringify({ refresh_token: refreshToken, updatedAt: new Date().toISOString() }, null, 2)); } function readTokenUpdatedAt() { try { const raw = fs.readFileSync(TOKEN_FILE, 'utf-8'); const data = JSON.parse(raw); return data.updatedAt || null; } catch (err) { return null; // viene de .env, sin fecha registrada } } // ------------------------------------------------------------------ // ESTADO DE SUBIDAS (activado / desactivado) - persistido en disco // ------------------------------------------------------------------ function readUploadEnabled() { try { const raw = fs.readFileSync(STATUS_FILE, 'utf-8'); const data = JSON.parse(raw); return data.enabled !== false; // por defecto true si el campo falta } catch (err) { // Si el archivo no existe aún, asumimos habilitado por defecto return true; } } function writeUploadEnabled(enabled) { fs.writeFileSync(STATUS_FILE, JSON.stringify({ enabled, updatedAt: new Date().toISOString() }, null, 2)); } // Inicializa el archivo de estado si no existe if (!fs.existsSync(STATUS_FILE)) { writeUploadEnabled(true); } // ------------------------------------------------------------------ // EXPRESS APP // ------------------------------------------------------------------ const app = express(); app.use(cors()); // Ajustar origin en producción si se sirve desde otro dominio app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); // sirve index.html // ------------------------------------------------------------------ // MULTER (procesamiento de multipart/form-data) // ------------------------------------------------------------------ const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, TMP_DIR), filename: (req, file, cb) => { // Prefijo con timestamp + random para evitar colisiones de nombres const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); const ext = path.extname(file.originalname); const safeBase = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9-_]/g, '_'); cb(null, `${safeBase}-${uniqueSuffix}${ext}`); } }); const MAX_FILE_SIZE_MB = 100; // límite razonable para fotos/videos de celular const upload = multer({ storage, limits: { fileSize: MAX_FILE_SIZE_MB * 1024 * 1024, files: 30 // máximo de archivos por request }, fileFilter: (req, file, cb) => { const allowed = /^image\/|^video\//; if (allowed.test(file.mimetype)) { cb(null, true); } else { cb(new Error('Tipo de archivo no permitido: ' + file.mimetype)); } } }); // ------------------------------------------------------------------ // GOOGLE DRIVE - AUTENTICACIÓN VÍA OAuth2 (cuenta personal) // ------------------------------------------------------------------ function getOAuth2Client() { const oauth2Client = new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET, OAUTH_REDIRECT_URI); const refreshToken = readRefreshToken(); if (refreshToken) { oauth2Client.setCredentials({ refresh_token: refreshToken }); } return oauth2Client; } function getDriveClient() { const auth = getOAuth2Client(); if (!auth.credentials || !auth.credentials.refresh_token) { throw new Error('No hay refresh token configurado. Genera uno desde /admin.'); } return google.drive({ version: 'v3', auth }); } /** * Sube un único archivo local a la carpeta de Drive configurada. * Devuelve el objeto de respuesta de la API de Drive (id, name, webViewLink). */ async function uploadFileToDrive(drive, localFilePath, originalName, mimeType) { const fileMetadata = { name: originalName, parents: FOLDER_ID ? [FOLDER_ID] : undefined }; const media = { mimeType, body: fs.createReadStream(localFilePath) }; const response = await drive.files.create({ resource: fileMetadata, media, fields: 'id, name, webViewLink' }); return response.data; } /** * Borra un archivo temporal local. No lanza si ya no existe. */ function cleanupLocalFile(filePath) { fs.unlink(filePath, (err) => { if (err && err.code !== 'ENOENT') { console.error('No se pudo borrar el archivo temporal:', filePath, err.message); } }); } // ------------------------------------------------------------------ // RUTA: POST /upload // ------------------------------------------------------------------ app.post('/upload', (req, res) => { // Puerta de entrada: si el admin desactivó las subidas, rechazamos antes // de tocar disco. Devolvemos 403 con mensaje claro para el frontend. if (!readUploadEnabled()) { return res.status(403).json({ success: false, error: 'La subida de fotos está desactivada por el momento.' }); } upload.array('photos', 30)(req, res, async (multerErr) => { if (multerErr) { console.error('Error de Multer:', multerErr.message); return res.status(400).json({ success: false, error: multerErr.message }); } const files = req.files; if (!files || files.length === 0) { return res.status(400).json({ success: false, error: 'No se recibieron archivos.' }); } let drive; try { drive = getDriveClient(); } catch (err) { console.error('Error autenticando con Google Drive:', err.message); // Limpieza aunque falle la autenticación, para no dejar basura en disco files.forEach((f) => cleanupLocalFile(f.path)); return res.status(500).json({ success: false, error: 'Error de autenticación con Google Drive.' }); } const results = []; const errors = []; // Subimos archivo por archivo. Si uno falla, seguimos con los demás // y reportamos el detalle al final (más resiliente para el usuario). for (const file of files) { try { const uploaded = await uploadFileToDrive( drive, file.path, file.originalname, file.mimetype ); results.push({ originalName: file.originalname, driveId: uploaded.id, link: uploaded.webViewLink }); } catch (err) { console.error('Error subiendo a Drive:', file.originalname, err.message); errors.push({ originalName: file.originalname, error: err.message }); } finally { // Limpieza automática del archivo temporal, suba o no exitosamente cleanupLocalFile(file.path); } } if (errors.length > 0 && results.length === 0) { // Todos fallaron return res.status(502).json({ success: false, error: 'No se pudo subir ningún archivo a Drive.', details: errors }); } return res.status(200).json({ success: true, uploaded: results.length, failed: errors.length, results, errors: errors.length > 0 ? errors : undefined }); }); }); // ------------------------------------------------------------------ // RUTA DE SALUD (útil para Render/Railway/health checks) // ------------------------------------------------------------------ app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // ------------------------------------------------------------------ // RUTA PÚBLICA: GET /status // El frontend la consulta antes de mostrar el botón de subida. // ------------------------------------------------------------------ app.get('/status', (req, res) => { res.json({ enabled: readUploadEnabled() }); }); // ------------------------------------------------------------------ // RUTA: GET /admin // Sirve la vista de administración (protegida con password en el propio // formulario/JS del frontend, ver public/admin.html). // ------------------------------------------------------------------ app.get('/admin', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin.html')); }); // ------------------------------------------------------------------ // RUTA: POST /admin/toggle // Body: { password: string, enabled: boolean } // ------------------------------------------------------------------ app.post('/admin/toggle', (req, res) => { const { password, enabled } = req.body || {}; if (password !== ADMIN_PASSWORD) { return res.status(401).json({ success: false, error: 'Contraseña incorrecta.' }); } if (typeof enabled !== 'boolean') { return res.status(400).json({ success: false, error: 'El campo "enabled" debe ser true o false.' }); } writeUploadEnabled(enabled); return res.json({ success: true, enabled }); }); // ------------------------------------------------------------------ // RUTA: POST /admin/status-check // Permite validar la contraseña y obtener el estado actual en un solo // paso, para pintar el toggle correctamente al cargar /admin. // ------------------------------------------------------------------ app.post('/admin/status-check', (req, res) => { const { password } = req.body || {}; if (password !== ADMIN_PASSWORD) { return res.status(401).json({ success: false, error: 'Contraseña incorrecta.' }); } return res.json({ success: true, enabled: readUploadEnabled() }); }); // ------------------------------------------------------------------ // RUTA: POST /admin/oauth/token-info // Devuelve cuándo se generó el refresh token vigente (o null si viene // del .env inicial y nunca se ha regenerado desde el panel). // ------------------------------------------------------------------ app.post('/admin/oauth/token-info', (req, res) => { const { password } = req.body || {}; if (password !== ADMIN_PASSWORD) { return res.status(401).json({ success: false, error: 'Contraseña incorrecta.' }); } return res.json({ success: true, hasToken: !!readRefreshToken(), updatedAt: readTokenUpdatedAt() // null si nunca se regeneró desde /admin }); }); // ------------------------------------------------------------------ // RUTA: GET /admin/oauth/start // Punto de entrada del botón "Generar nuevo token" del panel /admin. // Valida la contraseña por query string y redirige a la pantalla de // consentimiento de Google. // // Nota: la contraseña viaja en la URL (necesario porque es un redirect // de navegador, no un fetch). Va sobre HTTPS, pero por eso este link // no debe compartirse ni guardarse en marcadores. // ------------------------------------------------------------------ app.get('/admin/oauth/start', (req, res) => { const { password } = req.query; if (password !== ADMIN_PASSWORD) { return res.status(401).send('Contraseña incorrecta.'); } if (!CLIENT_ID || !CLIENT_SECRET) { return res.status(500).send('GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET no están configurados en el servidor.'); } const oauth2Client = getOAuth2Client(); const authUrl = oauth2Client.generateAuthUrl({ access_type: 'offline', // necesario para recibir refresh_token prompt: 'consent', // fuerza a Google a reemitir refresh_token siempre scope: OAUTH_SCOPES }); return res.redirect(authUrl); }); // ------------------------------------------------------------------ // RUTA: GET /admin/oauth/callback // Google redirige aquí después de que el usuario autoriza. Intercambia // el "code" por tokens y persiste el refresh_token nuevo en token.json. // ------------------------------------------------------------------ app.get('/admin/oauth/callback', async (req, res) => { const { code, error } = req.query; if (error) { return res.status(400).send(`

❌ Autorización cancelada

${error}

Volver al panel `); } if (!code) { return res.status(400).send('Falta el parámetro "code" en la respuesta de Google.'); } try { const oauth2Client = new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET, OAUTH_REDIRECT_URI); const { tokens } = await oauth2Client.getToken(code); if (!tokens.refresh_token) { // Esto puede pasar si Google decide no reemitirlo pese a prompt=consent // (raro, pero posible si la cuenta ya tenía uno activo muy reciente). return res.status(200).send(`

⚠️ Autorización correcta, pero sin refresh token nuevo

Google no devolvió un refresh_token esta vez. Intenta de nuevo, o revoca el acceso previo en myaccount.google.com/permissions y reintenta.

Volver al panel `); } writeRefreshToken(tokens.refresh_token); return res.status(200).send(`

✅ Token de Google actualizado

El servidor ya está usando el nuevo refresh token. No hace falta reiniciar nada.

Volver al panel `); } catch (err) { console.error('Error intercambiando el code por tokens:', err.message); return res.status(500).send(`

❌ Error al generar el token

${err.message}

Volver al panel `); } }); // ------------------------------------------------------------------ // MANEJO DE ERRORES GLOBAL (fallback) // ------------------------------------------------------------------ app.use((err, req, res, next) => { console.error('Error no controlado:', err); res.status(500).json({ success: false, error: 'Error interno del servidor.' }); }); // ------------------------------------------------------------------ // INICIO DEL SERVIDOR // ------------------------------------------------------------------ app.listen(PORT, () => { console.log(`🚀 Servidor escuchando en http://localhost:${PORT}`); console.log(`📁 Carpeta de Drive destino: ${FOLDER_ID || '(no configurada)'}`); });