diff --git a/src/app.js b/src/app.js index 1475d6446e..407d7edab1 100644 --- a/src/app.js +++ b/src/app.js @@ -25,6 +25,10 @@ const helpRequestRouter = require('./routes/helpRequestRouter'); app.use('/api/feedback', helpFeedbackRouter); app.use('/api/helprequest', helpRequestRouter); +const path = require('path'); + +app.use('/uploads', express.static(path.join(process.cwd(), 'uploads'))); + require('./startup/middleware')(app); const weeklyReportsRouter = require('./routes/weeklyReportsRouter'); diff --git a/src/controllers/__tests__/instagramController.test.js b/src/controllers/__tests__/instagramController.test.js new file mode 100644 index 0000000000..99b63d7ce6 --- /dev/null +++ b/src/controllers/__tests__/instagramController.test.js @@ -0,0 +1,767 @@ +jest.mock('../../models/instagramScheduledPost', () => ({ + find: jest.fn(), + create: jest.fn(), + findOne: jest.fn(), + findOneAndDelete: jest.fn(), +})); + +jest.mock('../../models/instagramPostHistory', () => ({ + find: jest.fn(), + create: jest.fn(), +})); + +jest.mock('../../models/metaToken', () => ({ + findOne: jest.fn(), +})); + +jest.mock('../../services/instagramServices', () => ({ + publishInstagramPost: jest.fn(), +})); + +jest.mock('fs', () => ({ + mkdirSync: jest.fn(), + writeFileSync: jest.fn(), +})); + +const fs = require('fs'); +const crypto = require('crypto'); +const InstagramScheduledPost = require('../../models/instagramScheduledPost'); +const InstagramPostHistory = require('../../models/instagramPostHistory'); +const MetaToken = require('../../models/metaToken'); +const { publishInstagramPost } = require('../../services/instagramServices'); +const { + createPost, + schedulePost, + getScheduledPosts, + deleteScheduledPost, + getHistory, + retryScheduledPost, +} = require('../instagramController'); + +// ─── test utilities ───────────────────────────────────────────────────────── + +function futureDate() { + return new Date(Date.now() + 60 * 60 * 1000).toISOString(); +} + +// ─── Test helpers ────────────────────────────────────────────────────────── + +const VALID_USER_ID = '507f1f77bcf86cd799439011'; +const VALID_POST_ID = '507f1f77bcf86cd799439012'; + +const VALID_MEDIA_BASE64 = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; + +// Mirrors what the global auth middleware actually puts on the request: +// req.body.requestor.requestorId. Pass requestorId: undefined to simulate +// an unauthenticated request (as if the middleware's allowlist/verify +// step never ran or failed). +function buildReq({ body = {}, params = {}, query = {}, requestorId = VALID_USER_ID } = {}) { + return { + body: { + ...body, + requestor: requestorId === undefined ? undefined : { requestorId }, + }, + params, + query, + }; +} + +function buildRes() { + const res = {}; + res.status = jest.fn().mockReturnThis(); + res.json = jest.fn(); + return res; +} + +const ORIGINAL_ENV = process.env; + +beforeEach(() => { + jest.clearAllMocks(); + process.env = { + ...ORIGINAL_ENV, + INSTAGRAM_ACCOUNT_ID: 'ig-account-123', + INSTAGRAM_MEDIA_BASE_URL: 'https://backend.example.com', + }; + fs.mkdirSync.mockReturnValue(undefined); + fs.writeFileSync.mockReturnValue(undefined); + jest.spyOn(crypto, 'randomUUID').mockReturnValue('fixed-uuid'); +}); + +afterEach(() => { + process.env = ORIGINAL_ENV; + jest.restoreAllMocks(); +}); + +// ─── createPost ───────────────────────────────────────────────────────────── + +describe('createPost', () => { + test('returns 401 when req.body.requestor is undefined', async () => { + const req = buildReq(); + req.body.requestor = undefined; + const res = buildRes(); + console.log('req', req); + await createPost(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ detail: 'Not authenticated' }); + }); + + test('returns 401 when requestorId is not a valid ObjectId', async () => { + const req = buildReq({ requestorId: 'not-an-object-id' }); + const res = buildRes(); + + await createPost(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + }); + + test('returns 400 when caption is missing', async () => { + const req = buildReq({ body: { media: { base64: VALID_MEDIA_BASE64 } } }); + const res = buildRes(); + + await createPost(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Caption is required.' }); + }); + + test('returns 400 when caption is only whitespace', async () => { + const req = buildReq({ body: { caption: ' ', media: { base64: VALID_MEDIA_BASE64 } } }); + const res = buildRes(); + + await createPost(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + test('returns 400 when media is missing', async () => { + const req = buildReq({ body: { caption: 'Hello world' } }); + const res = buildRes(); + + await createPost(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Media is required.' }); + }); + + test('returns 500 when Instagram token is missing or expired', async () => { + MetaToken.findOne.mockResolvedValue(null); + const req = buildReq({ + body: { caption: 'Hello world', media: { base64: VALID_MEDIA_BASE64 } }, + }); + const res = buildRes(); + + await createPost(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: 'Instagram access token is missing or expired. Refresh required.', + }); + }); + + test('returns 500 when Instagram token is expired', async () => { + MetaToken.findOne.mockResolvedValue({ + accessToken: 'expired-token', + expiresAt: new Date(Date.now() - 1000), + }); + const req = buildReq({ + body: { caption: 'Hello world', media: { base64: VALID_MEDIA_BASE64 } }, + }); + const res = buildRes(); + + await createPost(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + }); + + test('returns 500 when media base64 data is malformed', async () => { + MetaToken.findOne.mockResolvedValue({ + accessToken: 'valid-token', + expiresAt: new Date(Date.now() + 100000), + }); + const req = buildReq({ + body: { caption: 'Hello world', media: { base64: 'not-a-real-data-url' } }, + }); + const res = buildRes(); + + await createPost(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid media data.' }); + }); + + test('returns 500 when INSTAGRAM_MEDIA_BASE_URL is not configured', async () => { + delete process.env.INSTAGRAM_MEDIA_BASE_URL; + MetaToken.findOne.mockResolvedValue({ + accessToken: 'valid-token', + expiresAt: new Date(Date.now() + 100000), + }); + const req = buildReq({ + body: { caption: 'Hello world', media: { base64: VALID_MEDIA_BASE64 } }, + }); + const res = buildRes(); + + await createPost(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'INSTAGRAM_MEDIA_BASE_URL is not configured.' }); + }); + + test('returns 500 with the Graph API error message when publishing fails', async () => { + MetaToken.findOne.mockResolvedValue({ + accessToken: 'valid-token', + expiresAt: new Date(Date.now() + 100000), + }); + publishInstagramPost.mockRejectedValue({ + response: { data: { error: { message: 'Invalid OAuth access token.' } } }, + }); + const req = buildReq({ + body: { caption: 'Hello world', media: { base64: VALID_MEDIA_BASE64 } }, + }); + const res = buildRes(); + + await createPost(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid OAuth access token.' }); + }); + + test('returns 500 with err.message when publishing fails without a Graph API response body', async () => { + MetaToken.findOne.mockResolvedValue({ + accessToken: 'valid-token', + expiresAt: new Date(Date.now() + 100000), + }); + publishInstagramPost.mockRejectedValue(new Error('Network timeout')); + const req = buildReq({ + body: { caption: 'Hello world', media: { base64: VALID_MEDIA_BASE64 } }, + }); + const res = buildRes(); + + await createPost(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'Network timeout' }); + }); + + test('publishes successfully, writes media to disk, records history, and returns 200', async () => { + MetaToken.findOne.mockResolvedValue({ + accessToken: 'valid-token', + expiresAt: new Date(Date.now() + 100000), + }); + publishInstagramPost.mockResolvedValue({ + creationId: 'creation-1', + instagramMediaId: 'media-1', + permalink: 'https://instagram.com/p/abc123', + }); + InstagramPostHistory.create.mockResolvedValue({ _id: 'history-1' }); + + const req = buildReq({ + body: { + caption: ' Hello world ', + media: { base64: VALID_MEDIA_BASE64 }, + altText: 'A photo', + }, + }); + const res = buildRes(); + + await createPost(req, res); + + expect(fs.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('uploads'), { + recursive: true, + }); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('fixed-uuid.png'), + expect.any(Buffer), + ); + + expect(publishInstagramPost).toHaveBeenCalledWith({ + instagramAccountId: 'ig-account-123', + accessToken: 'valid-token', + caption: 'Hello world', + mediaUrl: 'https://backend.example.com/uploads/instagram/fixed-uuid.png', + mediaType: 'IMAGE', + }); + + expect(InstagramPostHistory.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: VALID_USER_ID, + caption: 'Hello world', + mediaType: 'IMAGE', + instagramMediaId: 'media-1', + permalink: 'https://instagram.com/p/abc123', + status: 'published', + }), + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + creationId: 'creation-1', + instagramMediaId: 'media-1', + permalink: 'https://instagram.com/p/abc123', + }); + }); + + test('classifies video media correctly', async () => { + MetaToken.findOne.mockResolvedValue({ + accessToken: 'valid-token', + expiresAt: new Date(Date.now() + 100000), + }); + publishInstagramPost.mockResolvedValue({ + creationId: 'creation-2', + instagramMediaId: 'media-2', + permalink: null, + }); + InstagramPostHistory.create.mockResolvedValue({ _id: 'history-2' }); + + const videoBase64 = 'data:video/mp4;base64,AAAA'; + const req = buildReq({ body: { caption: 'A clip', media: { base64: videoBase64 } } }); + const res = buildRes(); + + await createPost(req, res); + + expect(publishInstagramPost).toHaveBeenCalledWith( + expect.objectContaining({ mediaType: 'VIDEO' }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); + +// ─── schedulePost ─────────────────────────────────────────────────────────── + +describe('schedulePost', () => { + test('returns 401 when req.body.requestor is undefined', async () => { + const req = buildReq(); + req.body.requestor = undefined; + const res = buildRes(); + + await schedulePost(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + }); + + test('returns 401 when requestorId is not a valid ObjectId', async () => { + const req = buildReq({ requestorId: 'not-an-object-id' }); + const res = buildRes(); + + await schedulePost(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + }); + + test('returns 400 when caption is missing', async () => { + const req = buildReq({ + body: { media: { base64: VALID_MEDIA_BASE64 }, scheduledTime: futureDate() }, + }); + const res = buildRes(); + + await schedulePost(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Caption is required.' }); + }); + + test('returns 400 when media is missing', async () => { + const req = buildReq({ body: { caption: 'Hello', scheduledTime: futureDate() } }); + const res = buildRes(); + + await schedulePost(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Media is required.' }); + }); + + test('returns 400 when scheduledTime is missing', async () => { + const req = buildReq({ body: { caption: 'Hello', media: { base64: VALID_MEDIA_BASE64 } } }); + const res = buildRes(); + + await schedulePost(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Scheduled time is required.' }); + }); + + test('returns 400 when scheduledTime is not a valid date', async () => { + const req = buildReq({ + body: { + caption: 'Hello', + media: { base64: VALID_MEDIA_BASE64 }, + scheduledTime: 'not-a-date', + }, + }); + const res = buildRes(); + + await schedulePost(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Scheduled time must be in the future.' }); + }); + + test('returns 400 when scheduledTime is in the past', async () => { + const req = buildReq({ + body: { + caption: 'Hello', + media: { base64: VALID_MEDIA_BASE64 }, + scheduledTime: new Date(Date.now() - 100000).toISOString(), + }, + }); + const res = buildRes(); + + await schedulePost(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Scheduled time must be in the future.' }); + }); + + test('returns 500 when media is malformed', async () => { + const req = buildReq({ + body: { + caption: 'Hello', + media: { base64: 'garbage' }, + scheduledTime: futureDate(), + }, + }); + const res = buildRes(); + + await schedulePost(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid media data.' }); + }); + + test('creates a scheduled post and returns 201 on success', async () => { + const scheduledTime = futureDate(); + InstagramScheduledPost.create.mockResolvedValue({ + _id: VALID_POST_ID, + userId: VALID_USER_ID, + caption: 'Hello', + status: 'scheduled', + }); + + const req = buildReq({ + body: { + caption: ' Hello ', + media: { base64: VALID_MEDIA_BASE64 }, + altText: 'desc', + scheduledTime, + }, + }); + const res = buildRes(); + + await schedulePost(req, res); + + expect(InstagramScheduledPost.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: VALID_USER_ID, + caption: 'Hello', + mediaType: 'IMAGE', + mediaAltText: 'desc', + status: 'scheduled', + }), + ); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ message: 'Post scheduled.' })); + }); + + test('defaults mediaAltText to null when altText is not provided', async () => { + InstagramScheduledPost.create.mockResolvedValue({ _id: VALID_POST_ID }); + const req = buildReq({ + body: { + caption: 'Hello', + media: { base64: VALID_MEDIA_BASE64 }, + scheduledTime: futureDate(), + }, + }); + const res = buildRes(); + + await schedulePost(req, res); + + expect(InstagramScheduledPost.create).toHaveBeenCalledWith( + expect.objectContaining({ mediaAltText: null }), + ); + }); + + test('returns 500 when InstagramScheduledPost.create throws', async () => { + InstagramScheduledPost.create.mockRejectedValue(new Error('DB write failed')); + const req = buildReq({ + body: { + caption: 'Hello', + media: { base64: VALID_MEDIA_BASE64 }, + scheduledTime: futureDate(), + }, + }); + const res = buildRes(); + + await schedulePost(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'DB write failed' }); + }); +}); + +// ─── getScheduledPosts ────────────────────────────────────────────────────── + +describe('getScheduledPosts', () => { + test('returns 401 when req.body.requestor is undefined', async () => { + const req = buildReq(); + req.body.requestor = undefined; + const res = buildRes(); + + await getScheduledPosts(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + }); + + test('returns 401 when requestorId is not a valid ObjectId', async () => { + const req = buildReq({ requestorId: 'not-an-object-id' }); + const res = buildRes(); + + await getScheduledPosts(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + }); + + test('queries with the correct filter and returns 200', async () => { + const lean = jest.fn().mockResolvedValue([{ _id: VALID_POST_ID, status: 'scheduled' }]); + const sort = jest.fn().mockReturnValue({ lean }); + InstagramScheduledPost.find.mockReturnValue({ sort }); + + const req = buildReq(); + const res = buildRes(); + + await getScheduledPosts(req, res); + + expect(InstagramScheduledPost.find).toHaveBeenCalledWith({ + userId: VALID_USER_ID, + status: { $in: ['scheduled', 'publishing', 'failed'] }, + }); + expect(sort).toHaveBeenCalledWith({ scheduledTime: 1 }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([{ _id: VALID_POST_ID, status: 'scheduled' }]); + }); + + test('returns 500 when the query throws', async () => { + InstagramScheduledPost.find.mockImplementation(() => { + throw new Error('DB unavailable'); + }); + const req = buildReq(); + const res = buildRes(); + + await getScheduledPosts(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'DB unavailable' }); + }); +}); + +// ─── deleteScheduledPost ──────────────────────────────────────────────────── + +describe('deleteScheduledPost', () => { + test('returns 401 when req.body.requestor is undefined', async () => { + const req = buildReq(); + req.body.requestor = undefined; + req.body.params = { id: VALID_POST_ID }; + const res = buildRes(); + + await deleteScheduledPost(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + }); + + test('returns 401 when requestorId is not a valid ObjectId', async () => { + const req = buildReq({ requestorId: 'not-an-object-id', params: { id: VALID_POST_ID } }); + const res = buildRes(); + + await deleteScheduledPost(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + }); + + test('returns 404 when the post does not exist or belongs to another user', async () => { + InstagramScheduledPost.findOneAndDelete.mockResolvedValue(null); + const req = buildReq({ params: { id: VALID_POST_ID } }); + const res = buildRes(); + + await deleteScheduledPost(req, res); + + expect(InstagramScheduledPost.findOneAndDelete).toHaveBeenCalledWith({ + _id: VALID_POST_ID, + userId: VALID_USER_ID, + }); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Scheduled post not found.' }); + }); + + test('returns 200 when the post is deleted', async () => { + InstagramScheduledPost.findOneAndDelete.mockResolvedValue({ _id: VALID_POST_ID }); + const req = buildReq({ params: { id: VALID_POST_ID } }); + const res = buildRes(); + + await deleteScheduledPost(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ message: 'Deleted.' }); + }); + + test('returns 500 when the query throws', async () => { + InstagramScheduledPost.findOneAndDelete.mockRejectedValue(new Error('DB error')); + const req = buildReq({ params: { id: VALID_POST_ID } }); + const res = buildRes(); + + await deleteScheduledPost(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'DB error' }); + }); +}); + +// ─── getHistory ───────────────────────────────────────────────────────────── + +describe('getHistory', () => { + test('returns 401 when req.body.requestor is undefined', async () => { + const req = buildReq(); + req.body.requestor = undefined; + const res = buildRes(); + + await getHistory(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + }); + + test('returns 401 when requestorId is not a valid ObjectId', async () => { + const req = buildReq({ requestorId: 'not-an-object-id' }); + const res = buildRes(); + + await getHistory(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + }); + + test('defaults to a limit of 20 when none is provided', async () => { + const lean = jest.fn().mockResolvedValue([]); + const limitFn = jest.fn().mockReturnValue({ lean }); + const sort = jest.fn().mockReturnValue({ limit: limitFn }); + InstagramPostHistory.find.mockReturnValue({ sort }); + + const req = buildReq(); + const res = buildRes(); + + await getHistory(req, res); + + expect(InstagramPostHistory.find).toHaveBeenCalledWith({ userId: VALID_USER_ID }); + expect(sort).toHaveBeenCalledWith({ postedAt: -1 }); + expect(limitFn).toHaveBeenCalledWith(20); + expect(res.status).toHaveBeenCalledWith(200); + }); + + test('respects a custom limit under the cap', async () => { + const lean = jest.fn().mockResolvedValue([]); + const limitFn = jest.fn().mockReturnValue({ lean }); + const sort = jest.fn().mockReturnValue({ limit: limitFn }); + InstagramPostHistory.find.mockReturnValue({ sort }); + + const req = buildReq({ query: { limit: '5' } }); + const res = buildRes(); + + await getHistory(req, res); + + expect(limitFn).toHaveBeenCalledWith(5); + }); + + test('caps the limit at 100 when a larger value is requested', async () => { + const lean = jest.fn().mockResolvedValue([]); + const limitFn = jest.fn().mockReturnValue({ lean }); + const sort = jest.fn().mockReturnValue({ limit: limitFn }); + InstagramPostHistory.find.mockReturnValue({ sort }); + + const req = buildReq({ query: { limit: '500' } }); + const res = buildRes(); + + await getHistory(req, res); + + expect(limitFn).toHaveBeenCalledWith(100); + }); + + test('returns 500 when the query throws', async () => { + InstagramPostHistory.find.mockImplementation(() => { + throw new Error('DB unavailable'); + }); + const req = buildReq(); + const res = buildRes(); + + await getHistory(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'DB unavailable' }); + }); +}); + +// ─── retryScheduledPost ───────────────────────────────────────────────────── + +describe('retryScheduledPost', () => { + test('returns 401 when req.body.requestor is undefined', async () => { + const req = buildReq(); + req.body.requestor = undefined; + req.body.params = { id: VALID_POST_ID }; + const res = buildRes(); + + await retryScheduledPost(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + }); + + test('returns 401 when requestorId is not a valid ObjectId', async () => { + const req = buildReq({ requestorId: 'not-an-object-id', params: { id: VALID_POST_ID } }); + const res = buildRes(); + + await retryScheduledPost(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + }); + + test('returns 404 when the post does not exist or belongs to another user', async () => { + InstagramScheduledPost.findOne.mockResolvedValue(null); + const req = buildReq({ params: { id: VALID_POST_ID } }); + const res = buildRes(); + + await retryScheduledPost(req, res); + + expect(InstagramScheduledPost.findOne).toHaveBeenCalledWith({ + _id: VALID_POST_ID, + userId: VALID_USER_ID, + }); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Scheduled post not found.' }); + }); + + test('resets status to scheduled, clears lastError, saves, and returns 200', async () => { + const save = jest.fn().mockResolvedValue(undefined); + const post = { status: 'failed', lastError: 'IG container error', save }; + InstagramScheduledPost.findOne.mockResolvedValue(post); + + const req = buildReq({ params: { id: VALID_POST_ID } }); + const res = buildRes(); + + await retryScheduledPost(req, res); + + expect(post.status).toBe('scheduled'); + expect(post.lastError).toBeNull(); + expect(save).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ message: 'Post re-queued.', post }); + }); + + test('returns 500 when save() throws', async () => { + const save = jest.fn().mockRejectedValue(new Error('DB write failed')); + InstagramScheduledPost.findOne.mockResolvedValue({ status: 'failed', save }); + + const req = buildReq({ params: { id: VALID_POST_ID } }); + const res = buildRes(); + + await retryScheduledPost(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'DB write failed' }); + }); +}); diff --git a/src/controllers/instagramController.js b/src/controllers/instagramController.js new file mode 100644 index 0000000000..4f636ae5f1 --- /dev/null +++ b/src/controllers/instagramController.js @@ -0,0 +1,303 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const mongoose = require('mongoose'); +const InstagramScheduledPost = require('../models/instagramScheduledPost'); +const InstagramPostHistory = require('../models/instagramPostHistory'); +const MetaToken = require('../models/metaToken'); +const { publishInstagramPost } = require('../services/instagramServices'); + +const getInstagramCredentials = async () => { + const tokenDoc = await MetaToken.findOne({ platform: 'instagram' }); + + if (!tokenDoc || tokenDoc.expiresAt < new Date()) { + throw new Error('Instagram access token is missing or expired. Refresh required.'); + } + + return { + instagramAccountId: process.env.INSTAGRAM_ACCOUNT_ID, // this doesn't expire, fine to keep in env + accessToken: tokenDoc.accessToken, + }; +}; + +const saveBase64Media = async (media) => { + if (!media || !media.base64) { + throw new Error('Media is required.'); + } + + const match = media.base64.match(/^data:(image\/[^;]+|video\/[^;]+);base64,(.+)$/); + + if (!match) { + throw new Error('Invalid media data.'); + } + + const mimeType = match[1]; + const base64Data = match[2]; + + const extension = mimeType.split('/')[1] || 'bin'; + + const fileName = `${crypto.randomUUID()}.${extension}`; + + const uploadDirectory = path.join(process.cwd(), 'uploads', 'instagram'); + + fs.mkdirSync(uploadDirectory, { + recursive: true, + }); + + const filePath = path.join(uploadDirectory, fileName); + + fs.writeFileSync(filePath, Buffer.from(base64Data, 'base64')); + + /* + * This URL MUST be publicly accessible to Meta. + * + * Configure INSTAGRAM_MEDIA_BASE_URL to point + * to your public backend URL. + */ + + const baseUrl = process.env.INSTAGRAM_MEDIA_BASE_URL; + + if (!baseUrl) { + throw new Error('INSTAGRAM_MEDIA_BASE_URL is not configured.'); + } + + return { + mediaUrl: `${baseUrl}/uploads/instagram/${fileName}`, + + mediaType: mimeType.startsWith('video') ? 'VIDEO' : 'IMAGE', + }; +}; + +const createPost = async (req, res) => { + try { + const userId = req.body?.requestor?.requestorId; + if (!userId || !mongoose.Types.ObjectId.isValid(userId)) { + return res.status(401).json({ detail: 'Not authenticated' }); + } + + const { caption, media } = req.body; + + if (!caption || !caption.trim()) { + return res.status(400).json({ + error: 'Caption is required.', + }); + } + + if (!media || !media.base64) { + return res.status(400).json({ + error: 'Media is required.', + }); + } + const { instagramAccountId, accessToken } = await getInstagramCredentials(); + const uploadedMedia = await saveBase64Media(media); + + const result = await publishInstagramPost({ + instagramAccountId, + accessToken, + caption: caption.trim(), + mediaUrl: uploadedMedia.mediaUrl, + mediaType: uploadedMedia.mediaType, + }); + + await InstagramPostHistory.create({ + userId, + caption: caption.trim(), + mediaUrl: uploadedMedia.mediaUrl, + mediaType: uploadedMedia.mediaType, + instagramMediaId: result.instagramMediaId, + permalink: result.permalink, + postedAt: new Date(), + status: 'published', + }); + + return res.status(200).json({ + success: true, + creationId: result.creationId, + instagramMediaId: result.instagramMediaId, + permalink: result.permalink, + }); + } catch (err) { + console.error('[Instagram] Create post error:', err.response?.data || err.message); + + return res.status(500).json({ + error: err.response?.data?.error?.message || err.message || 'Failed to post to Instagram.', + }); + } +}; + +const schedulePost = async (req, res) => { + try { + const userId = req.body?.requestor?.requestorId; + if (!userId || !mongoose.Types.ObjectId.isValid(userId)) { + return res.status(401).json({ detail: 'Not authenticated' }); + } + + const { caption, media, altText, scheduledTime } = req.body; + + if (!caption || !caption.trim()) { + return res.status(400).json({ + error: 'Caption is required.', + }); + } + + if (!media || !media.base64) { + return res.status(400).json({ + error: 'Media is required.', + }); + } + + if (!scheduledTime) { + return res.status(400).json({ + error: 'Scheduled time is required.', + }); + } + + const scheduledDate = new Date(scheduledTime); + + if (Number.isNaN(scheduledDate.getTime()) || scheduledDate <= new Date()) { + return res.status(400).json({ + error: 'Scheduled time must be in the future.', + }); + } + + const uploadedMedia = await saveBase64Media(media); + + const post = await InstagramScheduledPost.create({ + userId, + caption: caption.trim(), + mediaUrl: uploadedMedia.mediaUrl, + mediaType: uploadedMedia.mediaType, + mediaAltText: altText || null, + scheduledTime: scheduledDate, + status: 'scheduled', + }); + + return res.status(201).json({ + message: 'Post scheduled.', + post, + }); + } catch (err) { + console.error('[Instagram] Schedule error:', err.message); + + return res.status(500).json({ + error: err.message, + }); + } +}; + +const getScheduledPosts = async (req, res) => { + try { + const userId = req.body?.requestor?.requestorId; + if (!userId || !mongoose.Types.ObjectId.isValid(userId)) { + return res.status(401).json({ detail: 'Not authenticated' }); + } + const posts = await InstagramScheduledPost.find({ + userId, + status: { + $in: ['scheduled', 'publishing', 'failed'], + }, + }) + .sort({ scheduledTime: 1 }) + .lean(); + + return res.status(200).json(posts); + } catch (err) { + return res.status(500).json({ + error: err.message, + }); + } +}; + +const deleteScheduledPost = async (req, res) => { + try { + const userId = req.body?.requestor?.requestorId; + if (!userId || !mongoose.Types.ObjectId.isValid(userId)) { + return res.status(401).json({ detail: 'Not authenticated' }); + } + + const post = await InstagramScheduledPost.findOneAndDelete({ + _id: req.params.id, + userId, + }); + + if (!post) { + return res.status(404).json({ + error: 'Scheduled post not found.', + }); + } + + return res.status(200).json({ + message: 'Deleted.', + }); + } catch (err) { + return res.status(500).json({ + error: err.message, + }); + } +}; + +const getHistory = async (req, res) => { + try { + const userId = req.body?.requestor?.requestorId; + if (!userId || !mongoose.Types.ObjectId.isValid(userId)) { + return res.status(401).json({ detail: 'Not authenticated' }); + } + const limit = Math.min(Number(req.query.limit) || 20, 100); + + const history = await InstagramPostHistory.find({ + userId, + }) + .sort({ postedAt: -1 }) + .limit(limit) + .lean(); + + return res.status(200).json(history); + } catch (err) { + return res.status(500).json({ + error: err.message, + }); + } +}; + +const retryScheduledPost = async (req, res) => { + try { + const userId = req.body?.requestor?.requestorId; + if (!userId || !mongoose.Types.ObjectId.isValid(userId)) { + return res.status(401).json({ detail: 'Not authenticated' }); + } + + const post = await InstagramScheduledPost.findOne({ + _id: req.params.id, + userId, + }); + + if (!post) { + return res.status(404).json({ + error: 'Scheduled post not found.', + }); + } + + post.status = 'scheduled'; + post.lastError = null; + + await post.save(); + + return res.status(200).json({ + message: 'Post re-queued.', + post, + }); + } catch (err) { + return res.status(500).json({ + error: err.message, + }); + } +}; + +module.exports = { + createPost, + schedulePost, + getScheduledPosts, + deleteScheduledPost, + getHistory, + retryScheduledPost, +}; diff --git a/src/cronjobs/instagramSchedulerJob.js b/src/cronjobs/instagramSchedulerJob.js new file mode 100644 index 0000000000..eaed09f344 --- /dev/null +++ b/src/cronjobs/instagramSchedulerJob.js @@ -0,0 +1,11 @@ +const cron = require('node-cron'); +const { runInstagramScheduler } = require('../services/instagramScheduler'); + +// Run every minute +cron.schedule('* * * * *', async () => { + try { + await runInstagramScheduler(); + } catch (err) { + console.error('[Instagram Scheduler] Error:', err.message); + } +}); diff --git a/src/cronjobs/instagramTokenRefreshJob.js b/src/cronjobs/instagramTokenRefreshJob.js new file mode 100644 index 0000000000..091ef535d8 --- /dev/null +++ b/src/cronjobs/instagramTokenRefreshJob.js @@ -0,0 +1,16 @@ +// cronjobs/instagramTokenRefreshJob.js +const cron = require('node-cron'); // or whatever scheduler this repo already uses — check cronjobs/userProfileJobs.js for the pattern +const refreshInstagramToken = require('../services/refreshInstagramToken'); +const logger = require('../startup/logger'); + +module.exports = () => { + // Run daily at 3am — refreshing well before the 60-day expiry + cron.schedule('0 3 * * *', async () => { + try { + const tokenDoc = await refreshInstagramToken(); + logger.logInfo(`Instagram token refreshed, expires ${tokenDoc.expiresAt}`); + } catch (err) { + logger.logException(err, 'Instagram token refresh failed'); + } + }); +}; diff --git a/src/models/instagramPostHistory.js b/src/models/instagramPostHistory.js new file mode 100644 index 0000000000..f2f72c08c6 --- /dev/null +++ b/src/models/instagramPostHistory.js @@ -0,0 +1,59 @@ +const mongoose = require('mongoose'); + +const InstagramPostHistorySchema = new mongoose.Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + required: true, + index: true, + }, + + caption: { + type: String, + required: true, + }, + + mediaUrl: { + type: String, + default: null, + }, + + mediaType: { + type: String, + enum: ['IMAGE', 'VIDEO'], + default: null, + }, + + instagramMediaId: { + type: String, + default: null, + }, + + permalink: { + type: String, + default: null, + }, + + postedAt: { + type: Date, + default: Date.now, + index: true, + }, + + status: { + type: String, + enum: ['published', 'failed'], + required: true, + }, + + error: { + type: String, + default: null, + }, + }, + { + timestamps: true, + }, +); + +module.exports = mongoose.model('InstagramPostHistory', InstagramPostHistorySchema); diff --git a/src/models/instagramScheduledPost.js b/src/models/instagramScheduledPost.js new file mode 100644 index 0000000000..d17325659f --- /dev/null +++ b/src/models/instagramScheduledPost.js @@ -0,0 +1,75 @@ +const mongoose = require('mongoose'); + +const InstagramScheduledPostSchema = new mongoose.Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + required: true, + index: true, + }, + + caption: { + type: String, + required: true, + }, + + mediaUrl: { + type: String, + required: true, + }, + + mediaType: { + type: String, + enum: ['IMAGE', 'VIDEO'], + required: true, + }, + + mediaAltText: { + type: String, + default: null, + }, + + scheduledTime: { + type: Date, + required: true, + index: true, + }, + + status: { + type: String, + enum: ['scheduled', 'publishing', 'published', 'failed'], + default: 'scheduled', + index: true, + }, + + creationId: { + type: String, + default: null, + }, + + instagramMediaId: { + type: String, + default: null, + }, + + permalink: { + type: String, + default: null, + }, + + lastError: { + type: String, + default: null, + }, + + attempts: { + type: Number, + default: 0, + }, + }, + { + timestamps: true, + }, +); + +module.exports = mongoose.model('InstagramScheduledPost', InstagramScheduledPostSchema); diff --git a/src/models/metaToken.js b/src/models/metaToken.js new file mode 100644 index 0000000000..de090cf30b --- /dev/null +++ b/src/models/metaToken.js @@ -0,0 +1,14 @@ +// models/metaToken.js +const mongoose = require('mongoose'); + +const metaTokenSchema = new mongoose.Schema( + { + platform: { type: String, default: 'instagram', unique: true }, + accessToken: { type: String, required: true }, + expiresAt: { type: Date, required: true }, + lastRefreshedAt: { type: Date }, + }, + { timestamps: true }, +); + +module.exports = mongoose.model('MetaToken', metaTokenSchema); diff --git a/src/routes/instagram.js b/src/routes/instagram.js new file mode 100644 index 0000000000..3697e66d41 --- /dev/null +++ b/src/routes/instagram.js @@ -0,0 +1,50 @@ +const express = require('express'); + +const router = express.Router(); + +const { + createPost, + schedulePost, + getScheduledPosts, + deleteScheduledPost, + getHistory, + retryScheduledPost, +} = require('../controllers/instagramController'); + +router.post( + '/post', + // auth, + createPost, +); + +router.post( + '/schedule', + // auth, + schedulePost, +); + +router.get( + '/schedule', + // auth, + getScheduledPosts, +); + +router.delete( + '/schedule/:id', + // auth, + deleteScheduledPost, +); + +router.post( + '/schedule/:id/retry', + // auth, + retryScheduledPost, +); + +router.get( + '/history', + // auth, + getHistory, +); + +module.exports = router; diff --git a/src/scripts/bootstrapInstagramToken.js b/src/scripts/bootstrapInstagramToken.js new file mode 100644 index 0000000000..889c65b08b --- /dev/null +++ b/src/scripts/bootstrapInstagramToken.js @@ -0,0 +1,33 @@ +// scripts/seedInstagramToken.js +require('dotenv').config(); +const mongoose = require('mongoose'); +const MetaToken = require('../models/metaToken'); + +async function seed() { + const appName = process.env.appName || 'HGNRest'; + const uri = `mongodb+srv://${encodeURIComponent(process.env.user)}:${encodeURIComponent(process.env.password)}@${process.env.cluster}/${process.env.dbName}?retryWrites=true&w=majority&appName=${appName}`; + await mongoose.connect(uri, { + useNewUrlParser: true, + useUnifiedTopology: true, + }); + + // 👇 paste your token and how many seconds until it expires + const accessToken = process.env.INSTAGRAM_ACCESS_TOKEN; + const expiresInSeconds = 5184000; // 60 days, adjust if you know the real value; use 3600 for a short-lived token + + const expiresAt = new Date(Date.now() + expiresInSeconds * 1000); + + await MetaToken.findOneAndUpdate( + { platform: 'instagram' }, + { accessToken, expiresAt, lastRefreshedAt: new Date() }, + { upsert: true }, + ); + + await mongoose.disconnect(); + process.exit(0); +} + +seed().catch((err) => { + console.error('❌ Seed failed:', err.message); + process.exit(1); +}); diff --git a/src/server.js b/src/server.js index 4ec46ec767..69ec950e98 100644 --- a/src/server.js +++ b/src/server.js @@ -17,6 +17,8 @@ require('./cronjobs/userProfileJobs')(); require('./cronjobs/pullRequestReviewJobs')(); require('./jobs/analyticsAggregation').scheduleDaily(); require('./cronjobs/bidWinnerJobs')(); +require('./cronjobs/instagramSchedulerJob'); +require('./cronjobs/instagramTokenRefreshJob')(); // Process pending and stuck emails on startup (only after DB is connected) mongoose.connection.once('connected', () => { diff --git a/src/services/instagramScheduler.js b/src/services/instagramScheduler.js new file mode 100644 index 0000000000..6e2322bb78 --- /dev/null +++ b/src/services/instagramScheduler.js @@ -0,0 +1,92 @@ +const InstagramScheduledPost = require('../models/instagramScheduledPost'); +const InstagramPostHistory = require('../models/instagramPostHistory'); +const { publishInstagramPost } = require('./instagramServices'); + +const runInstagramScheduler = async () => { + const now = new Date(); + + const posts = await InstagramScheduledPost.find({ + status: 'scheduled', + scheduledTime: { + $lte: now, + }, + }).limit(20); + + for (const post of posts) { + try { + const locked = await InstagramScheduledPost.findOneAndUpdate( + { + _id: post._id, + status: 'scheduled', + }, + { + $set: { + status: 'publishing', + }, + $inc: { + attempts: 1, + }, + }, + { + new: true, + }, + ); + + if (!locked) { + continue; + } + + const instagramAccountId = process.env.INSTAGRAM_ACCOUNT_ID; + + const accessToken = process.env.INSTAGRAM_ACCESS_TOKEN; + + const result = await publishInstagramPost({ + instagramAccountId, + accessToken, + caption: post.caption, + mediaUrl: post.mediaUrl, + mediaType: post.mediaType, + }); + + await InstagramScheduledPost.findByIdAndUpdate(post._id, { + status: 'published', + creationId: result.creationId, + instagramMediaId: result.instagramMediaId, + permalink: result.permalink, + lastError: null, + }); + + await InstagramPostHistory.create({ + userId: post.userId, + caption: post.caption, + mediaUrl: post.mediaUrl, + mediaType: post.mediaType, + instagramMediaId: result.instagramMediaId, + permalink: result.permalink, + postedAt: new Date(), + status: 'published', + }); + } catch (err) { + console.error(`[Instagram Scheduler] Failed ${post._id}:`, err.response?.data || err.message); + + await InstagramScheduledPost.findByIdAndUpdate(post._id, { + status: 'failed', + lastError: err.response?.data?.error?.message || err.message, + }); + + await InstagramPostHistory.create({ + userId: post.userId, + caption: post.caption, + mediaUrl: post.mediaUrl, + mediaType: post.mediaType, + postedAt: new Date(), + status: 'failed', + error: err.response?.data?.error?.message || err.message, + }); + } + } +}; + +module.exports = { + runInstagramScheduler, +}; diff --git a/src/services/instagramServices.js b/src/services/instagramServices.js new file mode 100644 index 0000000000..e64266e082 --- /dev/null +++ b/src/services/instagramServices.js @@ -0,0 +1,180 @@ +const axios = require('axios'); + +const GRAPH_API_VERSION = process.env.META_GRAPH_API_VERSION || 'v23.0'; + +const GRAPH_API_URL = `https://graph.facebook.com/${GRAPH_API_VERSION}`; + +const GRAPH_ID_PATTERN = /^[A-Za-z0-9_]{1,64}$/; + +function assertValidGraphId(id, label) { + if (typeof id !== 'string' || !GRAPH_ID_PATTERN.test(id)) { + throw new Error(`Invalid ${label}: ${JSON.stringify(id)}`); + } + return id; +} + +function assertValidMediaUrl(url) { + let parsed; + try { + parsed = new URL(url); + } catch { + throw new Error(`Invalid mediaUrl: ${JSON.stringify(url)}`); + } + if (parsed.protocol !== 'https:') { + throw new Error(`mediaUrl must be https: ${JSON.stringify(url)}`); + } + return url; +} + +const createMediaContainer = async ({ + instagramAccountId, + accessToken, + caption, + mediaUrl, + mediaType, +}) => { + const safeAccountId = assertValidGraphId(instagramAccountId, 'instagramAccountId'); + const safeMediaUrl = assertValidMediaUrl(mediaUrl); + const endpoint = `${GRAPH_API_URL}/${safeAccountId}/media`; + + const body = { + caption, + access_token: accessToken, + }; + + if (mediaType === 'VIDEO') { + body.media_type = 'REELS'; + body.video_url = safeMediaUrl; + } else { + body.image_url = safeMediaUrl; + } + + const response = await axios.post(endpoint, null, { + params: body, + timeout: 30000, + }); + + return response.data; +}; + +const waitForContainerReady = async ({ + creationId, + accessToken, + maxAttempts = 10, + delayMs = 2000, +}) => { + const safeCreationId = assertValidGraphId(creationId, 'creationId'); + const endpoint = `${GRAPH_API_URL}/${safeCreationId}`; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const response = await axios.get(endpoint, { + params: { + fields: 'status_code', + access_token: accessToken, + }, + timeout: 15000, + }); + + const { status_code: statusCode } = response.data; + + if (statusCode === 'FINISHED') { + return true; + } + + if (statusCode === 'ERROR') { + throw new Error('Instagram media processing failed.'); + } + + // statusCode is likely 'IN_PROGRESS' — wait and retry + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); + } + + throw new Error('Instagram media was not ready in time.'); +}; + +const publishMediaContainer = async ({ instagramAccountId, accessToken, creationId }) => { + const safeAccountId = assertValidGraphId(instagramAccountId, 'instagramAccountId'); + const safeCreationId = assertValidGraphId(creationId, 'creationId'); + const endpoint = `${GRAPH_API_URL}/${safeAccountId}/media_publish`; + + const response = await axios.post(endpoint, null, { + params: { + creation_id: safeCreationId, + access_token: accessToken, + }, + timeout: 30000, + }); + + return response.data; +}; + +const getMediaDetails = async ({ mediaId, accessToken }) => { + const safeMediaId = assertValidGraphId(mediaId, 'mediaId'); + const endpoint = `${GRAPH_API_URL}/${safeMediaId}`; + + const response = await axios.get(endpoint, { + params: { + fields: 'id,permalink,media_type,timestamp', + access_token: accessToken, + }, + timeout: 15000, + }); + + return response.data; +}; + +const publishInstagramPost = async ({ + instagramAccountId, + accessToken, + caption, + mediaUrl, + mediaType, +}) => { + const container = await createMediaContainer({ + instagramAccountId, + accessToken, + caption, + mediaUrl, + mediaType, + }); + + const creationId = container.id; + + if (!creationId) { + throw new Error('Instagram did not return a creation ID.'); + } + await waitForContainerReady({ creationId, accessToken }); + const published = await publishMediaContainer({ + instagramAccountId, + accessToken, + creationId, + }); + + const instagramMediaId = published.id; + + if (!instagramMediaId) { + throw new Error('Instagram did not return a media ID.'); + } + + const mediaDetails = await getMediaDetails({ + mediaId: instagramMediaId, + accessToken, + }); + + return { + creationId, + instagramMediaId, + permalink: mediaDetails.permalink || null, + mediaType: mediaDetails.media_type || mediaType, + }; +}; + +module.exports = { + createMediaContainer, + waitForContainerReady, + publishMediaContainer, + getMediaDetails, + publishInstagramPost, +}; diff --git a/src/services/refreshInstagramToken.js b/src/services/refreshInstagramToken.js new file mode 100644 index 0000000000..8a80e0064c --- /dev/null +++ b/src/services/refreshInstagramToken.js @@ -0,0 +1,26 @@ +// services/instagram/refreshInstagramToken.js +const axios = require('axios'); +const MetaToken = require('../models/metaToken'); + +async function refreshInstagramToken() { + const tokenDoc = await MetaToken.findOne({ platform: 'instagram' }); + if (!tokenDoc) throw new Error('No Instagram token found — run bootstrap script first.'); + + const { data } = await axios.get('https://graph.facebook.com/v19.0/oauth/access_token', { + params: { + grant_type: 'fb_exchange_token', + client_id: process.env.META_APP_ID, + client_secret: process.env.META_APP_SECRET, + fb_exchange_token: tokenDoc.accessToken, + }, + }); + + tokenDoc.accessToken = data.access_token; + tokenDoc.expiresAt = new Date(Date.now() + data.expires_in * 1000); + tokenDoc.lastRefreshedAt = new Date(); + await tokenDoc.save(); + + return tokenDoc; +} + +module.exports = refreshInstagramToken; diff --git a/src/startup/cors.js b/src/startup/cors.js index 61ac21c236..219b0d1b5e 100644 --- a/src/startup/cors.js +++ b/src/startup/cors.js @@ -25,7 +25,13 @@ module.exports = function (app) { }, credentials: true, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'Cache-Control', 'Pragma'], + allowedHeaders: [ + 'Content-Type', + 'Authorization', + 'Cache-Control', + 'Pragma', + 'ngrok-skip-browser-warning', + ], }), ); }; diff --git a/src/startup/middleware.js b/src/startup/middleware.js index e350e127df..d2675306f1 100644 --- a/src/startup/middleware.js +++ b/src/startup/middleware.js @@ -37,6 +37,9 @@ module.exports = function (app) { if (req.originalUrl.startsWith('/api/mastodon')) { return next(); } + if (req.originalUrl.startsWith('/uploads')) { + return next(); + } const openPaths = ['/api/lb/myWebhooks']; if (req.originalUrl === '/') { diff --git a/src/startup/routes.js b/src/startup/routes.js index fa7167df5c..b8d775ca55 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -461,6 +461,7 @@ const resourceRequestRouter = require('../routes/resourceRequestRouter')( userProfile, resourceRequestController, ); +const instagramRoutes = require('../routes/instagram'); module.exports = function (app) { app.use('/api/bm/summary-dashboard', summaryDashboardRouter); @@ -713,4 +714,6 @@ module.exports = function (app) { app.use('/api/kitchenandinventory/recipes', recipeRouter); app.use('/api/analytics', analyticsRouter); + + app.use('/api/instagram', instagramRoutes); };