server.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. require('dotenv').config();
  2. const app = require('fastify')({ logger: false });
  3. const multipart = require('@fastify/multipart');
  4. const axios = require('axios');
  5. const fs = require('fs');
  6. const path = require('path');
  7. const crypto = require('crypto');
  8. const { pipeline } = require('stream/promises');
  9. const { ObjectId } = require('mongodb');
  10. const { getDb } = require('./utils/MongoDBConnector');
  11. const RabbitMQProducer = require('./utils/RabbitMQProducer');
  12. const UPLOAD_DIR = process.env.UPLOAD_DIR || '/uploads';
  13. const ALLOWED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.mp4', '.mov', '.avi']);
  14. const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
  15. fs.mkdirSync(UPLOAD_DIR, { recursive: true });
  16. app.register(multipart, { limits: { fileSize: MAX_FILE_SIZE } });
  17. const GRAPH_API = 'https://graph.facebook.com/v22.0';
  18. // The public base URL of this app (used for OAuth redirect_uri)
  19. const APP_BASE_URL = process.env.APP_BASE_URL || 'http://localhost:8081';
  20. // ─── CORS ────────────────────────────────────────────────────────────────────
  21. app.addHook('onSend', async (request, reply) => {
  22. reply.header('Access-Control-Allow-Origin', '*');
  23. reply.header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
  24. reply.header('Access-Control-Allow-Headers', 'Content-Type');
  25. });
  26. app.options('*', async (request, reply) => {
  27. reply.code(204).send();
  28. });
  29. // ─── Helpers ─────────────────────────────────────────────────────────────────
  30. async function getCredentials(id) {
  31. const db = await getDb();
  32. return db.collection('platform_credentials').findOne({ _id: id });
  33. }
  34. async function setCredentials(id, data) {
  35. const db = await getDb();
  36. await db.collection('platform_credentials').updateOne(
  37. { _id: id },
  38. { $set: { _id: id, ...data, updatedAt: new Date() } },
  39. { upsert: true }
  40. );
  41. }
  42. async function deleteCredentials(id) {
  43. const db = await getDb();
  44. await db.collection('platform_credentials').deleteOne({ _id: id });
  45. }
  46. // ─── Media Upload & Library ───────────────────────────────────────────────────
  47. app.post('/upload', async (request, reply) => {
  48. const data = await request.file();
  49. if (!data) return reply.code(400).send({ error: 'No file provided' });
  50. const ext = path.extname(data.filename).toLowerCase();
  51. if (!ALLOWED_EXTENSIONS.has(ext)) {
  52. data.file.resume();
  53. return reply.code(400).send({ error: `File type "${ext}" is not allowed. Allowed: jpg, jpeg, png, gif, webp, mp4, mov, avi` });
  54. }
  55. const filename = `${crypto.randomUUID()}${ext}`;
  56. const filepath = path.join(UPLOAD_DIR, filename);
  57. try {
  58. await pipeline(data.file, fs.createWriteStream(filepath));
  59. } catch (err) {
  60. console.error('[Gateway] Upload write error:', err.message);
  61. return reply.code(500).send({ error: 'Failed to save file' });
  62. }
  63. const stat = fs.statSync(filepath);
  64. const record = {
  65. filename,
  66. originalName: data.filename,
  67. url: `/media/${filename}`,
  68. mimetype: data.mimetype,
  69. size: stat.size,
  70. uploadedAt: new Date(),
  71. };
  72. try {
  73. const db = await getDb();
  74. await db.collection('media_files').insertOne(record);
  75. } catch (err) {
  76. console.error('[Gateway] Media metadata save error:', err.message);
  77. }
  78. return { url: record.url, filename, originalName: data.filename, mimetype: data.mimetype, size: stat.size };
  79. });
  80. // List all uploaded media files, newest first
  81. app.get('/media-library', async () => {
  82. const db = await getDb();
  83. const files = await db.collection('media_files').find({}).sort({ uploadedAt: -1 }).toArray();
  84. return { files };
  85. });
  86. // Delete a media file from disk and database
  87. app.delete('/media/:filename', async (request, reply) => {
  88. const { filename } = request.params;
  89. // Prevent path traversal
  90. if (!filename || filename.includes('/') || filename.includes('..') || filename.includes('\0')) {
  91. return reply.code(400).send({ error: 'Invalid filename' });
  92. }
  93. const filepath = path.join(UPLOAD_DIR, filename);
  94. try {
  95. fs.unlinkSync(filepath);
  96. } catch (err) {
  97. if (err.code !== 'ENOENT') {
  98. console.error('[Gateway] Delete error:', err.message);
  99. return reply.code(500).send({ error: 'Failed to delete file' });
  100. }
  101. // Already gone from disk — still clean up DB record
  102. }
  103. const db = await getDb();
  104. await db.collection('media_files').deleteOne({ filename });
  105. return { success: true };
  106. });
  107. // ─── Drafts ──────────────────────────────────────────────────────────────────
  108. app.post('/drafts', async (request, reply) => {
  109. const { content = '', mediaUrl = '', scheduledAt = '', destinations = [] } = request.body || {};
  110. const db = await getDb();
  111. const now = new Date();
  112. const result = await db.collection('drafts').insertOne({
  113. content, mediaUrl, scheduledAt, destinations, createdAt: now, updatedAt: now,
  114. });
  115. const draft = await db.collection('drafts').findOne({ _id: result.insertedId });
  116. return reply.code(201).send(draft);
  117. });
  118. app.get('/drafts', async () => {
  119. const db = await getDb();
  120. const drafts = await db.collection('drafts').find({}).sort({ updatedAt: -1 }).toArray();
  121. return { drafts };
  122. });
  123. app.get('/drafts/:id', async (request, reply) => {
  124. const { id } = request.params;
  125. let oid;
  126. try { oid = new ObjectId(id); } catch { return reply.code(400).send({ error: 'Invalid draft ID' }); }
  127. const db = await getDb();
  128. const draft = await db.collection('drafts').findOne({ _id: oid });
  129. if (!draft) return reply.code(404).send({ error: 'Draft not found' });
  130. return draft;
  131. });
  132. app.put('/drafts/:id', async (request, reply) => {
  133. const { id } = request.params;
  134. let oid;
  135. try { oid = new ObjectId(id); } catch { return reply.code(400).send({ error: 'Invalid draft ID' }); }
  136. const { content = '', mediaUrl = '', scheduledAt = '', destinations = [] } = request.body || {};
  137. const db = await getDb();
  138. const result = await db.collection('drafts').updateOne(
  139. { _id: oid },
  140. { $set: { content, mediaUrl, scheduledAt, destinations, updatedAt: new Date() } }
  141. );
  142. if (!result.matchedCount) return reply.code(404).send({ error: 'Draft not found' });
  143. return { success: true };
  144. });
  145. app.delete('/drafts/:id', async (request, reply) => {
  146. const { id } = request.params;
  147. let oid;
  148. try { oid = new ObjectId(id); } catch { return reply.code(400).send({ error: 'Invalid draft ID' }); }
  149. const db = await getDb();
  150. await db.collection('drafts').deleteOne({ _id: oid });
  151. return { success: true };
  152. });
  153. // ─── Meta Token Expiry ───────────────────────────────────────────────────────
  154. let _tokenExpiryCache = null;
  155. let _tokenExpiryCacheAt = 0;
  156. const TOKEN_EXPIRY_TTL = 60 * 60 * 1000; // 1 hour
  157. app.get('/meta/token-expiry', async (request, reply) => {
  158. if (_tokenExpiryCache && Date.now() - _tokenExpiryCacheAt < TOKEN_EXPIRY_TTL) {
  159. return _tokenExpiryCache;
  160. }
  161. const appCred = await getCredentials('meta_app');
  162. if (!appCred?.appId || !appCred?.appSecret) return { accounts: [] };
  163. const ig = await getCredentials('instagram');
  164. const selectedAccounts = (ig?.accounts || []).filter((a) => a.selected && a.accessToken);
  165. if (!selectedAccounts.length) return { accounts: [] };
  166. const appToken = `${appCred.appId}|${appCred.appSecret}`;
  167. const accounts = [];
  168. for (const account of selectedAccounts) {
  169. try {
  170. const res = await axios.get(`${GRAPH_API}/debug_token`, {
  171. params: { input_token: account.accessToken, access_token: appToken },
  172. timeout: 10000,
  173. });
  174. const data = res.data.data;
  175. const expiresAt = data.expires_at ? new Date(data.expires_at * 1000).toISOString() : null;
  176. const daysLeft = expiresAt
  177. ? Math.ceil((new Date(expiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
  178. : null;
  179. accounts.push({ id: account.id, username: account.username, expiresAt, daysLeft, isValid: !!data.is_valid });
  180. } catch (err) {
  181. console.error(`[Gateway] Token expiry check failed for ${account.username}:`, err.message);
  182. }
  183. }
  184. _tokenExpiryCache = { accounts, checkedAt: new Date().toISOString() };
  185. _tokenExpiryCacheAt = Date.now();
  186. return _tokenExpiryCache;
  187. });
  188. // ─── Account Profiles ────────────────────────────────────────────────────────
  189. app.get('/profiles', async () => {
  190. const db = await getDb();
  191. const profiles = await db.collection('account_profiles').find({}).toArray();
  192. return { profiles };
  193. });
  194. app.get('/profiles/:accountKey', async (request, reply) => {
  195. const { accountKey } = request.params;
  196. const db = await getDb();
  197. const profile = await db.collection('account_profiles').findOne({ _id: accountKey });
  198. return profile ?? { _id: accountKey };
  199. });
  200. app.put('/profiles/:accountKey', async (request, reply) => {
  201. const { accountKey } = request.params;
  202. const {
  203. businessName = '', description = '', websiteUrl = '', industry = '',
  204. targetAudience = '', toneOfVoice = '', keywords = '', hashtags = '',
  205. postingGuidelines = '',
  206. } = request.body || {};
  207. const db = await getDb();
  208. await db.collection('account_profiles').updateOne(
  209. { _id: accountKey },
  210. { $set: { businessName, description, websiteUrl, industry, targetAudience, toneOfVoice, keywords, hashtags, postingGuidelines, updatedAt: new Date() } },
  211. { upsert: true }
  212. );
  213. return { success: true };
  214. });
  215. // ─── Platform service URLs ────────────────────────────────────────────────────
  216. const PLATFORM_SERVICES = {
  217. twitter: process.env.TWITTER_SERVICE_URL || 'http://twitter:3001',
  218. linkedin: process.env.LINKEDIN_SERVICE_URL || 'http://linkedin:3002',
  219. mastodon: process.env.MASTODON_SERVICE_URL || 'http://mastodon:3003',
  220. bluesky: process.env.BLUESKY_SERVICE_URL || 'http://bluesky:3004',
  221. instagram: process.env.INSTAGRAM_SERVICE_URL || 'http://instagram:3005',
  222. facebook: process.env.FACEBOOK_SERVICE_URL || 'http://facebook:3006',
  223. };
  224. // Direct multi-platform post endpoint.
  225. // Body: { content: string, destinations: Array<{ platform, accountId?, imageUrl?, videoUrl?, link? }> }
  226. app.post('/post', async (request, reply) => {
  227. const { content, destinations = [] } = request.body || {};
  228. if (!content?.trim()) return reply.code(400).send({ error: 'content is required' });
  229. if (!destinations.length) return reply.code(400).send({ error: 'destinations must not be empty' });
  230. const results = await Promise.allSettled(
  231. destinations.map(async ({ platform, accountId, imageUrl, videoUrl, link }) => {
  232. const serviceUrl = PLATFORM_SERVICES[platform];
  233. if (!serviceUrl) throw new Error(`Unknown platform: ${platform}`);
  234. const res = await axios.post(`${serviceUrl}/post`, { content, accountId, imageUrl, videoUrl, link }, { timeout: 30000 });
  235. return { platform, accountId, ...res.data };
  236. })
  237. );
  238. const output = results.map((r, i) =>
  239. r.status === 'fulfilled'
  240. ? r.value
  241. : { platform: destinations[i].platform, accountId: destinations[i].accountId, success: false, error: r.reason?.message }
  242. );
  243. const anyFailed = output.some((r) => !r.success);
  244. return reply.code(anyFailed ? 207 : 200).send({ results: output });
  245. });
  246. // ─── Legacy post route ────────────────────────────────────────────────────────
  247. let rabbitMQProducer = new RabbitMQProducer();
  248. app.post('/', async (request, reply) => {
  249. try {
  250. await rabbitMQProducer.sendMessage('formatter', request.body.message);
  251. reply.send({ status: 'ok' });
  252. } catch (error) {
  253. console.error('Error handling POST request:', error);
  254. reply.status(500).send({ error: 'Internal Server Error' });
  255. }
  256. });
  257. // ─── Meta App Credentials ────────────────────────────────────────────────────
  258. // Save Facebook App ID + Secret (entered by user in Settings UI)
  259. app.post('/credentials/meta-app', async (request, reply) => {
  260. const { appId, appSecret } = request.body || {};
  261. if (!appId || !appSecret) {
  262. return reply.code(400).send({ error: 'appId and appSecret are required' });
  263. }
  264. await setCredentials('meta_app', { appId, appSecret });
  265. return { success: true };
  266. });
  267. // Get Meta App config (secret is masked for UI display)
  268. app.get('/credentials/meta-app', async () => {
  269. const cred = await getCredentials('meta_app');
  270. if (!cred) return { configured: false };
  271. return { configured: true, appId: cred.appId, appSecretHint: `****${cred.appSecret.slice(-4)}` };
  272. });
  273. // ─── Meta OAuth Flow ──────────────────────────────────────────────────────────
  274. // Return the Facebook OAuth URL to redirect the user to
  275. app.get('/auth/meta/init', async (request, reply) => {
  276. const cred = await getCredentials('meta_app');
  277. if (!cred?.appId) {
  278. return reply.code(400).send({ error: 'Save your Facebook App ID and Secret first' });
  279. }
  280. const redirectUri = `${APP_BASE_URL}/api/auth/meta/callback`;
  281. const scopes = [
  282. 'pages_manage_posts',
  283. 'pages_read_engagement',
  284. 'instagram_basic',
  285. 'instagram_content_publish',
  286. 'instagram_manage_insights',
  287. ].join(',');
  288. const url = `https://www.facebook.com/v22.0/dialog/oauth?client_id=${cred.appId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${scopes}&response_type=code`;
  289. return { url };
  290. });
  291. // OAuth callback — Facebook redirects here after user authorises
  292. app.get('/auth/meta/callback', async (request, reply) => {
  293. const { code, error: oauthError } = request.query;
  294. if (oauthError) {
  295. return reply.redirect(`${APP_BASE_URL}/settings?meta_error=${encodeURIComponent(oauthError)}`);
  296. }
  297. if (!code) {
  298. return reply.redirect(`${APP_BASE_URL}/settings?meta_error=no_code`);
  299. }
  300. try {
  301. const appCred = await getCredentials('meta_app');
  302. if (!appCred?.appId) throw new Error('App credentials not configured');
  303. const redirectUri = `${APP_BASE_URL}/api/auth/meta/callback`;
  304. // Exchange code for short-lived token
  305. const shortRes = await axios.get(`${GRAPH_API}/oauth/access_token`, {
  306. params: {
  307. client_id: appCred.appId,
  308. client_secret: appCred.appSecret,
  309. redirect_uri: redirectUri,
  310. code,
  311. },
  312. });
  313. // Upgrade to long-lived user token (~60 days)
  314. const longRes = await axios.get(`${GRAPH_API}/oauth/access_token`, {
  315. params: {
  316. grant_type: 'fb_exchange_token',
  317. client_id: appCred.appId,
  318. client_secret: appCred.appSecret,
  319. fb_exchange_token: shortRes.data.access_token,
  320. },
  321. });
  322. const userToken = longRes.data.access_token;
  323. // Fetch all managed Facebook Pages
  324. const pagesRes = await axios.get(`${GRAPH_API}/me/accounts`, {
  325. params: { access_token: userToken, fields: 'id,name,access_token,picture' },
  326. });
  327. const pages = [];
  328. const igAccounts = [];
  329. for (const page of pagesRes.data.data || []) {
  330. pages.push({
  331. id: page.id,
  332. name: page.name,
  333. accessToken: page.access_token,
  334. picture: page.picture?.data?.url || null,
  335. selected: false,
  336. });
  337. // Check for linked Instagram Business Account
  338. try {
  339. const igRes = await axios.get(`${GRAPH_API}/${page.id}`, {
  340. params: {
  341. fields: 'instagram_business_account',
  342. access_token: page.access_token,
  343. },
  344. });
  345. if (igRes.data.instagram_business_account?.id) {
  346. const igId = igRes.data.instagram_business_account.id;
  347. // Fetch IG account details
  348. const igProfile = await axios.get(`${GRAPH_API}/${igId}`, {
  349. params: {
  350. fields: 'id,username,name,profile_picture_url',
  351. access_token: userToken,
  352. },
  353. });
  354. igAccounts.push({
  355. id: igId,
  356. username: igProfile.data.username || igProfile.data.name,
  357. name: igProfile.data.name,
  358. avatar: igProfile.data.profile_picture_url || null,
  359. accessToken: userToken,
  360. pageId: page.id,
  361. selected: false,
  362. });
  363. }
  364. } catch (_) {
  365. // Page has no linked Instagram account — skip
  366. }
  367. }
  368. // Store discovery results for the UI to pick from
  369. await setCredentials('meta_discovery', { pages, igAccounts, discoveredAt: new Date() });
  370. reply.redirect(`${APP_BASE_URL}/settings?meta_discovery=1`);
  371. } catch (err) {
  372. console.error('[Gateway] Meta OAuth error:', err.response?.data || err.message);
  373. reply.redirect(`${APP_BASE_URL}/settings?meta_error=${encodeURIComponent(err.message)}`);
  374. }
  375. });
  376. // Return pending discovery results so the UI can render the page picker
  377. app.get('/auth/meta/discovered', async () => {
  378. const discovery = await getCredentials('meta_discovery');
  379. if (!discovery) return { pages: [], igAccounts: [] };
  380. return { pages: discovery.pages || [], igAccounts: discovery.igAccounts || [] };
  381. });
  382. // User has chosen which pages/accounts to connect
  383. app.post('/auth/meta/save', async (request, reply) => {
  384. const { selectedPageIds = [], selectedIgAccountIds = [] } = request.body || {};
  385. const discovery = await getCredentials('meta_discovery');
  386. if (!discovery) return reply.code(400).send({ error: 'No discovery data found — reconnect via OAuth' });
  387. const fbPages = (discovery.pages || []).map((p) => ({
  388. ...p,
  389. selected: selectedPageIds.includes(p.id),
  390. }));
  391. const igAccounts = (discovery.igAccounts || []).map((a) => ({
  392. ...a,
  393. selected: selectedIgAccountIds.includes(a.id),
  394. }));
  395. await setCredentials('facebook', { pages: fbPages });
  396. await setCredentials('instagram', { accounts: igAccounts });
  397. await deleteCredentials('meta_discovery');
  398. _tokenExpiryCache = null; // invalidate cache after reconnect
  399. return { success: true, facebookPages: fbPages.filter((p) => p.selected).length, instagramAccounts: igAccounts.filter((a) => a.selected).length };
  400. });
  401. // Disconnect all Meta platforms
  402. app.delete('/credentials/meta', async () => {
  403. await deleteCredentials('facebook');
  404. await deleteCredentials('instagram');
  405. await deleteCredentials('meta_discovery');
  406. return { success: true };
  407. });
  408. // ─── Credential Status ────────────────────────────────────────────────────────
  409. // Aggregate connection status for all DB-managed platforms
  410. app.get('/credentials', async () => {
  411. const [metaApp, fb, ig] = await Promise.all([
  412. getCredentials('meta_app'),
  413. getCredentials('facebook'),
  414. getCredentials('instagram'),
  415. ]);
  416. const fbPages = (fb?.pages || []).filter((p) => p.selected);
  417. const igAccounts = (ig?.accounts || []).filter((a) => a.selected);
  418. return {
  419. metaApp: { configured: !!(metaApp?.appId) },
  420. facebook: {
  421. connected: fbPages.length > 0,
  422. pages: fbPages.map(({ id, name, picture }) => ({ id, name, picture })),
  423. },
  424. instagram: {
  425. connected: igAccounts.length > 0,
  426. accounts: igAccounts.map(({ id, username, avatar }) => ({ id, username, avatar })),
  427. },
  428. };
  429. });
  430. module.exports = app;