diff --git a/src/controllers/mastodonPostController.js b/src/controllers/mastodonPostController.js index 6be267ab02..7e2800ba71 100644 --- a/src/controllers/mastodonPostController.js +++ b/src/controllers/mastodonPostController.js @@ -12,7 +12,7 @@ function getAuthHeaders() { } // Upload image to Mastodon with optional alt text and get media ID -async function uploadMedia(base64Image, altText = null) { +async function uploadMedia(base64Image, altText = null, { timeout } = {}) { try { // Convert base64 to buffer const base64Data = base64Image.replace(/^data:image\/\w+;base64,/, ''); @@ -31,7 +31,10 @@ async function uploadMedia(base64Image, altText = null) { ...formData.getHeaders(), }; - const uploadResponse = await axios.post(uploadUrl, formData, { headers: uploadHeaders }); + const uploadResponse = await axios.post(uploadUrl, formData, { + headers: uploadHeaders, + timeout, + }); const mediaId = uploadResponse.data.id; console.log('Image uploaded, media ID:', mediaId); @@ -50,7 +53,7 @@ async function uploadMedia(base64Image, altText = null) { { description: altText.trim(), }, - { headers: updateHeaders }, + { headers: updateHeaders, timeout }, ); console.log('Alt text updated successfully'); @@ -130,7 +133,17 @@ async function scheduleStatus(req, res) { // Don't upload the image yet for scheduled posts // Just store the base64 data and alt text const text = req.body.description || req.body.title; - if (!text?.trim()) throw new Error("Post content can't be empty"); + if (!text?.trim()) { + return res.status(400).json({ error: "Post content can't be empty" }); + } + + const scheduledTime = new Date(req.body.scheduledTime); + if (!req.body.scheduledTime || Number.isNaN(scheduledTime.getTime())) { + return res.status(400).json({ error: 'A valid scheduled date and time is required' }); + } + if (scheduledTime <= new Date()) { + return res.status(400).json({ error: 'Scheduled time must be in the future' }); + } const postData = { status: text.trim(), @@ -149,22 +162,22 @@ async function scheduleStatus(req, res) { postData.local_media_url = req.body.mediaItems; } - const { scheduledTime } = req.body; await MastodonSchedule.create({ postData: JSON.stringify(postData), scheduledTime, }); - res.sendStatus(200); + return res.sendStatus(200); } catch (err) { console.error('Schedule failed:', err.message); - res.status(500).json({ error: err.message }); + return res.status(500).json({ error: err.message }); } } // Fetch scheduled posts async function fetchScheduledStatus(_req, res) { try { - const scheduled = await MastodonSchedule.find(); + // Posted records are kept as delivery history, not shown as scheduled + const scheduled = await MastodonSchedule.find({ status: { $ne: 'posted' } }); res.json(scheduled); } catch (err) { res.status(500).send('Failed to fetch scheduled pins'); @@ -174,10 +187,17 @@ async function fetchScheduledStatus(_req, res) { // Delete scheduled post async function deleteScheduledStatus(req, res) { try { - await MastodonSchedule.deleteOne({ _id: req.params.id }); - res.send('Scheduled post deleted successfully'); + // A post being published or already posted cannot be deleted + const result = await MastodonSchedule.deleteOne({ + _id: req.params.id, + status: { $in: ['pending', null, 'failed'] }, + }); + if (!result?.deletedCount) { + return res.status(404).send('Scheduled post not found or already being published'); + } + return res.send('Scheduled post deleted successfully'); } catch { - res.status(500).send('Failed to delete scheduled post'); + return res.status(500).send('Failed to delete scheduled post'); } } diff --git a/src/cronjobs/__tests__/mastodonScheduleJob.test.js b/src/cronjobs/__tests__/mastodonScheduleJob.test.js new file mode 100644 index 0000000000..910f22375a --- /dev/null +++ b/src/cronjobs/__tests__/mastodonScheduleJob.test.js @@ -0,0 +1,350 @@ +jest.mock('axios', () => ({ post: jest.fn() })); +jest.mock('node-cron', () => ({ schedule: jest.fn() })); +jest.mock('../../models/mastodonSchedule', () => ({ + findOneAndUpdate: jest.fn(), + updateOne: jest.fn(), + updateMany: jest.fn(), +})); +jest.mock('../../controllers/mastodonPostController', () => ({ uploadMedia: jest.fn() })); + +// In-memory stand-in for the schedule collection. Each update runs to +// completion before another starts, like a single-document MongoDB update. +function matchesCondition(value, condition) { + if (condition && typeof condition === 'object' && !(condition instanceof Date)) { + return Object.entries(condition).every(([op, expected]) => { + if (op === '$in') return expected.some((e) => (e === null ? value == null : value === e)); + if (op === '$nin') return !expected.includes(value); + if (op === '$ne') return value !== expected; + if (op === '$lte') return value <= expected; + if (op === '$lt') return value < expected; + if (op === '$gt') return value > expected; + throw new Error(`Unsupported operator ${op}`); + }); + } + // Like MongoDB, null matches a missing field + if (condition === null) return value == null; + return value === condition; +} + +function matches(doc, filter) { + return Object.entries(filter).every(([key, condition]) => { + if (key === '$or') return condition.some((sub) => matches(doc, sub)); + return matchesCondition(doc[key], condition); + }); +} + +function applyUpdate(doc, update) { + Object.assign(doc, update.$set); + Object.keys(update.$unset || {}).forEach((key) => delete doc[key]); + Object.entries(update.$inc || {}).forEach(([key, n]) => { + doc[key] = (doc[key] || 0) + n; + }); +} + +function createStore(docs) { + return { + docs, + findOneAndUpdate: async (filter, update, options) => { + const due = docs + .filter((doc) => matches(doc, filter)) + .sort((a, b) => a.scheduledTime - b.scheduledTime); + if (!due.length) return null; + applyUpdate(due[0], update); + return options?.new ? { ...due[0] } : null; + }, + updateOne: async (filter, update) => { + const doc = docs.find((d) => matches(d, filter)); + if (doc) applyUpdate(doc, update); + return { nModified: doc ? 1 : 0 }; + }, + updateMany: async (filter, update) => { + const found = docs.filter((d) => matches(d, filter)); + found.forEach((doc) => applyUpdate(doc, update)); + return { nModified: found.length }; + }, + }; +} + +function duePost(overrides = {}) { + return { + _id: 'p1', + postData: JSON.stringify({ status: 'Due post', visibility: 'public' }), + scheduledTime: new Date(Date.now() - 60 * 1000), + status: 'pending', + ...overrides, + }; +} + +describe('mastodonScheduleJob', () => { + let axios; + let cron; + let MastodonSchedule; + let job; + let store; + + function useStore(docs) { + store = createStore(docs); + MastodonSchedule.findOneAndUpdate.mockImplementation(store.findOneAndUpdate); + MastodonSchedule.updateOne.mockImplementation(store.updateOne); + MastodonSchedule.updateMany.mockImplementation(store.updateMany); + } + + beforeEach(() => { + jest.resetModules(); + process.env.MASTODON_ACCESS_TOKEN = 'test-token'; + process.env.MASTODON_ENDPOINT = 'https://mastodon.example'; + axios = require('axios'); + cron = require('node-cron'); + MastodonSchedule = require('../../models/mastodonSchedule'); + job = require('../mastodonScheduleJob'); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete process.env.MASTODON_ACCESS_TOKEN; + delete process.env.MASTODON_ENDPOINT; + }); + + it('schedules the job to run every minute', () => { + job.startMastodonScheduleJob(); + expect(cron.schedule).toHaveBeenCalledWith('* * * * *', job.processScheduledPosts); + }); + + it('posts a due post once with an idempotency key and records it as posted', async () => { + useStore([duePost()]); + axios.post.mockResolvedValue({ data: { id: '109' } }); + + await job.processScheduledPosts(); + + expect(axios.post).toHaveBeenCalledTimes(1); + expect(axios.post).toHaveBeenCalledWith( + 'https://mastodon.example/api/v1/statuses', + { status: 'Due post', visibility: 'public' }, + expect.objectContaining({ + headers: { + Authorization: 'Bearer test-token', + 'Idempotency-Key': 'hgn-mastodon-schedule-p1', + }, + }), + ); + expect(store.docs[0]).toMatchObject({ status: 'posted', remoteStatusId: '109' }); + expect(store.docs[0].lockOwner).toBeUndefined(); + + await job.processScheduledPosts(); + expect(axios.post).toHaveBeenCalledTimes(1); + }); + + it('treats records saved without a status as pending', async () => { + const legacy = duePost(); + delete legacy.status; + useStore([legacy]); + axios.post.mockResolvedValue({ data: { id: '1' } }); + + await job.processScheduledPosts(); + + expect(axios.post).toHaveBeenCalledTimes(1); + expect(store.docs[0].status).toBe('posted'); + }); + + it('publishes once when the minute callback runs again while a post is in flight', async () => { + useStore([duePost()]); + let finishFirstPost; + axios.post.mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirstPost = () => resolve({ data: { id: '1' } }); + }), + ); + axios.post.mockResolvedValue({ data: { id: '2' } }); + + job.startMastodonScheduleJob(); + const minuteCallback = cron.schedule.mock.calls[0][1]; + + const firstRun = minuteCallback(new Date()); + await new Promise(setImmediate); + expect(axios.post).toHaveBeenCalledTimes(1); + + await minuteCallback(new Date()); + finishFirstPost(); + await firstRun; + + expect(axios.post).toHaveBeenCalledTimes(1); + expect(store.docs[0].status).toBe('posted'); + }); + + it('publishes once when two workers process the same due post', async () => { + useStore([duePost()]); + axios.post.mockResolvedValue({ data: { id: '1' } }); + + await Promise.all([ + job.processScheduledPosts({ workerId: 'server-a' }), + job.processScheduledPosts({ workerId: 'server-b' }), + ]); + + expect(axios.post).toHaveBeenCalledTimes(1); + expect(store.docs[0].status).toBe('posted'); + }); + + it('does not republish when Mastodon accepts the post but recording it fails', async () => { + useStore([duePost()]); + axios.post.mockResolvedValue({ data: { id: '1' } }); + MastodonSchedule.updateOne.mockRejectedValueOnce(new Error('database unavailable')); + + await job.processScheduledPosts(); + expect(axios.post).toHaveBeenCalledTimes(1); + expect(store.docs[0].status).toBe('publishing'); + + // Later run while the claim is still held + await job.processScheduledPosts(); + expect(axios.post).toHaveBeenCalledTimes(1); + + // Run after the claim has expired + store.docs[0].lockedUntil = new Date(Date.now() - 1000); + await job.processScheduledPosts(); + expect(axios.post).toHaveBeenCalledTimes(1); + expect(store.docs[0].status).toBe('failed'); + }); + + it('does not retry when the request times out, since the post may have gone through', async () => { + useStore([duePost()]); + axios.post.mockRejectedValue(new Error('timeout of 30000ms exceeded')); + + await job.processScheduledPosts(); + await job.processScheduledPosts(); + + expect(axios.post).toHaveBeenCalledTimes(1); + expect(store.docs[0].status).toBe('failed'); + }); + + it('does not publish when a slow image upload outlasts the claim', async () => { + const { uploadMedia } = require('../../controllers/mastodonPostController'); + useStore([ + duePost({ + postData: JSON.stringify({ + status: 'With image', + local_media_base64: 'data:image/png;base64,AA', + }), + }), + ]); + // While the upload is running, the claim expires, another run marks + // the post failed, and the user deletes it + uploadMedia.mockImplementation(async () => { + store.docs[0].lockedUntil = new Date(Date.now() - 1000); + await job.processScheduledPosts({ workerId: 'other-run' }); + store.docs.splice(0, 1); + return 'media-1'; + }); + + await job.processScheduledPosts(); + + expect(uploadMedia).toHaveBeenCalledWith('data:image/png;base64,AA', null, { + timeout: 30000, + }); + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('renews the claim before publishing after an image upload', async () => { + const { uploadMedia } = require('../../controllers/mastodonPostController'); + useStore([ + duePost({ + postData: JSON.stringify({ + status: 'With image', + local_media_base64: 'data:image/png;base64,AA', + }), + }), + ]); + uploadMedia.mockResolvedValue('media-1'); + axios.post.mockImplementation(async () => { + // The renewed claim is still held while the status request runs + expect(store.docs[0].status).toBe('publishing'); + expect(store.docs[0].lockedUntil.getTime()).toBeGreaterThan(Date.now() + 9 * 60 * 1000); + return { data: { id: '1' } }; + }); + + await job.processScheduledPosts(); + + expect(axios.post).toHaveBeenCalledTimes(1); + expect(axios.post.mock.calls[0][1].media_ids).toEqual(['media-1']); + expect(store.docs[0].status).toBe('posted'); + }); + + it('retries a rate-limited post with the same idempotency key', async () => { + useStore([duePost()]); + axios.post.mockRejectedValueOnce( + Object.assign(new Error('Too many requests'), { response: { status: 429, data: {} } }), + ); + axios.post.mockResolvedValue({ data: { id: '1' } }); + + await job.processScheduledPosts(); + expect(store.docs[0].status).toBe('pending'); + + await job.processScheduledPosts(); + expect(axios.post).toHaveBeenCalledTimes(2); + expect(axios.post.mock.calls[1][2].headers['Idempotency-Key']).toBe('hgn-mastodon-schedule-p1'); + expect(store.docs[0].status).toBe('posted'); + }); + + it.each([500, 502, 503, 504])( + 'does not retry a %i response, since the post may have been accepted', + async (status) => { + useStore([duePost()]); + axios.post.mockRejectedValue( + Object.assign(new Error('Server error'), { response: { status, data: {} } }), + ); + + await job.processScheduledPosts(); + await job.processScheduledPosts(); + + expect(axios.post).toHaveBeenCalledTimes(1); + expect(store.docs[0].status).toBe('failed'); + expect(store.docs[0].lastError).toBe('Server error'); + }, + ); + + it('does not publish again after a gateway error and an outage longer than the key window', async () => { + useStore([duePost()]); + // Mastodon accepted the post, but the gateway returned 502 + axios.post.mockRejectedValueOnce( + Object.assign(new Error('Bad gateway'), { response: { status: 502, data: {} } }), + ); + axios.post.mockResolvedValue({ data: { id: '2' } }); + await job.processScheduledPosts(); + + // 61 minutes later the Idempotency-Key has expired + jest.useFakeTimers({ now: Date.now() + 61 * 60 * 1000, doNotFake: ['setImmediate'] }); + try { + await job.processScheduledPosts(); + } finally { + jest.useRealTimers(); + } + + expect(axios.post).toHaveBeenCalledTimes(1); + expect(store.docs[0].status).toBe('failed'); + }); + + it('stops retrying rate-limited posts after the maximum number of attempts', async () => { + useStore([duePost()]); + axios.post.mockRejectedValue( + Object.assign(new Error('Too many requests'), { response: { status: 429, data: {} } }), + ); + + await job.processScheduledPosts(); + await job.processScheduledPosts(); + await job.processScheduledPosts(); + await job.processScheduledPosts(); + + expect(axios.post).toHaveBeenCalledTimes(3); + expect(store.docs[0].status).toBe('failed'); + }); + + it('does not post scheduled posts that are not due yet', async () => { + useStore([duePost({ scheduledTime: new Date(Date.now() + 60 * 60 * 1000) })]); + + await job.processScheduledPosts(); + + expect(axios.post).not.toHaveBeenCalled(); + expect(store.docs[0].status).toBe('pending'); + }); +}); diff --git a/src/cronjobs/mastodonScheduleJob.js b/src/cronjobs/mastodonScheduleJob.js index bd69f61945..8527235171 100644 --- a/src/cronjobs/mastodonScheduleJob.js +++ b/src/cronjobs/mastodonScheduleJob.js @@ -1,3 +1,5 @@ +const crypto = require('crypto'); +const os = require('os'); const cron = require('node-cron'); const axios = require('axios'); const MastodonSchedule = require('../models/mastodonSchedule'); @@ -6,14 +8,38 @@ const { uploadMedia } = require('../controllers/mastodonPostController'); const MASTODON_ENDPOINT = process.env.MASTODON_ENDPOINT || 'https://mastodon.social'; const ACCESS_TOKEN = process.env.MASTODON_ACCESS_TOKEN; +// How long a worker owns a claimed post. Must be longer than one publish +// attempt (media upload plus the status request). +const LEASE_MS = 10 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 30 * 1000; +const MAX_ATTEMPTS = 3; +const MAX_POSTS_PER_RUN = 20; + +// Records saved before delivery state existed have no status field +const PENDING = { $in: ['pending', null] }; +const CLEAR_LOCK = { lockOwner: '', lockedUntil: '' }; + +const WORKER_ID = `${os.hostname()}:${process.pid}:${crypto.randomUUID()}`; + +// Thrown when a worker no longer owns a post it was about to publish +class ClaimLostError extends Error {} + function getAuthHeaders() { if (!ACCESS_TOKEN) throw new Error('MASTODON_ACCESS_TOKEN not set'); return { Authorization: `Bearer ${ACCESS_TOKEN}` }; } -async function postToMastodon(postData) { +// Same key for every attempt at the same scheduled post, so Mastodon +// drops a repeat it has already accepted (it keeps keys for one hour) +function getIdempotencyKey(postId) { + return `hgn-mastodon-schedule-${postId}`; +} + +// beforePublish runs right before the status request, after any slow +// media upload, so a post whose claim has expired is never sent +async function postToMastodon(postData, idempotencyKey, beforePublish = async () => {}) { const url = `${MASTODON_ENDPOINT}/api/v1/statuses`; - const headers = getAuthHeaders(); + const headers = { ...getAuthHeaders(), 'Idempotency-Key': idempotencyKey }; // Parse if string const data = typeof postData === 'string' ? JSON.parse(postData) : postData; @@ -32,7 +58,9 @@ async function postToMastodon(postData) { // eslint-disable-next-line camelcase const altText = data.mediaAltText || null; // eslint-disable-next-line camelcase - const mediaId = await uploadMedia(data.local_media_base64, altText); + const mediaId = await uploadMedia(data.local_media_base64, altText, { + timeout: REQUEST_TIMEOUT_MS, + }); console.log('Image uploaded, media ID:', mediaId); // eslint-disable-next-line camelcase mastodonData.media_ids = [mediaId]; @@ -42,40 +70,161 @@ async function postToMastodon(postData) { } } + await beforePublish(); + console.log('Posting to Mastodon:', `${mastodonData.status.substring(0, 50)}...`); - return axios.post(url, mastodonData, { headers, responseType: 'json' }); + return axios.post(url, mastodonData, { + headers, + responseType: 'json', + timeout: REQUEST_TIMEOUT_MS, + }); +} + +// A post still "publishing" after its lease may already be live on +// Mastodon, so it is marked failed instead of being sent again +async function failExpiredClaims(now) { + const result = await MastodonSchedule.updateMany( + { status: 'publishing', lockedUntil: { $lt: now } }, + { + $set: { + status: 'failed', + lastError: 'Delivery was not confirmed before the claim expired; not retried', + }, + $unset: CLEAR_LOCK, + }, + ); + if (result?.nModified || result?.modifiedCount) { + console.error('Marked unconfirmed scheduled Mastodon posts as failed:', result); + } +} + +// Atomically move one due post from pending to publishing. Only one +// callback or server instance can win a given post. +// Posts already tried in this run are skipped, so a retry waits for the +// next run instead of repeating straight away. +function claimNextDuePost(workerId, dueBefore, triedIds) { + return MastodonSchedule.findOneAndUpdate( + { _id: { $nin: triedIds }, status: PENDING, scheduledTime: { $lte: dueBefore } }, + { + $set: { + status: 'publishing', + lockOwner: workerId, + lockedUntil: new Date(Date.now() + LEASE_MS), + }, + $inc: { attempts: 1 }, + }, + { new: true, sort: { scheduledTime: 1 } }, + ); +} + +// Renew the claim right before publishing. Fails if the claim expired +// (for example during a slow media upload) and was marked failed, which +// also means the post may since have been deleted. +async function confirmClaim(post, workerId) { + const now = new Date(); + const renewed = await MastodonSchedule.findOneAndUpdate( + { _id: post._id, status: 'publishing', lockOwner: workerId, lockedUntil: { $gt: now } }, + { $set: { lockedUntil: new Date(now.getTime() + LEASE_MS) } }, + { new: true }, + ); + if (!renewed) throw new ClaimLostError('Claim expired before publishing'); +} + +// Only a 429 is retried: Mastodon rate-limited the request, so nothing +// was posted. A server or gateway error (5xx) may come back after the +// post was accepted, and Mastodon keeps the Idempotency-Key for at most +// an hour, so a later retry could publish it twice. Those posts, like +// timeouts and lost connections, are marked failed for the user to +// reschedule. +function isRetryable(post, err) { + return err.response?.status === 429 && (post.attempts || 0) < MAX_ATTEMPTS; +} + +async function releaseFailedClaim(post, workerId, err) { + const retry = isRetryable(post, err); + const message = err.response?.data?.error || err.message; + console.error(`❌ Failed to post scheduled Mastodon post ${post._id}:`, message); + + const update = { status: retry ? 'pending' : 'failed', lastError: message }; + + try { + await MastodonSchedule.updateOne( + { _id: post._id, status: 'publishing', lockOwner: workerId }, + { $set: update, $unset: CLEAR_LOCK }, + ); + } catch (updateErr) { + // The claim stays in place and expires to failed, so nothing is resent + console.error(`Could not release scheduled Mastodon post ${post._id}:`, updateErr.message); + } } -async function processScheduledPosts() { +async function recordDelivery(post, workerId, response) { + const delivered = { status: 'posted', postedAt: new Date() }; + if (response?.data?.id) delivered.remoteStatusId = String(response.data.id); + try { - const now = new Date(); - const scheduled = await MastodonSchedule.find({ - scheduledTime: { $lte: now }, - }); + await MastodonSchedule.updateOne( + { _id: post._id, status: 'publishing', lockOwner: workerId }, + { $set: delivered, $unset: { ...CLEAR_LOCK, lastError: '' } }, + ); + console.log(`✅ Posted scheduled Mastodon post: ${post._id}`); + } catch (err) { + // The claim stays in place and expires to failed, so the post is not + // published a second time + console.error( + `Posted scheduled Mastodon post ${post._id} but could not record it:`, + err.message, + ); + } +} - if (scheduled.length > 0) { - console.log(`Found ${scheduled.length} scheduled posts to process`); +async function publishClaimedPost(post, workerId) { + console.log(`Processing scheduled post ${post._id}`); + let response; + try { + response = await postToMastodon(post.postData, getIdempotencyKey(post._id), () => + confirmClaim(post, workerId), + ); + } catch (err) { + if (err instanceof ClaimLostError) { + // Another run already marked this post failed; leave it as it is + console.error(`Skipped scheduled Mastodon post ${post._id}:`, err.message); + return; } + await releaseFailedClaim(post, workerId, err); + return; + } + await recordDelivery(post, workerId, response); +} - // Use Promise.all with map instead of for-of loop - await Promise.all( - scheduled.map(async (post) => { - try { - console.log(`Processing scheduled post ${post._id}`); - await postToMastodon(post.postData); - await MastodonSchedule.deleteOne({ _id: post._id }); - console.log(`✅ Posted scheduled Mastodon post: ${post._id}`); - } catch (err) { - console.error(`❌ Failed to post scheduled Mastodon post ${post._id}:`, err.message); - if (err.response?.data) { - console.error('Mastodon API error:', err.response.data); - } - } - }), - ); +// node-cron passes the run time as the first argument, so options are +// read defensively +async function processScheduledPosts(options) { + const workerId = options?.workerId || WORKER_ID; + const runStartedAt = new Date(); + const triedIds = []; + + try { + await failExpiredClaims(runStartedAt); } catch (err) { - console.error('Error processing scheduled Mastodon posts:', err.message); + console.error('Error expiring scheduled Mastodon claims:', err.message); + } + + // Posts are claimed and sent one at a time + for (let i = 0; i < MAX_POSTS_PER_RUN; i += 1) { + let post; + try { + // eslint-disable-next-line no-await-in-loop + post = await claimNextDuePost(workerId, runStartedAt, triedIds); + } catch (err) { + console.error('Error claiming scheduled Mastodon posts:', err.message); + return; + } + if (!post) return; + triedIds.push(post._id); + // eslint-disable-next-line no-await-in-loop + await publishClaimedPost(post, workerId); } } diff --git a/src/models/mastodonSchedule.js b/src/models/mastodonSchedule.js index 1f1f1156d7..15986636be 100644 --- a/src/models/mastodonSchedule.js +++ b/src/models/mastodonSchedule.js @@ -2,9 +2,25 @@ const mongoose = require('mongoose'); const { Schema } = mongoose; +// Delivery state for a scheduled post: +// pending -> publishing (claimed by one worker) -> posted | failed. +// Records created before this field existed have no status and are +// treated as pending. +const DELIVERY_STATUSES = ['pending', 'publishing', 'posted', 'failed']; + const mastodonSchedule = new Schema({ postData: { type: String, required: true }, scheduledTime: { type: Date, required: true }, + status: { type: String, enum: DELIVERY_STATUSES, default: 'pending' }, + lockOwner: { type: String }, + lockedUntil: { type: Date }, + attempts: { type: Number, default: 0 }, + remoteStatusId: { type: String }, + postedAt: { type: Date }, + lastError: { type: String }, }); +mastodonSchedule.index({ status: 1, scheduledTime: 1 }); + module.exports = mongoose.model('mastodonSchedule', mastodonSchedule); +module.exports.DELIVERY_STATUSES = DELIVERY_STATUSES; diff --git a/src/routes/__tests__/mastodonRouter.test.js b/src/routes/__tests__/mastodonRouter.test.js new file mode 100644 index 0000000000..c6850d0c8b --- /dev/null +++ b/src/routes/__tests__/mastodonRouter.test.js @@ -0,0 +1,131 @@ +const express = require('express'); +const request = require('supertest'); + +jest.mock('../../utilities/permissions', () => ({ hasPermission: jest.fn() })); +jest.mock('../../models/mastodonSchedule', () => ({ + create: jest.fn(), + find: jest.fn(), + deleteOne: jest.fn(), +})); + +const { hasPermission } = require('../../utilities/permissions'); +const MastodonSchedule = require('../../models/mastodonSchedule'); +const mastodonRouter = require('../mastodonRouter'); + +// Mirrors how the app mounts the router, with a stand-in for the auth +// middleware that normally puts the logged-in user on req.body.requestor. +const buildApp = () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.body = { ...(req.body || {}), requestor: { requestorId: 'user-1', role: 'Administrator' } }; + next(); + }); + app.use('/api', mastodonRouter); + return app; +}; + +const inOneHour = () => new Date(Date.now() + 60 * 60 * 1000).toISOString(); + +describe('mastodonRouter', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + hasPermission.mockResolvedValue(true); + app = buildApp(); + }); + + describe('permissions', () => { + it('rejects people without the announcements permission', async () => { + hasPermission.mockResolvedValue(false); + + const res = await request(app).get('/api/mastodon/schedule'); + + expect(res.status).toBe(403); + expect(hasPermission).toHaveBeenCalledWith( + expect.objectContaining({ requestorId: 'user-1' }), + 'sendEmails', + ); + expect(MastodonSchedule.find).not.toHaveBeenCalled(); + }); + + it('returns 500 and logs the error when the permission check fails', async () => { + hasPermission.mockRejectedValue(new Error('database unavailable')); + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const res = await request(app).get('/api/mastodon/schedule'); + + expect(res.status).toBe(500); + expect(consoleError).toHaveBeenCalledWith( + 'Mastodon permission check failed:', + 'database unavailable', + ); + expect(MastodonSchedule.find).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it('lets people with the permission through', async () => { + MastodonSchedule.find.mockResolvedValue([]); + + const res = await request(app).get('/api/mastodon/schedule'); + + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + }); + + describe('POST /api/mastodon/schedule', () => { + it('stores a valid scheduled post', async () => { + MastodonSchedule.create.mockResolvedValue({}); + const scheduledTime = inOneHour(); + + const res = await request(app) + .post('/api/mastodon/schedule') + .send({ description: ' Hello Mastodon ', scheduledTime }); + + expect(res.status).toBe(200); + const saved = MastodonSchedule.create.mock.calls[0][0]; + expect(JSON.parse(saved.postData)).toEqual({ + status: 'Hello Mastodon', + visibility: 'public', + }); + expect(saved.scheduledTime.toISOString()).toBe(scheduledTime); + }); + + it.each([ + ['empty content', { description: ' ', scheduledTime: inOneHour() }], + ['a missing time', { description: 'Hello' }], + ['an invalid time', { description: 'Hello', scheduledTime: 'not-a-date' }], + ['a time in the past', { description: 'Hello', scheduledTime: '2020-01-01T00:00:00Z' }], + ])('rejects %s with 400', async (_label, body) => { + const res = await request(app).post('/api/mastodon/schedule').send(body); + + expect(res.status).toBe(400); + expect(res.body.error).toBeTruthy(); + expect(MastodonSchedule.create).not.toHaveBeenCalled(); + }); + }); + + describe('DELETE /api/mastodon/schedule/:id', () => { + it('deletes an existing scheduled post', async () => { + MastodonSchedule.deleteOne.mockResolvedValue({ deletedCount: 1 }); + + const res = await request(app).delete('/api/mastodon/schedule/abc123'); + + expect(res.status).toBe(200); + expect(MastodonSchedule.deleteOne).toHaveBeenCalledWith({ + _id: 'abc123', + status: { $in: ['pending', null, 'failed'] }, + }); + }); + + it('returns 404 when the post does not exist', async () => { + MastodonSchedule.deleteOne.mockResolvedValue({ deletedCount: 0 }); + + const res = await request(app).delete('/api/mastodon/schedule/missing'); + + expect(res.status).toBe(404); + }); + }); +}); diff --git a/src/routes/mastodonRouter.js b/src/routes/mastodonRouter.js index da95e72fa9..2f1984d8c0 100644 --- a/src/routes/mastodonRouter.js +++ b/src/routes/mastodonRouter.js @@ -1,19 +1,3 @@ -// const express = require('express'); -// const { -// createPin, -// schedulePin, -// fetchScheduledPin, -// deletedScheduledPin, -// } = require('../controllers/mastodonPostController'); - -// const mastodonRouter = express.Router(); - -// mastodonRouter.post('/mastodon/createPin', createPin); -// mastodonRouter.post('/mastodon/schedule', schedulePin); -// mastodonRouter.get('/mastodon/schedule', fetchScheduledPin); -// mastodonRouter.delete('/mastodon/schedule/:id', deletedScheduledPin); - -// module.exports = mastodonRouter; const express = require('express'); const { createPin, @@ -22,9 +6,25 @@ const { deletedScheduledPin, fetchPostHistory, } = require('../controllers/mastodonPostController'); +const { hasPermission } = require('../utilities/permissions'); const mastodonRouter = express.Router(); +// Posting to Mastodon uses the organization's account, so only people who +// can send announcements (the same permission as the Announcements page) +// may use these routes. +async function requireAnnouncementsPermission(req, res, next) { + try { + if (await hasPermission(req.body?.requestor, 'sendEmails')) return next(); + return res.status(403).json({ error: 'You are not authorized to post to Mastodon.' }); + } catch (err) { + console.error('Mastodon permission check failed:', err.message); + return res.status(500).json({ error: 'Failed to check permissions.' }); + } +} + +mastodonRouter.use('/mastodon', requireAnnouncementsPermission); + mastodonRouter.post('/mastodon/createPin', createPin); mastodonRouter.post('/mastodon/schedule', schedulePin); mastodonRouter.get('/mastodon/schedule', fetchScheduledPin); diff --git a/src/server.js b/src/server.js index 4ec46ec767..fb61a81771 100644 --- a/src/server.js +++ b/src/server.js @@ -17,6 +17,7 @@ require('./cronjobs/userProfileJobs')(); require('./cronjobs/pullRequestReviewJobs')(); require('./jobs/analyticsAggregation').scheduleDaily(); require('./cronjobs/bidWinnerJobs')(); +require('./cronjobs/mastodonScheduleJob').startMastodonScheduleJob(); // Process pending and stuck emails on startup (only after DB is connected) mongoose.connection.once('connected', () => { diff --git a/src/startup/middleware.js b/src/startup/middleware.js index 72a8439ed9..b61af9f32a 100644 --- a/src/startup/middleware.js +++ b/src/startup/middleware.js @@ -33,10 +33,6 @@ module.exports = function (app) { app.use(express.urlencoded({ limit: '50mb', extended: true })); app.all('*', (req, res, next) => { - // Allow unauthenticated access for Mastodon test APIs - if (req.originalUrl.startsWith('/api/mastodon')) { - return next(); - } const openPaths = ['/api/lb/myWebhooks']; if (req.originalUrl === '/') { diff --git a/src/startup/routes.js b/src/startup/routes.js index c83c6bbd9e..ccbb694b55 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -62,6 +62,7 @@ const blueSquareEmailAssignment = require('../models/BlueSquareEmailAssignment') const hgnformRouter = require('../routes/hgnformRouter'); const hgnFormResponseRouter = require('../routes/hgnFormResponseRouter'); const progressRouter = require('../routes/progressRouter'); +const mastodonRouter = require('../routes/mastodonRouter'); const questionnaireAnalyticsRouter = require('../routes/questionnaireAnalyticsRouter'); const applicantAnalyticsRouter = require('../routes/applicantAnalyticsRoutes'); @@ -481,6 +482,7 @@ module.exports = function (app) { app.use('/api', userProfileRouter); app.use('/api', dashboardRouter); app.use('/api', timeEntryRouter); + app.use('/api', mastodonRouter); app.use('/api', timelogTrackingRouter); app.use('/api', teamRouter); app.use('/api', wastedMaterialRouter);