'use strict'; const express = require('express'); const cors = require('cors'); const path = require('path'); const fs = require('fs'); const https = require('https'); const http = require('http'); const crypto = require('crypto'); const PORT = parseInt(process.env.FB_SERVER_PORT || '3002', 10); const DATA_DIR = process.env.FB_DATA_DIR ? path.resolve(process.env.FB_DATA_DIR) : path.resolve(__dirname, 'data'); const CFG_PATH = path.join(DATA_DIR, 'config.json'); const ENCRYPTION_SECRET = process.env.FB_ENCRYPTION_SECRET || 'restreamer-ui-fb-secret-key-32x!'; // ───────────────────────────────────────────────────────────────────────────── // Schema unificado de config.json // ───────────────────────────────────────────────────────────────────────────── /** * { * "__fb_config": { * "app_id": string, * "app_secret": string ← AES-256-GCM encrypted * }, * "__yt_config": { * "client_id": string, * "client_secret": string ← AES-256-GCM encrypted * }, * "fb__": { * "fb_user_id": string, // PK * "name": string, * "token_type": "USER", * "access_token": string, // AES-256-GCM encrypted long-lived token (60 días) * "expires_at": number, // Unix ms * "scope_granted": string[], * "pages": [{ * "id": string, // Page ID (PK) * "name": string, * "category": string, * "token_type": "PAGE", * "access_token": string, // AES-256-GCM encrypted long-lived page token * "tasks": string[] * }], * "updated_at": number * }, * "yt__": { * "account_key": string, // PK = channel_id o "yt_" * "label": string, // Nombre del canal * "channel_title": string, * "channel_id": string, * "access_token": string, // AES-256-GCM encrypted * "refresh_token": string, // AES-256-GCM encrypted * "token_expiry": number, // Unix ms * "updated_at": number * } * } */ // ── Encryption helpers (AES-256-GCM) ───────────────────────────────────────── function deriveKey(secret) { return crypto.createHash('sha256').update(secret).digest(); } function encrypt(text) { if (!text) return ''; try { const key = deriveKey(ENCRYPTION_SECRET); const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); const enc = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return iv.toString('hex') + ':' + tag.toString('hex') + ':' + enc.toString('hex'); } catch (_) { return text; } } function decrypt(data) { if (!data) return ''; try { if (!data.includes(':')) return data; const parts = data.split(':'); if (parts.length < 3) return data; const [ivHex, tagHex, ...encParts] = parts; const encHex = encParts.join(':'); const key = deriveKey(ENCRYPTION_SECRET); const iv = Buffer.from(ivHex, 'hex'); const tag = Buffer.from(tagHex, 'hex'); const encBuf = Buffer.from(encHex, 'hex'); const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(tag); return decipher.update(encBuf, undefined, 'utf8') + decipher.final('utf8'); } catch (_) { return data; } } // ── config.json I/O ─────────────────────────────────────────────────────────── function loadCfg() { if (!fs.existsSync(CFG_PATH)) return {}; try { return JSON.parse(fs.readFileSync(CFG_PATH, 'utf8')); } catch (_) { return {}; } } function saveCfg(data) { if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(CFG_PATH, JSON.stringify(data, null, 2), 'utf8'); } // ── Facebook serialization ──────────────────────────────────────────────────── function serializeFbAccount(acc) { return { ...acc, access_token: encrypt(acc.access_token || ''), pages: (acc.pages || []).map((p) => ({ ...p, access_token: encrypt(p.access_token || ''), })), }; } function deserializeFbAccount(acc) { if (!acc) return null; return { ...acc, access_token: decrypt(acc.access_token || ''), pages: (acc.pages || []).map((p) => ({ ...p, access_token: decrypt(p.access_token || ''), })), }; } function publicFbAccount(acc) { const { access_token, pages, ...rest } = acc; return { ...rest, pages: (pages || []).map(({ access_token: _t, ...p }) => p), restreamer_channel_id: acc.restreamer_channel_id || '', restreamer_publication_id: acc.restreamer_publication_id || '', }; } // ── YouTube serialization ───────────────────────────────────────────────────── function serializeYtAccount(acc) { return { ...acc, access_token: encrypt(acc.access_token || ''), refresh_token: encrypt(acc.refresh_token || ''), }; } function deserializeYtAccount(acc) { if (!acc) return null; return { ...acc, access_token: decrypt(acc.access_token || ''), refresh_token: decrypt(acc.refresh_token || ''), }; } function publicYtAccount(acc) { const { access_token, refresh_token, ...rest } = acc; return { ...rest, has_refresh_token: !!(refresh_token), has_access_token: !!(access_token), restreamer_channel_id: acc.restreamer_channel_id || '', restreamer_publication_id: acc.restreamer_publication_id || '', }; } // ── Facebook Graph API helpers ──────────────────────────────────────────────── function fbGet(url) { return new Promise((resolve, reject) => { const lib = url.startsWith('https') ? https : http; lib.get(url, (res) => { let body = ''; res.on('data', (c) => { body += c; }); res.on('end', () => { try { resolve(JSON.parse(body)); } catch (e) { reject(new Error('Invalid JSON from Facebook: ' + body.slice(0, 200))); } }); }).on('error', reject); }); } async function fbExchangeToLongLived(appId, appSecret, shortToken) { const data = await fbGet( `https://graph.facebook.com/v19.0/oauth/access_token` + `?grant_type=fb_exchange_token` + `&client_id=${encodeURIComponent(appId)}` + `&client_secret=${encodeURIComponent(appSecret)}` + `&fb_exchange_token=${encodeURIComponent(shortToken)}` ); if (data.error) throw new Error(`FB exchange error: ${data.error.message}`); return data; // { access_token, token_type, expires_in } } async function fbExchangeCodeToLongLived(appId, appSecret, code, redirectUri) { // Step 1: code → short-lived user token const step1 = await fbGet( `https://graph.facebook.com/v19.0/oauth/access_token` + `?client_id=${encodeURIComponent(appId)}` + `&client_secret=${encodeURIComponent(appSecret)}` + `&redirect_uri=${encodeURIComponent(redirectUri)}` + `&code=${encodeURIComponent(code)}` ); if (step1.error) throw new Error(`Code exchange error: ${step1.error.message}`); // Step 2: short-lived → long-lived (~60 days) const step2 = await fbExchangeToLongLived(appId, appSecret, step1.access_token); return { shortToken: step1.access_token, ...step2 }; } async function fbFetchPages(longLivedUserToken) { const data = await fbGet( `https://graph.facebook.com/v19.0/me/accounts` + `?fields=id,name,access_token,category,tasks` + `&access_token=${encodeURIComponent(longLivedUserToken)}` ); if (data.error) throw new Error(`fetchPages error: ${data.error.message}`); return (data.data || []).map((p) => ({ id: p.id, name: p.name, category: p.category || '', tasks: p.tasks || [], access_token: p.access_token, token_type: 'PAGE', })); } async function fbFetchUserInfo(token) { const data = await fbGet( `https://graph.facebook.com/v19.0/me?fields=id,name&access_token=${encodeURIComponent(token)}` ); if (data.error) throw new Error(data.error.message); return data; } async function fbDebugToken(appId, appSecret, token) { const data = await fbGet( `https://graph.facebook.com/v19.0/debug_token` + `?input_token=${encodeURIComponent(token)}` + `&access_token=${encodeURIComponent(appId + '|' + appSecret)}` ); if (data.error) return { scopes: [], expires_at: 0 }; const d = data.data || {}; return { scopes: d.scopes || [], expires_at: d.expires_at ? d.expires_at * 1000 : 0, }; } // ── YouTube OAuth2 helpers ──────────────────────────────────────────────────── async function ytHttpsPost(url, body) { return new Promise((resolve, reject) => { const bodyStr = typeof body === 'string' ? body : new URLSearchParams(body).toString(); const urlObj = new URL(url); const options = { hostname: urlObj.hostname, path: urlObj.pathname + urlObj.search, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(bodyStr), }, }; const req = https.request(options, (res) => { let buf = ''; res.on('data', (c) => { buf += c; }); res.on('end', () => { try { resolve(JSON.parse(buf)); } catch (e) { reject(new Error('Invalid JSON from Google: ' + buf.slice(0, 200))); } }); }); req.on('error', reject); req.write(bodyStr); req.end(); }); } async function ytFetchChannelInfo(accessToken) { return new Promise((resolve, reject) => { const url = 'https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true'; const req = https.request(url, { headers: { Authorization: 'Bearer ' + accessToken }, }, (res) => { let buf = ''; res.on('data', (c) => { buf += c; }); res.on('end', () => { try { const data = JSON.parse(buf); if (data.items && data.items.length > 0) { resolve({ channel_title: data.items[0].snippet.title, channel_id: data.items[0].id, }); } else { resolve({}); } } catch (e) { reject(e); } }); }); req.on('error', reject); req.end(); }); } // ── Express app ─────────────────────────────────────────────────────────────── const app = express(); app.use(cors({ origin: true, methods: ['GET','POST','PUT','DELETE','OPTIONS'], allowedHeaders: ['Content-Type'] })); app.use(express.json()); // ── Health ──────────────────────────────────────────────────────────────────── app.get('/health', (_, res) => { res.json({ ok: true, config: CFG_PATH, port: PORT, ts: new Date().toISOString() }); }); // ═════════════════════════════════════════════════════════════════════════════ // FACEBOOK // ═════════════════════════════════════════════════════════════════════════════ // ── FB App config (app_id + app_secret) ────────────────────────────────────── app.get('/fb/config', (_, res) => { const cfg = loadCfg(); const c = cfg.__fb_config || {}; // Soporte retrocompatibilidad con clave vieja __config const old = cfg.__config || {}; res.json({ app_id: c.app_id || old.app_id || '', has_secret: !!(c.app_secret || old.app_secret), }); }); app.put('/fb/config', (req, res) => { const { app_id, app_secret } = req.body || {}; const cfg = loadCfg(); // Migrar de __config a __fb_config si es necesario if (cfg.__config && !cfg.__fb_config) { cfg.__fb_config = { ...cfg.__config }; delete cfg.__config; } cfg.__fb_config = { ...(cfg.__fb_config || {}), ...(app_id !== undefined ? { app_id: String(app_id) } : {}), ...(app_secret !== undefined ? { app_secret: encrypt(String(app_secret)) } : {}), }; saveCfg(cfg); res.json({ ok: true }); }); // ── FB Accounts ─────────────────────────────────────────────────────────────── app.get('/fb/accounts', (_, res) => { const cfg = loadCfg(); const accounts = Object.entries(cfg) .filter(([k, v]) => k.startsWith('fb__') && v && v.fb_user_id) .map(([, v]) => publicFbAccount(deserializeFbAccount(v))); res.json(accounts); }); app.get('/fb/accounts/:id/token', (req, res) => { const cfg = loadCfg(); const raw = cfg['fb__' + req.params.id]; if (!raw) return res.status(404).json({ error: 'Account not found' }); const acc = deserializeFbAccount(raw); res.json({ fb_user_id: acc.fb_user_id, name: acc.name, token_type: acc.token_type, access_token: acc.access_token, expires_at: acc.expires_at, scope_granted: acc.scope_granted || [], pages: acc.pages || [], }); }); app.delete('/fb/accounts/:id', (req, res) => { const cfg = loadCfg(); const key = 'fb__' + req.params.id; if (!cfg[key]) return res.status(404).json({ error: 'Account not found' }); delete cfg[key]; saveCfg(cfg); res.json({ ok: true }); }); // Asociar una cuenta FB existente con un canal/publicación de Restreamer app.put('/fb/accounts/:id/context', (req, res) => { const { restreamer_channel_id, restreamer_publication_id } = req.body || {}; const cfg = loadCfg(); const key = 'fb__' + req.params.id; if (!cfg[key]) return res.status(404).json({ error: 'Account not found' }); cfg[key] = { ...cfg[key], restreamer_channel_id: restreamer_channel_id || '', restreamer_publication_id: restreamer_publication_id || '', }; saveCfg(cfg); console.log(`[fb/context] ✅ ${req.params.id} → channel:${restreamer_channel_id || '-'} pub:${restreamer_publication_id || '-'}`); res.json({ ok: true }); }); // ── FB Exchange: Auth Code → Short-lived → Long-lived (60 días) ────────────── /** * POST /fb/exchange * Body: { code, redirect_uri } * * Flujo completo: * 1. code → short-lived user token (Graph API /oauth/access_token) * 2. short-lived → long-lived token (~60 días) (grant_type=fb_exchange_token) * 3. Fetch user info (id, name) * 4. Debug token → scopes, expires_at * 5. Fetch páginas del usuario (tokens de página ya son long-lived) * 6. Guardar todo cifrado en config.json * 7. Retornar cuenta pública (sin tokens) */ app.post('/fb/exchange', async (req, res) => { const { code, redirect_uri, restreamer_channel_id, restreamer_publication_id } = req.body || {}; if (!code || !redirect_uri) { return res.status(400).json({ error: 'code and redirect_uri are required' }); } const cfg = loadCfg(); const c = cfg.__fb_config || cfg.__config || {}; if (!c.app_id) { return res.status(400).json({ error: 'Facebook App ID not configured. Go to Settings → Integrations.' }); } const appSecret = c.app_secret ? decrypt(c.app_secret) : ''; if (!appSecret) { return res.status(400).json({ error: 'Facebook App Secret not configured. Go to Settings → Integrations.' }); } try { const { access_token: longToken, expires_in } = await fbExchangeCodeToLongLived( c.app_id, appSecret, code, redirect_uri ); const expires_at = expires_in ? Date.now() + parseInt(expires_in, 10) * 1000 : Date.now() + 60 * 24 * 60 * 60 * 1000; const userInfo = await fbFetchUserInfo(longToken); const { scopes } = await fbDebugToken(c.app_id, appSecret, longToken); let pages = []; try { pages = await fbFetchPages(longToken); } catch (_) {} const account = { fb_user_id: userInfo.id, name: userInfo.name, token_type: 'USER', access_token: longToken, expires_at, scope_granted: scopes, pages, restreamer_channel_id: restreamer_channel_id || '', restreamer_publication_id: restreamer_publication_id || '', updated_at: Date.now(), }; cfg['fb__' + userInfo.id] = serializeFbAccount(account); saveCfg(cfg); console.log(`[fb/exchange] ✅ ${userInfo.name} (${userInfo.id}) — expires ${new Date(expires_at).toISOString()} → channel:${restreamer_channel_id || '-'} pub:${restreamer_publication_id || '-'}`); res.json({ ok: true, account: publicFbAccount(account) }); } catch (err) { console.error('[fb/exchange] Error:', err.message); res.status(500).json({ error: err.message }); } }); // ── FB Upgrade: short-lived token → long-lived (implicit flow fallback) ─────── app.post('/fb/upgrade', async (req, res) => { const { access_token: shortToken, restreamer_channel_id, restreamer_publication_id } = req.body || {}; if (!shortToken) return res.status(400).json({ error: 'access_token is required' }); const cfg = loadCfg(); const c = cfg.__fb_config || cfg.__config || {}; const appSecret = c.app_secret ? decrypt(c.app_secret) : c.app_secret_plain || ''; if (!c.app_id || !appSecret) { return res.status(400).json({ error: 'App ID / Secret not configured. Cannot upgrade token.' }); } try { const { access_token: longToken, expires_in } = await fbExchangeToLongLived(c.app_id, appSecret, shortToken); const expires_at = expires_in ? Date.now() + parseInt(expires_in, 10) * 1000 : Date.now() + 60 * 24 * 60 * 60 * 1000; const userInfo = await fbFetchUserInfo(longToken); const { scopes } = await fbDebugToken(c.app_id, appSecret, longToken); let pages = []; try { pages = await fbFetchPages(longToken); } catch (_) {} const account = { fb_user_id: userInfo.id, name: userInfo.name, token_type: 'USER', access_token: longToken, expires_at, scope_granted: scopes, pages, restreamer_channel_id: restreamer_channel_id || '', restreamer_publication_id: restreamer_publication_id || '', updated_at: Date.now(), }; cfg['fb__' + userInfo.id] = serializeFbAccount(account); saveCfg(cfg); console.log(`[fb/upgrade] ✅ Token upgraded: ${userInfo.name} (${userInfo.id})`); res.json({ ok: true, account: publicFbAccount(account) }); } catch (err) { console.error('[fb/upgrade] Error:', err.message); res.status(500).json({ error: err.message }); } }); // ── FB Refresh: renovar long-lived token existente ──────────────────────────── app.post('/fb/refresh/:id', async (req, res) => { const cfg = loadCfg(); const raw = cfg['fb__' + req.params.id]; if (!raw) return res.status(404).json({ error: 'Account not found' }); const c = cfg.__fb_config || cfg.__config || {}; const appSecret = c.app_secret ? decrypt(c.app_secret) : ''; if (!c.app_id || !appSecret) { return res.status(400).json({ error: 'App ID / Secret not configured' }); } const acc = deserializeFbAccount(raw); try { const { access_token: newToken, expires_in } = await fbExchangeToLongLived(c.app_id, appSecret, acc.access_token); const expires_at = expires_in ? Date.now() + parseInt(expires_in, 10) * 1000 : Date.now() + 60 * 24 * 60 * 60 * 1000; const { scopes } = await fbDebugToken(c.app_id, appSecret, newToken); let pages = acc.pages || []; try { pages = await fbFetchPages(newToken); } catch (_) {} const updated = { ...acc, access_token: newToken, expires_at, scope_granted: scopes, pages, updated_at: Date.now() }; cfg['fb__' + req.params.id] = serializeFbAccount(updated); saveCfg(cfg); console.log(`[fb/refresh] ✅ Token refreshed: ${acc.name} (${req.params.id})`); res.json({ ok: true, account: publicFbAccount(updated) }); } catch (err) { console.error('[fb/refresh] Error:', err.message); res.status(500).json({ error: err.message }); } }); // ═════════════════════════════════════════════════════════════════════════════ // YOUTUBE // ═════════════════════════════════════════════════════════════════════════════ // ── YT Credentials (client_id + client_secret) ─────────────────────────────── app.get('/yt/config', (_, res) => { const cfg = loadCfg(); const c = cfg.__yt_config || {}; res.json({ client_id: c.client_id || '', has_secret: !!(c.client_secret) }); }); app.put('/yt/config', (req, res) => { const { client_id, client_secret } = req.body || {}; const cfg = loadCfg(); cfg.__yt_config = { ...(cfg.__yt_config || {}), ...(client_id !== undefined ? { client_id: String(client_id) } : {}), ...(client_secret !== undefined ? { client_secret: encrypt(String(client_secret)) } : {}), }; saveCfg(cfg); console.log(`[yt/config] ✅ Credentials saved (client_id: ${client_id || '(unchanged)'})`); res.json({ ok: true }); }); // Versión completa con secreto descifrado (uso interno — intercambio de tokens) app.get('/yt/config/full', (_, res) => { const cfg = loadCfg(); const c = cfg.__yt_config || {}; res.json({ client_id: c.client_id || '', client_secret: c.client_secret ? decrypt(c.client_secret) : '', }); }); // ── YT Accounts ─────────────────────────────────────────────────────────────── app.get('/yt/accounts', (_, res) => { const cfg = loadCfg(); const accounts = Object.entries(cfg) .filter(([k, v]) => k.startsWith('yt__') && v && v.account_key) .map(([, v]) => publicYtAccount(deserializeYtAccount(v))); res.json(accounts); }); app.get('/yt/accounts/:key/token', (req, res) => { const cfg = loadCfg(); const raw = cfg['yt__' + req.params.key]; if (!raw) return res.status(404).json({ error: 'YT account not found' }); const acc = deserializeYtAccount(raw); res.json({ account_key: acc.account_key, label: acc.label, channel_title: acc.channel_title || '', channel_id: acc.channel_id || '', access_token: acc.access_token, refresh_token: acc.refresh_token, token_expiry: acc.token_expiry, }); }); app.delete('/yt/accounts/:key', (req, res) => { const cfg = loadCfg(); const key = 'yt__' + req.params.key; if (!cfg[key]) return res.status(404).json({ error: 'YT account not found' }); delete cfg[key]; saveCfg(cfg); res.json({ ok: true }); }); // Asociar una cuenta YT existente con un canal/publicación de Restreamer app.put('/yt/accounts/:key/context', (req, res) => { const { restreamer_channel_id, restreamer_publication_id } = req.body || {}; const cfg = loadCfg(); const key = 'yt__' + req.params.key; if (!cfg[key]) return res.status(404).json({ error: 'YT account not found' }); cfg[key] = { ...cfg[key], restreamer_channel_id: restreamer_channel_id || '', restreamer_publication_id: restreamer_publication_id || '', }; saveCfg(cfg); console.log(`[yt/context] ✅ ${req.params.key} → channel:${restreamer_channel_id || '-'} pub:${restreamer_publication_id || '-'}`); res.json({ ok: true }); }); // ── YT Save account (tras el intercambio de código en el browser) ───────────── /** * POST /yt/accounts * Body: { account_key, label, channel_title, channel_id, access_token, refresh_token, token_expiry } * * El browser ya hizo el intercambio code→token con Google. * Este endpoint solo persiste el resultado cifrado en config.json. */ app.post('/yt/accounts', (req, res) => { const { account_key, label, channel_title, channel_id, access_token, refresh_token, token_expiry, restreamer_channel_id, restreamer_publication_id, } = req.body || {}; if (!account_key || !access_token) { return res.status(400).json({ error: 'account_key and access_token are required' }); } const cfg = loadCfg(); const existing = cfg['yt__' + account_key] ? deserializeYtAccount(cfg['yt__' + account_key]) : {}; const account = { account_key, label: label || existing.label || account_key, channel_title: channel_title || existing.channel_title || '', channel_id: channel_id || existing.channel_id || '', access_token, refresh_token: refresh_token || existing.refresh_token || '', token_expiry: token_expiry || existing.token_expiry || 0, restreamer_channel_id: restreamer_channel_id || existing.restreamer_channel_id || '', restreamer_publication_id: restreamer_publication_id || existing.restreamer_publication_id || '', updated_at: Date.now(), }; cfg['yt__' + account_key] = serializeYtAccount(account); saveCfg(cfg); console.log(`[yt/accounts] ✅ Account saved: ${label || account_key} (${channel_id || account_key}) → channel:${restreamer_channel_id || '-'} pub:${restreamer_publication_id || '-'}`); res.json({ ok: true, account: publicYtAccount(account) }); }); // ── YT Refresh token: usar refresh_token para obtener nuevo access_token ────── /** * POST /yt/accounts/:key/refresh * Usa el refresh_token almacenado + credentials para obtener un nuevo access_token. */ app.post('/yt/accounts/:key/refresh', async (req, res) => { const cfg = loadCfg(); const raw = cfg['yt__' + req.params.key]; if (!raw) return res.status(404).json({ error: 'YT account not found' }); const c = cfg.__yt_config || {}; const clientSecret = c.client_secret ? decrypt(c.client_secret) : ''; if (!c.client_id || !clientSecret) { return res.status(400).json({ error: 'YouTube OAuth2 credentials not configured in Settings → Integrations' }); } const acc = deserializeYtAccount(raw); if (!acc.refresh_token) { return res.status(400).json({ error: 'No refresh token stored for this account' }); } try { const data = await ytHttpsPost('https://oauth2.googleapis.com/token', { client_id: c.client_id, client_secret: clientSecret, refresh_token: acc.refresh_token, grant_type: 'refresh_token', }); if (data.error) throw new Error(data.error_description || data.error); const updated = { ...acc, access_token: data.access_token, token_expiry: Date.now() + (data.expires_in || 3600) * 1000, updated_at: Date.now(), }; cfg['yt__' + req.params.key] = serializeYtAccount(updated); saveCfg(cfg); console.log(`[yt/refresh] ✅ Token refreshed: ${acc.label || req.params.key}`); res.json({ ok: true, access_token: data.access_token, token_expiry: updated.token_expiry }); } catch (err) { console.error('[yt/refresh] Error:', err.message); res.status(500).json({ error: err.message }); } }); // ── YT Exchange: Auth Code → tokens (server-side, sin exponer client_secret) ── /** * POST /yt/exchange * Body: { code, redirect_uri } * * Flujo: * 1. Intercambia code → access_token + refresh_token (Google /token) * 2. Obtiene info del canal * 3. Guarda cifrado en config.json */ app.post('/yt/exchange', async (req, res) => { const { code, redirect_uri, restreamer_channel_id, restreamer_publication_id } = req.body || {}; if (!code || !redirect_uri) { return res.status(400).json({ error: 'code and redirect_uri are required' }); } const cfg = loadCfg(); const c = cfg.__yt_config || {}; const clientSecret = c.client_secret ? decrypt(c.client_secret) : ''; if (!c.client_id || !clientSecret) { return res.status(400).json({ error: 'YouTube OAuth2 credentials not configured in Settings → Integrations' }); } try { const tokenData = await ytHttpsPost('https://oauth2.googleapis.com/token', { code, client_id: c.client_id, client_secret: clientSecret, redirect_uri, grant_type: 'authorization_code', }); if (tokenData.error) throw new Error(tokenData.error_description || tokenData.error); let channelInfo = {}; try { channelInfo = await ytFetchChannelInfo(tokenData.access_token); } catch (_) {} const accountKey = channelInfo.channel_id || ('yt_' + Date.now()); const channelName = channelInfo.channel_title || accountKey; const account = { account_key: accountKey, label: channelName, channel_title: channelInfo.channel_title || '', channel_id: channelInfo.channel_id || '', access_token: tokenData.access_token, refresh_token: tokenData.refresh_token || '', token_expiry: Date.now() + (tokenData.expires_in || 3600) * 1000, restreamer_channel_id: restreamer_channel_id || '', restreamer_publication_id: restreamer_publication_id || '', updated_at: Date.now(), }; cfg['yt__' + accountKey] = serializeYtAccount(account); saveCfg(cfg); console.log(`[yt/exchange] ✅ Account saved: ${channelName} (${accountKey}) → channel:${restreamer_channel_id || '-'} pub:${restreamer_publication_id || '-'}`); res.json({ ok: true, account: publicYtAccount(account) }); } catch (err) { console.error('[yt/exchange] Error:', err.message); res.status(500).json({ error: err.message }); } }); // ── Start server ────────────────────────────────────────────────────────────── app.listen(PORT, '0.0.0.0', () => { console.log(`\n[server] ✅ http://0.0.0.0:${PORT}`); console.log(`[server] 💾 Config: ${CFG_PATH}`); console.log(`[server] 🔐 Encryption: AES-256-GCM\n`); console.log(' GET /health'); console.log(' ── Facebook ──────────────────────────────────────────'); console.log(' GET /fb/config'); console.log(' PUT /fb/config { app_id, app_secret }'); console.log(' GET /fb/accounts'); console.log(' GET /fb/accounts/:id/token'); console.log(' DELETE /fb/accounts/:id'); console.log(' PUT /fb/accounts/:id/context { restreamer_channel_id, restreamer_publication_id }'); console.log(' POST /fb/exchange { code, redirect_uri, restreamer_channel_id, restreamer_publication_id } ← Auth Code→Long-lived'); console.log(' POST /fb/upgrade { access_token, restreamer_channel_id, restreamer_publication_id } ← Short-lived→Long-lived'); console.log(' POST /fb/refresh/:id ← Renew long-lived token'); console.log(' ── YouTube ───────────────────────────────────────────'); console.log(' GET /yt/config'); console.log(' PUT /yt/config { client_id, client_secret }'); console.log(' GET /yt/config/full'); console.log(' GET /yt/accounts'); console.log(' GET /yt/accounts/:key/token'); console.log(' DELETE /yt/accounts/:key'); console.log(' PUT /yt/accounts/:key/context { restreamer_channel_id, restreamer_publication_id }'); console.log(' POST /yt/accounts { account_key, access_token, restreamer_channel_id, restreamer_publication_id, ... }'); console.log(' POST /yt/exchange { code, redirect_uri, restreamer_channel_id, restreamer_publication_id } ← Auth Code flow'); console.log(' POST /yt/accounts/:key/refresh ← Refresh access_token\n'); }); process.on('SIGINT', () => process.exit(0)); process.on('SIGTERM', () => process.exit(0));