Files
carlitosbondandClaude Sonnet 5 6ee4022d21 Add fotos.carloselugo.com photo-upload service
Event photo upload app with OAuth2 (Google Drive), Caddy route, and
env-var-driven compose config. Refresh token persists to token.json
so re-auth isn't needed after container restarts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 23:24:00 +00:00

486 lines
18 KiB
JavaScript

/**
* ============================================================
* 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(`
<body style="font-family:sans-serif;text-align:center;padding:60px 20px;">
<h2>❌ Autorización cancelada</h2>
<p>${error}</p>
<a href="/admin">Volver al panel</a>
</body>
`);
}
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(`
<body style="font-family:sans-serif;text-align:center;padding:60px 20px;">
<h2>⚠️ Autorización correcta, pero sin refresh token nuevo</h2>
<p>Google no devolvió un refresh_token esta vez. Intenta de nuevo, o revoca el acceso previo en
<a href="https://myaccount.google.com/permissions" target="_blank">myaccount.google.com/permissions</a> y reintenta.</p>
<a href="/admin">Volver al panel</a>
</body>
`);
}
writeRefreshToken(tokens.refresh_token);
return res.status(200).send(`
<body style="font-family:sans-serif;text-align:center;padding:60px 20px;">
<h2>✅ Token de Google actualizado</h2>
<p>El servidor ya está usando el nuevo refresh token. No hace falta reiniciar nada.</p>
<a href="/admin">Volver al panel</a>
</body>
`);
} catch (err) {
console.error('Error intercambiando el code por tokens:', err.message);
return res.status(500).send(`
<body style="font-family:sans-serif;text-align:center;padding:60px 20px;">
<h2>❌ Error al generar el token</h2>
<p>${err.message}</p>
<a href="/admin">Volver al panel</a>
</body>
`);
}
});
// ------------------------------------------------------------------
// 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)'}`);
});