511 lines
17 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);
// FB_DATA_DIR env var allows Docker to mount a persistent volume for tokens
// Default: <server_dir>/data (development)
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');
// ── Encryption helpers (AES-256-GCM) ─────────────────────────────────────────
// Key is derived from a secret stored in config; if none, tokens are stored as-is.
const ENCRYPTION_SECRET = process.env.FB_ENCRYPTION_SECRET || 'restreamer-ui-fb-secret-key-32x!';
function deriveKey(secret) {
return crypto.createHash('sha256').update(secret).digest(); // 32 bytes
}
function encrypt(text) {
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; // fallback: store plain if crypto fails
}
}
function decrypt(data) {
try {
if (!data || !data.includes(':')) return data;
const [ivHex, tagHex, encHex] = data.split(':');
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; // fallback: return as-is if decryption fails
}
}
// ── config.json persistence ───────────────────────────────────────────────────
/**
* Schema in config.json:
* {
* "__config": { "app_id": "...", "app_secret": "..." },
* "<fb_user_id>": {
* "fb_user_id": string, // PK — Facebook User ID
* "name": string, // Display name
* "token_type": "USER"|"PAGE", // Token type
* "access_token": string, // AES-256-GCM encrypted long-lived token
* "expires_at": number, // Unix ms — when the token expires
* "scope_granted": string[], // Scopes accepted by the user
* "pages": Page[], // List of managed pages (each has its own long-lived token)
* "updated_at": number // Last update Unix ms
* }
* }
*/
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 Graph API helpers ────────────────────────────────────────────────
/**
* Simple GET to Facebook Graph API returning parsed JSON.
*/
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);
});
}
/**
* POST form-encoded data to Facebook Graph API.
*/
function fbPost(url, params) {
return new Promise((resolve, reject) => {
const body = new URLSearchParams(params).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(body),
},
};
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: ' + buf.slice(0, 200))); }
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
/**
* Exchange short-lived user token → long-lived user token (~60 days).
*/
async function exchangeToLongLived(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 }
}
/**
* Exchange auth code → short-lived token → long-lived token.
*/
async function exchangeCodeToLongLived(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}`);
const shortToken = step1.access_token;
// Step 2: short-lived → long-lived (~60 days)
const step2 = await exchangeToLongLived(appId, appSecret, shortToken);
return { shortToken, ...step2 };
}
/**
* Fetch long-lived tokens for all pages managed by the user.
* Page tokens from /me/accounts with a long-lived user token are already long-lived.
*/
async function fetchPages(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, // long-lived page token
token_type: 'PAGE',
}));
}
/**
* Fetch basic user info.
*/
async function fetchUserInfo(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;
}
/**
* Parse granted scopes from token debug info.
*/
async function debugToken(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, // convert to ms
};
}
// ── Serialize / deserialize account (encrypts access_token) ──────────────────
function serializeAccount(acc) {
return {
...acc,
access_token: encrypt(acc.access_token || ''),
pages: (acc.pages || []).map((p) => ({
...p,
access_token: encrypt(p.access_token || ''),
})),
};
}
function deserializeAccount(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 || ''),
})),
};
}
/** Strip access_token from account before sending to client UI */
function publicAccount(acc) {
// eslint-disable-next-line no-unused-vars
const { access_token, pages, ...rest } = acc;
return {
...rest,
pages: (pages || []).map(({ access_token: _t, ...p }) => p),
};
}
// ── 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() });
});
// ── App config (app_id + app_secret) ─────────────────────────────────────────
app.get('/fb/config', (_, res) => {
const cfg = loadCfg();
const c = cfg.__config || {};
res.json({ app_id: c.app_id || '', has_secret: !!(c.app_secret) });
});
app.put('/fb/config', (req, res) => {
const { app_id, app_secret } = req.body || {};
const cfg = loadCfg();
cfg.__config = {
...(cfg.__config || {}),
...(app_id !== undefined ? { app_id: String(app_id) } : {}),
...(app_secret !== undefined ? { app_secret: String(app_secret) } : {}),
};
saveCfg(cfg);
res.json({ ok: true });
});
// ── List accounts (no tokens exposed) ────────────────────────────────────────
app.get('/fb/accounts', (_, res) => {
const cfg = loadCfg();
const accounts = Object.values(cfg)
.filter((v) => v && v.fb_user_id && v.fb_user_id !== '__config')
.map(deserializeAccount)
.map(publicAccount);
res.json(accounts);
});
// ── Get single account token (for internal use — stream key generation) ───────
// Returns full account including decrypted token
app.get('/fb/accounts/:id/token', (req, res) => {
const cfg = loadCfg();
const raw = cfg[req.params.id];
if (!raw) return res.status(404).json({ error: 'Account not found' });
const acc = deserializeAccount(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 || [],
});
});
// ── Delete account ────────────────────────────────────────────────────────────
app.delete('/fb/accounts/:id', (req, res) => {
const cfg = loadCfg();
if (!cfg[req.params.id]) return res.status(404).json({ error: 'Account not found' });
delete cfg[req.params.id];
saveCfg(cfg);
res.json({ ok: true });
});
// ── MAIN FLOW: Exchange auth code → short-lived → long-lived token ─────────────
/**
* POST /fb/exchange
* Body: { code: string, redirect_uri: string }
*
* Flow:
* 1. code → short-lived user token (Graph /oauth/access_token)
* 2. short → long-lived user token (Graph /oauth/access_token?grant_type=fb_exchange_token)
* 3. Fetch user info (id, name)
* 4. Debug token (scopes, expires_at)
* 5. Fetch pages with long-lived page tokens
* 6. Persist encrypted in config.json
* 7. Return public account (no tokens)
*/
app.post('/fb/exchange', async (req, res) => {
const { code, redirect_uri } = req.body || {};
if (!code || !redirect_uri) {
return res.status(400).json({ error: 'code and redirect_uri are required' });
}
const cfg = loadCfg();
const c = cfg.__config || {};
if (!c.app_id || !c.app_secret) {
return res.status(400).json({ error: 'Facebook App ID and App Secret are not configured. Go to Settings → Integrations.' });
}
try {
// Step 1 + 2: code → long-lived user token
const { access_token: longToken, expires_in } = await exchangeCodeToLongLived(
c.app_id, c.app_secret, code, redirect_uri
);
// expires_in is in seconds; Facebook long-lived tokens expire in ~60 days
const expires_at = expires_in
? Date.now() + parseInt(expires_in, 10) * 1000
: Date.now() + 60 * 24 * 60 * 60 * 1000; // fallback: 60 days
// Step 3: user info
const userInfo = await fetchUserInfo(longToken);
// Step 4: scopes
const { scopes } = await debugToken(c.app_id, c.app_secret, longToken);
// Step 5: pages (already long-lived when fetched with long-lived user token)
let pages = [];
try { pages = await fetchPages(longToken); } catch (_) { /* user may have no pages */ }
// Step 6: persist
const account = {
fb_user_id: userInfo.id,
name: userInfo.name,
token_type: 'USER',
access_token: longToken, // will be encrypted by serializeAccount
expires_at,
scope_granted: scopes,
pages,
updated_at: Date.now(),
};
cfg[userInfo.id] = serializeAccount(account);
saveCfg(cfg);
// Step 7: respond with public info
res.json({ ok: true, account: publicAccount(account) });
} catch (err) {
console.error('[fb/exchange] Error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ── Refresh: exchange a still-valid token for a new long-lived one ────────────
/**
* POST /fb/refresh/:id
* Re-exchanges the stored long-lived token for a fresh one.
* Facebook supports this while the token is still valid.
*/
app.post('/fb/refresh/:id', async (req, res) => {
const cfg = loadCfg();
const raw = cfg[req.params.id];
if (!raw) return res.status(404).json({ error: 'Account not found' });
const c = cfg.__config || {};
if (!c.app_id || !c.app_secret) {
return res.status(400).json({ error: 'App ID / Secret not configured' });
}
const acc = deserializeAccount(raw);
try {
const { access_token: newToken, expires_in } = await exchangeToLongLived(
c.app_id, c.app_secret, 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 debugToken(c.app_id, c.app_secret, newToken);
let pages = acc.pages || [];
try { pages = await fetchPages(newToken); } catch (_) { /* keep old */ }
const updated = {
...acc,
access_token: newToken,
expires_at,
scope_granted: scopes,
pages,
updated_at: Date.now(),
};
cfg[req.params.id] = serializeAccount(updated);
saveCfg(cfg);
res.json({ ok: true, account: publicAccount(updated) });
} catch (err) {
console.error('[fb/refresh] Error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ── Manually save a short-lived token and upgrade it ─────────────────────────
/**
* POST /fb/upgrade
* Body: { access_token: string } (short-lived token from implicit flow)
* Upgrades to long-lived and saves.
*/
app.post('/fb/upgrade', async (req, res) => {
const { access_token: shortToken } = req.body || {};
if (!shortToken) return res.status(400).json({ error: 'access_token is required' });
const cfg = loadCfg();
const c = cfg.__config || {};
if (!c.app_id || !c.app_secret) {
return res.status(400).json({ error: 'App ID / Secret not configured. Cannot upgrade token.' });
}
try {
const { access_token: longToken, expires_in } = await exchangeToLongLived(
c.app_id, c.app_secret, shortToken
);
const expires_at = expires_in
? Date.now() + parseInt(expires_in, 10) * 1000
: Date.now() + 60 * 24 * 60 * 60 * 1000;
const userInfo = await fetchUserInfo(longToken);
const { scopes } = await debugToken(c.app_id, c.app_secret, longToken);
let pages = [];
try { pages = await fetchPages(longToken); } catch (_) { /* no pages */ }
const account = {
fb_user_id: userInfo.id,
name: userInfo.name,
token_type: 'USER',
access_token: longToken,
expires_at,
scope_granted: scopes,
pages,
updated_at: Date.now(),
};
cfg[userInfo.id] = serializeAccount(account);
saveCfg(cfg);
res.json({ ok: true, account: publicAccount(account) });
} catch (err) {
console.error('[fb/upgrade] Error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ── Start server ──────────────────────────────────────────────────────────────
app.listen(PORT, '0.0.0.0', () => {
console.log(`\n[fb-server] ✅ http://0.0.0.0:${PORT}`);
console.log(`[fb-server] 💾 Config: ${CFG_PATH}`);
console.log(`[fb-server] 🔐 Encryption: AES-256-GCM\n`);
console.log(' GET /health');
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(' POST /fb/exchange { code, redirect_uri } ← Auth Code flow');
console.log(' POST /fb/refresh/:id ← Renew long-lived token');
console.log(' POST /fb/upgrade { access_token } ← Upgrade short-lived token\n');
});
process.on('SIGINT', () => process.exit(0));
process.on('SIGTERM', () => process.exit(0));