server.js 14 KB

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