829 lines
32 KiB
JavaScript

'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>": {
* "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__<channel_id>": {
* "account_key": string, // PK = channel_id o "yt_<timestamp>"
* "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));