diff --git a/.gitignore b/.gitignore index 14f025d..9a4c763 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,12 @@ dozzle/data/users.yml # ntfy user database (contains credentials) ntfy/config/user.db + +# Photo upload (fotos.carloselugo.com) +photo-upload/credentials.json +photo-upload/node_modules/ +photo-upload/tmp_uploads/* +!photo-upload/tmp_uploads/.gitkeep +photo-upload/upload_status.json +photo-upload/token.json +photo-upload/env.backup diff --git a/Caddyfile b/Caddyfile index 9bb3708..626eb2c 100644 --- a/Caddyfile +++ b/Caddyfile @@ -191,3 +191,14 @@ hits.carloselugo.com { format json } } + +# ── Fotos del Evento — fotos.carloselugo.com ─────────────────────────────────── +fotos.carloselugo.com { + import security_headers + reverse_proxy fotos:3000 + encode gzip + log { + output file /var/log/caddy/fotos.log + format json + } +} diff --git a/README.md b/README.md index 7cdf1dc..9f6bcc4 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Self-hosted infrastructure running on a single server, reverse-proxied by Caddy | ntfy | [ntfy.carloselugo.com](https://ntfy.carloselugo.com) | Push notifications | | Dozzle | [logs.carloselugo.com](https://logs.carloselugo.com) | Container log viewer | | Gitea | [git.carloselugo.com](https://git.carloselugo.com) | Self-hosted Git | +| Fotos | [fotos.carloselugo.com](https://fotos.carloselugo.com) | Event photo upload → Google Drive (OAuth2, admin panel at `/admin`) | > uptime, metrics, and logs are restricted to whitelisted IPs + basic auth. @@ -78,3 +79,4 @@ git push - Umami analytics is disabled (commented out in `docker-compose.yml`) — uncomment to re-enable. - Netdata is pinned to CPUs 0–1 with low CPU priority to avoid impacting other workloads. - ntfy denies all access by default — users must be created with `ntfy user add`. +- The `fotos` service reads its secrets from `/opt/web/.env` (compose-level, gitignored) — `DRIVE_FOLDER_ID`, `ADMIN_PASSWORD`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_OAUTH_REFRESH_TOKEN`, `OAUTH_REDIRECT_URI`. The live refresh token is persisted separately in `photo-upload/token.json` (bind-mounted, gitignored, overwrites the bootstrap `.env` value once regenerated from `/admin`). The OAuth client in Google Cloud Console must have `OAUTH_REDIRECT_URI`'s exact value registered under Authorized redirect URIs, and — while the app is in Testing publishing status — the signing-in Google account must be added under Audience → Test users, or Google blocks the callback. diff --git a/docker-compose.yml b/docker-compose.yml index baddedd..b2e7f8f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -213,6 +213,26 @@ services: # sysctls: # - net.ipv6.conf.all.disable_ipv6=1 + # ─── Fotos del Evento — fotos.carloselugo.com ───────────────────────────── + fotos: + build: ./photo-upload + container_name: fotos + restart: unless-stopped + environment: + - DRIVE_FOLDER_ID=${DRIVE_FOLDER_ID} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} + - GOOGLE_OAUTH_REFRESH_TOKEN=${GOOGLE_OAUTH_REFRESH_TOKEN} + - OAUTH_REDIRECT_URI=${OAUTH_REDIRECT_URI} + - PORT=3000 + volumes: + - ./photo-upload/upload_status.json:/app/upload_status.json + - ./photo-upload/token.json:/app/token.json + networks: + - web-net + # No exponemos puertos al host: Caddy llega por la red interna + # ─── Hit Counter ─────────────────────────────────────────────────────────── hit-counter: build: ./hit-counter diff --git a/photo-upload/.env.example b/photo-upload/.env.example new file mode 100644 index 0000000..efa1afa --- /dev/null +++ b/photo-upload/.env.example @@ -0,0 +1,6 @@ +DRIVE_FOLDER_ID=1AbCdEfGhIjKlMnOpQrStUvWxYz +GOOGLE_OAUTH_CLIENT_ID=xxxxx.apps.googleusercontent.com +GOOGLE_OAUTH_CLIENT_SECRET=xxxxx +GOOGLE_OAUTH_REFRESH_TOKEN=xxxxx +PORT=3000 +ADMIN_PASSWORD=cambia-esta-clave diff --git a/photo-upload/Dockerfile b/photo-upload/Dockerfile new file mode 100644 index 0000000..9f97e52 --- /dev/null +++ b/photo-upload/Dockerfile @@ -0,0 +1,15 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install --omit=dev + +COPY . . + +# tmp_uploads se crea en runtime, pero lo dejamos listo por si acaso +RUN mkdir -p tmp_uploads + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/photo-upload/get-refresh-token.js b/photo-upload/get-refresh-token.js new file mode 100644 index 0000000..5e6eb10 --- /dev/null +++ b/photo-upload/get-refresh-token.js @@ -0,0 +1,59 @@ +/** + * Utilidad de un solo uso para obtener un refresh token de OAuth2 para la + * cuenta personal de Google dueña de la carpeta de Drive (necesario porque + * las Service Accounts no tienen cuota de almacenamiento en Drive personal). + * + * Uso: + * node get-refresh-token.js + * + * 1. Imprime una URL de autorización. + * 2. Ábrela en un navegador (donde sea, no tiene que ser este servidor), + * inicia sesión con la cuenta de Google dueña de la carpeta y autoriza. + * 3. El navegador redirige a una URL rota tipo http://localhost/?code=XXXX + * (dará error de conexión, es normal). Copia el valor de "code" de la + * barra de direcciones. + * 4. Pega el código aquí cuando el script lo pida. + * 5. El script imprime el refresh_token — guárdalo en GOOGLE_OAUTH_REFRESH_TOKEN. + */ + +'use strict'; + +const readline = require('readline'); +const { google } = require('googleapis'); + +const [, , CLIENT_ID, CLIENT_SECRET] = process.argv; + +if (!CLIENT_ID || !CLIENT_SECRET) { + console.error('Uso: node get-refresh-token.js '); + process.exit(1); +} + +const REDIRECT_URI = 'http://localhost'; + +const oauth2Client = new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI); + +const authUrl = oauth2Client.generateAuthUrl({ + access_type: 'offline', + prompt: 'consent', + scope: ['https://www.googleapis.com/auth/drive'] +}); + +console.log('\n1. Abre esta URL en un navegador (con la cuenta de Google dueña de la carpeta):\n'); +console.log(authUrl); +console.log('\n2. Autoriza. Te redirigirá a una URL rota (http://localhost/?code=...) — está bien.'); +console.log('3. Copia el valor de "code" de esa URL y pégalo abajo.\n'); + +const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + +rl.question('Código: ', async (code) => { + rl.close(); + try { + const { tokens } = await oauth2Client.getToken(code.trim()); + console.log('\n✅ Refresh token obtenido:\n'); + console.log(tokens.refresh_token); + console.log('\nGuárdalo como GOOGLE_OAUTH_REFRESH_TOKEN.\n'); + } catch (err) { + console.error('\n❌ Error al intercambiar el código:', err.message); + process.exit(1); + } +}); diff --git a/photo-upload/package.json b/photo-upload/package.json new file mode 100644 index 0000000..09c04df --- /dev/null +++ b/photo-upload/package.json @@ -0,0 +1,20 @@ +{ + "name": "evento-fotos-upload", + "version": "1.0.0", + "description": "Landing page de cero fricción para subir fotos de un evento directo a Google Drive, sin login ni registro.", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "googleapis": "^140.0.1", + "multer": "^1.4.5-lts.1" + } +} diff --git a/photo-upload/public/admin.html b/photo-upload/public/admin.html new file mode 100644 index 0000000..1f61f7b --- /dev/null +++ b/photo-upload/public/admin.html @@ -0,0 +1,345 @@ + + + + + +Admin · Fotos del Evento + + + + + + + +
+

🔐 Panel de administración

+ +
+ + +
+ +
+

Controla si los asistentes pueden subir fotos ahora mismo.

+ +
+ +
+ +
+

Si las subidas empiezan a fallar por autenticación con Google, genera un token nuevo.

+ +
Verificando estado del token...
+
+
+ +
+
+ + + + diff --git a/photo-upload/public/assets/logo.png b/photo-upload/public/assets/logo.png new file mode 100644 index 0000000..99ca212 Binary files /dev/null and b/photo-upload/public/assets/logo.png differ diff --git a/photo-upload/public/index.html b/photo-upload/public/index.html new file mode 100644 index 0000000..484d374 --- /dev/null +++ b/photo-upload/public/index.html @@ -0,0 +1,392 @@ + + + + + +Fotos del Evento + + + + + + + +
+
+ +

Selecciona las fotos o videos de tu galería.
Se suben automáticamente, sin necesidad de registrarte.

+ + + + + + + + +
+
+
+ +
+
+
+
+ +
War Hawks · Reencuentro de Clase Graduada
+ + + + + \ No newline at end of file diff --git a/photo-upload/server.js b/photo-upload/server.js new file mode 100644 index 0000000..d589c01 --- /dev/null +++ b/photo-upload/server.js @@ -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(` + +

❌ 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)'}`); +}); diff --git a/photo-upload/tmp_uploads/.gitkeep b/photo-upload/tmp_uploads/.gitkeep new file mode 100644 index 0000000..e69de29