server.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. require('dotenv').config();
  2. const app = require('fastify')({ logger: false });
  3. const axios = require('axios');
  4. const { getDb } = require('./utils/MongoDBConnector');
  5. const RabbitMQProducer = require('./utils/RabbitMQProducer');
  6. const GRAPH_API = 'https://graph.facebook.com/v22.0';
  7. // The public base URL of this app (used for OAuth redirect_uri)
  8. const APP_BASE_URL = process.env.APP_BASE_URL || 'http://localhost:8081';
  9. // ─── CORS ────────────────────────────────────────────────────────────────────
  10. app.addHook('onSend', async (request, reply) => {
  11. reply.header('Access-Control-Allow-Origin', '*');
  12. reply.header('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
  13. reply.header('Access-Control-Allow-Headers', 'Content-Type');
  14. });
  15. app.options('*', async (request, reply) => {
  16. reply.code(204).send();
  17. });
  18. // ─── Helpers ─────────────────────────────────────────────────────────────────
  19. async function getCredentials(id) {
  20. const db = await getDb();
  21. return db.collection('platform_credentials').findOne({ _id: id });
  22. }
  23. async function setCredentials(id, data) {
  24. const db = await getDb();
  25. await db.collection('platform_credentials').updateOne(
  26. { _id: id },
  27. { $set: { _id: id, ...data, updatedAt: new Date() } },
  28. { upsert: true }
  29. );
  30. }
  31. async function deleteCredentials(id) {
  32. const db = await getDb();
  33. await db.collection('platform_credentials').deleteOne({ _id: id });
  34. }
  35. // ─── Platform service URLs ────────────────────────────────────────────────────
  36. const PLATFORM_SERVICES = {
  37. twitter: process.env.TWITTER_SERVICE_URL || 'http://twitter:3001',
  38. linkedin: process.env.LINKEDIN_SERVICE_URL || 'http://linkedin:3002',
  39. mastodon: process.env.MASTODON_SERVICE_URL || 'http://mastodon:3003',
  40. bluesky: process.env.BLUESKY_SERVICE_URL || 'http://bluesky:3004',
  41. instagram: process.env.INSTAGRAM_SERVICE_URL || 'http://instagram:3005',
  42. facebook: process.env.FACEBOOK_SERVICE_URL || 'http://facebook:3006',
  43. };
  44. // Direct multi-platform post endpoint.
  45. // Body: { content: string, destinations: Array<{ platform, accountId?, imageUrl?, videoUrl?, link? }> }
  46. app.post('/post', async (request, reply) => {
  47. const { content, destinations = [] } = request.body || {};
  48. if (!content?.trim()) return reply.code(400).send({ error: 'content is required' });
  49. if (!destinations.length) return reply.code(400).send({ error: 'destinations must not be empty' });
  50. const results = await Promise.allSettled(
  51. destinations.map(async ({ platform, accountId, imageUrl, videoUrl, link }) => {
  52. const serviceUrl = PLATFORM_SERVICES[platform];
  53. if (!serviceUrl) throw new Error(`Unknown platform: ${platform}`);
  54. const res = await axios.post(`${serviceUrl}/post`, { content, accountId, imageUrl, videoUrl, link }, { timeout: 30000 });
  55. return { platform, accountId, ...res.data };
  56. })
  57. );
  58. const output = results.map((r, i) =>
  59. r.status === 'fulfilled'
  60. ? r.value
  61. : { platform: destinations[i].platform, accountId: destinations[i].accountId, success: false, error: r.reason?.message }
  62. );
  63. const anyFailed = output.some((r) => !r.success);
  64. return reply.code(anyFailed ? 207 : 200).send({ results: output });
  65. });
  66. // ─── Legacy post route ────────────────────────────────────────────────────────
  67. let rabbitMQProducer = new RabbitMQProducer();
  68. app.post('/', async (request, reply) => {
  69. try {
  70. await rabbitMQProducer.sendMessage('formatter', request.body.message);
  71. reply.send({ status: 'ok' });
  72. } catch (error) {
  73. console.error('Error handling POST request:', error);
  74. reply.status(500).send({ error: 'Internal Server Error' });
  75. }
  76. });
  77. // ─── Meta App Credentials ────────────────────────────────────────────────────
  78. // Save Facebook App ID + Secret (entered by user in Settings UI)
  79. app.post('/credentials/meta-app', async (request, reply) => {
  80. const { appId, appSecret } = request.body || {};
  81. if (!appId || !appSecret) {
  82. return reply.code(400).send({ error: 'appId and appSecret are required' });
  83. }
  84. await setCredentials('meta_app', { appId, appSecret });
  85. return { success: true };
  86. });
  87. // Get Meta App config (secret is masked for UI display)
  88. app.get('/credentials/meta-app', async () => {
  89. const cred = await getCredentials('meta_app');
  90. if (!cred) return { configured: false };
  91. return { configured: true, appId: cred.appId, appSecretHint: `****${cred.appSecret.slice(-4)}` };
  92. });
  93. // ─── Meta OAuth Flow ──────────────────────────────────────────────────────────
  94. // Return the Facebook OAuth URL to redirect the user to
  95. app.get('/auth/meta/init', async (request, reply) => {
  96. const cred = await getCredentials('meta_app');
  97. if (!cred?.appId) {
  98. return reply.code(400).send({ error: 'Save your Facebook App ID and Secret first' });
  99. }
  100. const redirectUri = `${APP_BASE_URL}/api/auth/meta/callback`;
  101. const scopes = [
  102. 'pages_manage_posts',
  103. 'pages_read_engagement',
  104. 'instagram_basic',
  105. 'instagram_content_publish',
  106. 'instagram_manage_insights',
  107. ].join(',');
  108. const url = `https://www.facebook.com/v22.0/dialog/oauth?client_id=${cred.appId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${scopes}&response_type=code`;
  109. return { url };
  110. });
  111. // OAuth callback — Facebook redirects here after user authorises
  112. app.get('/auth/meta/callback', async (request, reply) => {
  113. const { code, error: oauthError } = request.query;
  114. if (oauthError) {
  115. return reply.redirect(`${APP_BASE_URL}/settings?meta_error=${encodeURIComponent(oauthError)}`);
  116. }
  117. if (!code) {
  118. return reply.redirect(`${APP_BASE_URL}/settings?meta_error=no_code`);
  119. }
  120. try {
  121. const appCred = await getCredentials('meta_app');
  122. if (!appCred?.appId) throw new Error('App credentials not configured');
  123. const redirectUri = `${APP_BASE_URL}/api/auth/meta/callback`;
  124. // Exchange code for short-lived token
  125. const shortRes = await axios.get(`${GRAPH_API}/oauth/access_token`, {
  126. params: {
  127. client_id: appCred.appId,
  128. client_secret: appCred.appSecret,
  129. redirect_uri: redirectUri,
  130. code,
  131. },
  132. });
  133. // Upgrade to long-lived user token (~60 days)
  134. const longRes = await axios.get(`${GRAPH_API}/oauth/access_token`, {
  135. params: {
  136. grant_type: 'fb_exchange_token',
  137. client_id: appCred.appId,
  138. client_secret: appCred.appSecret,
  139. fb_exchange_token: shortRes.data.access_token,
  140. },
  141. });
  142. const userToken = longRes.data.access_token;
  143. // Fetch all managed Facebook Pages
  144. const pagesRes = await axios.get(`${GRAPH_API}/me/accounts`, {
  145. params: { access_token: userToken, fields: 'id,name,access_token,picture' },
  146. });
  147. const pages = [];
  148. const igAccounts = [];
  149. for (const page of pagesRes.data.data || []) {
  150. pages.push({
  151. id: page.id,
  152. name: page.name,
  153. accessToken: page.access_token,
  154. picture: page.picture?.data?.url || null,
  155. selected: false,
  156. });
  157. // Check for linked Instagram Business Account
  158. try {
  159. const igRes = await axios.get(`${GRAPH_API}/${page.id}`, {
  160. params: {
  161. fields: 'instagram_business_account',
  162. access_token: page.access_token,
  163. },
  164. });
  165. if (igRes.data.instagram_business_account?.id) {
  166. const igId = igRes.data.instagram_business_account.id;
  167. // Fetch IG account details
  168. const igProfile = await axios.get(`${GRAPH_API}/${igId}`, {
  169. params: {
  170. fields: 'id,username,name,profile_picture_url',
  171. access_token: userToken,
  172. },
  173. });
  174. igAccounts.push({
  175. id: igId,
  176. username: igProfile.data.username || igProfile.data.name,
  177. name: igProfile.data.name,
  178. avatar: igProfile.data.profile_picture_url || null,
  179. accessToken: userToken,
  180. pageId: page.id,
  181. selected: false,
  182. });
  183. }
  184. } catch (_) {
  185. // Page has no linked Instagram account — skip
  186. }
  187. }
  188. // Store discovery results for the UI to pick from
  189. await setCredentials('meta_discovery', { pages, igAccounts, discoveredAt: new Date() });
  190. reply.redirect(`${APP_BASE_URL}/settings?meta_discovery=1`);
  191. } catch (err) {
  192. console.error('[Gateway] Meta OAuth error:', err.response?.data || err.message);
  193. reply.redirect(`${APP_BASE_URL}/settings?meta_error=${encodeURIComponent(err.message)}`);
  194. }
  195. });
  196. // Return pending discovery results so the UI can render the page picker
  197. app.get('/auth/meta/discovered', async () => {
  198. const discovery = await getCredentials('meta_discovery');
  199. if (!discovery) return { pages: [], igAccounts: [] };
  200. return { pages: discovery.pages || [], igAccounts: discovery.igAccounts || [] };
  201. });
  202. // User has chosen which pages/accounts to connect
  203. app.post('/auth/meta/save', async (request, reply) => {
  204. const { selectedPageIds = [], selectedIgAccountIds = [] } = request.body || {};
  205. const discovery = await getCredentials('meta_discovery');
  206. if (!discovery) return reply.code(400).send({ error: 'No discovery data found — reconnect via OAuth' });
  207. const fbPages = (discovery.pages || []).map((p) => ({
  208. ...p,
  209. selected: selectedPageIds.includes(p.id),
  210. }));
  211. const igAccounts = (discovery.igAccounts || []).map((a) => ({
  212. ...a,
  213. selected: selectedIgAccountIds.includes(a.id),
  214. }));
  215. await setCredentials('facebook', { pages: fbPages });
  216. await setCredentials('instagram', { accounts: igAccounts });
  217. await deleteCredentials('meta_discovery');
  218. return { success: true, facebookPages: fbPages.filter((p) => p.selected).length, instagramAccounts: igAccounts.filter((a) => a.selected).length };
  219. });
  220. // Disconnect all Meta platforms
  221. app.delete('/credentials/meta', async () => {
  222. await deleteCredentials('facebook');
  223. await deleteCredentials('instagram');
  224. await deleteCredentials('meta_discovery');
  225. return { success: true };
  226. });
  227. // ─── Credential Status ────────────────────────────────────────────────────────
  228. // Aggregate connection status for all DB-managed platforms
  229. app.get('/credentials', async () => {
  230. const [metaApp, fb, ig] = await Promise.all([
  231. getCredentials('meta_app'),
  232. getCredentials('facebook'),
  233. getCredentials('instagram'),
  234. ]);
  235. const fbPages = (fb?.pages || []).filter((p) => p.selected);
  236. const igAccounts = (ig?.accounts || []).filter((a) => a.selected);
  237. return {
  238. metaApp: { configured: !!(metaApp?.appId) },
  239. facebook: {
  240. connected: fbPages.length > 0,
  241. pages: fbPages.map(({ id, name, picture }) => ({ id, name, picture })),
  242. },
  243. instagram: {
  244. connected: igAccounts.length > 0,
  245. accounts: igAccounts.map(({ id, username, avatar }) => ({ id, username, avatar })),
  246. },
  247. };
  248. });
  249. module.exports = app;