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>
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* 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)'}`);
|
||||
});
|
||||
Reference in New Issue
Block a user