diff --git a/.env.example b/.env.example index 88e8a72c5..f661dbf1b 100644 --- a/.env.example +++ b/.env.example @@ -197,3 +197,4 @@ SEARCH_RETRY_DELAY=5000 # DEPENDENCY_WEBHOOK_URL=your_discord_webhook_url # DEPENDENCY_CHECK_INTERVAL=86400000 # DEPENDENCY_NOTIFY_ONLY_SECURITY=false +LUCKY_NOTIFY_API_KEY= diff --git a/docker-compose.yml b/docker-compose.yml index 4170f3552..f4be7da9b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -126,6 +126,7 @@ services: environment: - NODE_ENV=production - DISCORD_TOKEN=${DISCORD_TOKEN} + - LUCKY_NOTIFY_API_KEY=${LUCKY_NOTIFY_API_KEY:-} - CLIENT_ID=${CLIENT_ID} - CLIENT_SECRET=${CLIENT_SECRET} - SENTRY_DSN=${SENTRY_DSN:-} diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 5d93f21ea..ff31d1272 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -17,6 +17,7 @@ import { setupLevelsRoutes } from './levels' import { setupStarboardRoutes } from './starboard' import { setupMusicRoutes } from './music' import { setupArtistsRoutes } from './artists' +import { setupInternalNotifyRoutes } from './internalNotify' import { apiLimiter } from '../middleware/rateLimit' import { requireAuth } from '../middleware/auth' import { requireGuildModuleAccess } from '../middleware/guildAccess' @@ -77,6 +78,7 @@ const routeSetups = [ export function setupRoutes(app: Express): void { setupHealthRoutes(app) + setupInternalNotifyRoutes(app) app.use('/api/', apiLimiter) for (const config of guildGuardConfigs) { diff --git a/packages/backend/src/routes/internalNotify.ts b/packages/backend/src/routes/internalNotify.ts new file mode 100644 index 000000000..5d27ba55e --- /dev/null +++ b/packages/backend/src/routes/internalNotify.ts @@ -0,0 +1,67 @@ +import type { Express, Request, Response } from 'express' +import { writeLimiter } from '../middleware/rateLimit' +import { asyncHandler } from '../middleware/asyncHandler' +import { AppError } from '../errors/AppError' +import { errorLog } from '@lucky/shared/utils' + +const DISCORD_API = 'https://discord.com/api/v10' + +function requireKey(req: Request): void { + const provided = req.header('x-notify-key') + const expected = process.env.LUCKY_NOTIFY_API_KEY + if (!expected || !provided || provided !== expected) { + throw AppError.unauthorized('invalid notify key') + } +} + +type NotifyBody = { + channelId?: string + content?: string + embeds?: unknown[] +} + +export function setupInternalNotifyRoutes(app: Express): void { + app.post( + '/api/internal/notify', + writeLimiter, + asyncHandler(async (req: Request, res: Response) => { + requireKey(req) + const body = (req.body ?? {}) as NotifyBody + if (!body.channelId || (!body.content && !body.embeds)) { + throw AppError.badRequest( + 'channelId + content|embeds required', + ) + } + const token = process.env.DISCORD_TOKEN + if (!token) { + throw new AppError(500, 'bot token missing') + } + + const resp = await fetch( + `${DISCORD_API}/channels/${body.channelId}/messages`, + { + method: 'POST', + headers: { + Authorization: `Bot ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + content: body.content?.slice(0, 1900), + embeds: body.embeds, + }), + }, + ) + + if (!resp.ok) { + const text = await resp.text().catch(() => '') + errorLog({ + message: 'internal notify failed', + data: { status: resp.status, text }, + }) + throw new AppError(502, `discord ${resp.status}`) + } + + res.status(204).send() + }), + ) +} diff --git a/packages/backend/tests/integration/routes/internalNotify.test.ts b/packages/backend/tests/integration/routes/internalNotify.test.ts new file mode 100644 index 000000000..d656d588f --- /dev/null +++ b/packages/backend/tests/integration/routes/internalNotify.test.ts @@ -0,0 +1,384 @@ +import { describe, test, expect, beforeEach, jest } from '@jest/globals' +import request from 'supertest' +import express from 'express' +import { setupInternalNotifyRoutes } from '../../../src/routes/internalNotify' +import { errorHandler } from '../../../src/middleware/errorHandler' + +const API_KEY = 'test-notify-api-key-12345' +const DISCORD_TOKEN = 'test-bot-token' +const CHANNEL_ID = '123456789012345678' + +describe('Internal Notify Routes Integration', () => { + let app: express.Express + + beforeEach(() => { + app = express() + app.use(express.json()) + setupInternalNotifyRoutes(app) + app.use(errorHandler) + + jest.clearAllMocks() + process.env.LUCKY_NOTIFY_API_KEY = API_KEY + process.env.DISCORD_TOKEN = DISCORD_TOKEN + + global.fetch = jest.fn() + }) + + describe('POST /api/internal/notify', () => { + test('should send message with content and return 204', async () => { + const mockFetch = global.fetch as jest.MockedFunction< + typeof fetch + > + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue(''), + } as any) + + const response = await request(app) + .post('/api/internal/notify') + .set('x-notify-key', API_KEY) + .send({ + channelId: CHANNEL_ID, + content: 'Test message', + }) + + expect(response.status).toBe(204) + expect(mockFetch).toHaveBeenCalledWith( + `https://discord.com/api/v10/channels/${CHANNEL_ID}/messages`, + expect.objectContaining({ + method: 'POST', + headers: { + Authorization: `Bot ${DISCORD_TOKEN}`, + 'Content-Type': 'application/json', + }, + }), + ) + }) + + test('should send message with embeds and return 204', async () => { + const mockFetch = global.fetch as jest.MockedFunction< + typeof fetch + > + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue(''), + } as any) + + const embed = { + title: 'Test Embed', + description: 'Test description', + color: 3447003, + } + + const response = await request(app) + .post('/api/internal/notify') + .set('x-notify-key', API_KEY) + .send({ + channelId: CHANNEL_ID, + embeds: [embed], + }) + + expect(response.status).toBe(204) + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining(JSON.stringify(embed)), + }), + ) + }) + + test('should truncate content to 1900 chars', async () => { + const mockFetch = global.fetch as jest.MockedFunction< + typeof fetch + > + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue(''), + } as any) + + const longContent = 'x'.repeat(2500) + + await request(app) + .post('/api/internal/notify') + .set('x-notify-key', API_KEY) + .send({ + channelId: CHANNEL_ID, + content: longContent, + }) + + const callArgs = mockFetch.mock.calls[0] + const bodyStr = callArgs[1]?.body as string + const body = JSON.parse(bodyStr) + + expect(body.content).toBe('x'.repeat(1900)) + expect(body.content.length).toBe(1900) + }) + + test('should reject requests without api key', async () => { + const response = await request(app) + .post('/api/internal/notify') + .send({ + channelId: CHANNEL_ID, + content: 'Test message', + }) + + expect(response.status).toBe(401) + expect(response.body.error).toContain('invalid notify key') + }) + + test('should reject requests with wrong api key', async () => { + const response = await request(app) + .post('/api/internal/notify') + .set('x-notify-key', 'wrong-key') + .send({ + channelId: CHANNEL_ID, + content: 'Test message', + }) + + expect(response.status).toBe(401) + expect(response.body.error).toContain('invalid notify key') + }) + + test('should reject request when api key not configured', async () => { + delete process.env.LUCKY_NOTIFY_API_KEY + + const response = await request(app) + .post('/api/internal/notify') + .set('x-notify-key', 'any-key') + .send({ + channelId: CHANNEL_ID, + content: 'Test message', + }) + + expect(response.status).toBe(401) + }) + + test('should return 400 when channelId is missing', async () => { + const response = await request(app) + .post('/api/internal/notify') + .set('x-notify-key', API_KEY) + .send({ + content: 'Test message', + }) + + expect(response.status).toBe(400) + expect(response.body.error).toContain('channelId') + }) + + test('should return 400 when both content and embeds are missing', async () => { + const response = await request(app) + .post('/api/internal/notify') + .set('x-notify-key', API_KEY) + .send({ + channelId: CHANNEL_ID, + }) + + expect(response.status).toBe(400) + expect(response.body.error).toContain( + 'content|embeds required', + ) + }) + + test('should accept empty body object', async () => { + const response = await request(app) + .post('/api/internal/notify') + .set('x-notify-key', API_KEY) + .send({}) + + expect(response.status).toBe(400) + expect(response.body.error).toContain('channelId') + }) + + test('should return 500 when Discord token is missing', async () => { + delete process.env.DISCORD_TOKEN + + const response = await request(app) + .post('/api/internal/notify') + .set('x-notify-key', API_KEY) + .send({ + channelId: CHANNEL_ID, + content: 'Test message', + }) + + expect(response.status).toBe(500) + expect(response.body.error).toContain('bot token missing') + }) + + test('should return 502 when Discord API returns error', async () => { + const mockFetch = global.fetch as jest.MockedFunction< + typeof fetch + > + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + text: jest.fn().mockResolvedValue('Forbidden'), + } as any) + + const response = await request(app) + .post('/api/internal/notify') + .set('x-notify-key', API_KEY) + .send({ + channelId: CHANNEL_ID, + content: 'Test message', + }) + + expect(response.status).toBe(502) + expect(response.body.error).toContain('discord 403') + }) + + test('should handle Discord API 404 error', async () => { + const mockFetch = global.fetch as jest.MockedFunction< + typeof fetch + > + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + text: jest.fn().mockResolvedValue('Not Found'), + } as any) + + const response = await request(app) + .post('/api/internal/notify') + .set('x-notify-key', API_KEY) + .send({ + channelId: CHANNEL_ID, + content: 'Test message', + }) + + expect(response.status).toBe(502) + expect(response.body.error).toContain('discord 404') + }) + + test('should handle Discord API network error gracefully', async () => { + const mockFetch = global.fetch as jest.MockedFunction< + typeof fetch + > + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + text: jest.fn().mockResolvedValue('Internal Server Error'), + } as any) + + const response = await request(app) + .post('/api/internal/notify') + .set('x-notify-key', API_KEY) + .send({ + channelId: CHANNEL_ID, + content: 'Test message', + }) + + expect(response.status).toBe(502) + expect(response.body.error).toContain('discord 500') + }) + + test('should handle fetch text parsing error', async () => { + const mockFetch = global.fetch as jest.MockedFunction< + typeof fetch + > + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + text: jest + .fn() + .mockRejectedValue(new Error('Parse error')), + } as any) + + const response = await request(app) + .post('/api/internal/notify') + .set('x-notify-key', API_KEY) + .send({ + channelId: CHANNEL_ID, + content: 'Test message', + }) + + expect(response.status).toBe(502) + expect(response.body.error).toContain('discord 500') + }) + + test('should send both content and embeds when provided', async () => { + const mockFetch = global.fetch as jest.MockedFunction< + typeof fetch + > + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue(''), + } as any) + + const embed = { title: 'Embed', color: 16711680 } + + const response = await request(app) + .post('/api/internal/notify') + .set('x-notify-key', API_KEY) + .send({ + channelId: CHANNEL_ID, + content: 'Message with embed', + embeds: [embed], + }) + + expect(response.status).toBe(204) + + const callArgs = mockFetch.mock.calls[0] + const bodyStr = callArgs[1]?.body as string + const body = JSON.parse(bodyStr) + + expect(body.content).toBe('Message with embed') + expect(body.embeds).toEqual([embed]) + }) + + test('should use correct Discord API endpoint', async () => { + const mockFetch = global.fetch as jest.MockedFunction< + typeof fetch + > + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue(''), + } as any) + + await request(app) + .post('/api/internal/notify') + .set('x-notify-key', API_KEY) + .send({ + channelId: CHANNEL_ID, + content: 'Test', + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://discord.com/api/v10/channels/123456789012345678/messages', + expect.any(Object), + ) + }) + + test('should accept content with special characters', async () => { + const mockFetch = global.fetch as jest.MockedFunction< + typeof fetch + > + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue(''), + } as any) + + const specialContent = '🎵 Now playing: "Test" [Explicit] \n\n' + + const response = await request(app) + .post('/api/internal/notify') + .set('x-notify-key', API_KEY) + .send({ + channelId: CHANNEL_ID, + content: specialContent, + }) + + expect(response.status).toBe(204) + + const callArgs = mockFetch.mock.calls[0] + const bodyStr = callArgs[1]?.body as string + const body = JSON.parse(bodyStr) + + expect(body.content).toBe(specialContent) + }) + }) +}) diff --git a/packages/backend/tests/unit/routes/index.test.ts b/packages/backend/tests/unit/routes/index.test.ts index 96dbd0671..360f280f5 100644 --- a/packages/backend/tests/unit/routes/index.test.ts +++ b/packages/backend/tests/unit/routes/index.test.ts @@ -20,6 +20,7 @@ const setupStarboardRoutes = jest.fn() const setupMusicRoutes = jest.fn() const setupSpotifyRoutes = jest.fn() const setupArtistsRoutes = jest.fn() +const setupInternalNotifyRoutes = jest.fn() const requireGuildModuleAccess = jest.fn() const apiLimiter = jest.fn() @@ -102,6 +103,10 @@ jest.mock('../../../src/routes/artists', () => ({ setupArtistsRoutes, })) +jest.mock("../../../src/routes/internalNotify", () => ({ + setupInternalNotifyRoutes, +})) + jest.mock('../../../src/middleware/rateLimit', () => ({ apiLimiter, })) @@ -139,6 +144,7 @@ describe('setupRoutes', () => { const useCalls = app.use.mock.calls as unknown[][] expect(setupHealthRoutes).toHaveBeenCalledWith(app) + expect(setupInternalNotifyRoutes).toHaveBeenCalledWith(app) expect(app.use).toHaveBeenCalledWith('/api/', apiLimiter) expect(requireGuildModuleAccess).toHaveBeenCalledWith('moderation') diff --git a/packages/backend/tests/unit/routes/internalNotify.test.ts b/packages/backend/tests/unit/routes/internalNotify.test.ts new file mode 100644 index 000000000..cbd0f19d6 --- /dev/null +++ b/packages/backend/tests/unit/routes/internalNotify.test.ts @@ -0,0 +1,127 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals' +import express from 'express' +import request from 'supertest' + +import { setupInternalNotifyRoutes } from '../../../src/routes/internalNotify' + +const ORIGINAL_FETCH = global.fetch + +function buildApp(): express.Express { + const app = express() + app.use(express.json()) + setupInternalNotifyRoutes(app) + app.use( + ( + err: { statusCode?: number; message?: string }, + _req: express.Request, + res: express.Response, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _next: express.NextFunction, + ) => { + res.status(err.statusCode ?? 500).json({ + error: err.message ?? 'unknown', + }) + }, + ) + return app +} + +beforeEach(() => { + process.env.LUCKY_NOTIFY_API_KEY = 'k' + process.env.DISCORD_TOKEN = 'tok' +}) + +afterEach(() => { + global.fetch = ORIGINAL_FETCH + delete process.env.LUCKY_NOTIFY_API_KEY + delete process.env.DISCORD_TOKEN +}) + +describe('POST /api/internal/notify', () => { + it('rejects when LUCKY_NOTIFY_API_KEY is unset', async () => { + delete process.env.LUCKY_NOTIFY_API_KEY + const res = await request(buildApp()) + .post('/api/internal/notify') + .set('x-notify-key', 'anything') + .send({ channelId: '1', content: 'hi' }) + expect(res.status).toBe(401) + }) + + it('rejects on wrong key', async () => { + const res = await request(buildApp()) + .post('/api/internal/notify') + .set('x-notify-key', 'wrong') + .send({ channelId: '1', content: 'hi' }) + expect(res.status).toBe(401) + }) + + it('rejects on missing channelId', async () => { + const res = await request(buildApp()) + .post('/api/internal/notify') + .set('x-notify-key', 'k') + .send({ content: 'hi' }) + expect(res.status).toBe(400) + }) + + it('rejects on missing content+embeds', async () => { + const res = await request(buildApp()) + .post('/api/internal/notify') + .set('x-notify-key', 'k') + .send({ channelId: '1' }) + expect(res.status).toBe(400) + }) + + it('forwards to Discord and returns 204 on success', async () => { + const fetchMock = jest.fn(async () => ({ + ok: true, + status: 200, + text: async () => '', + })) + global.fetch = fetchMock as unknown as typeof fetch + + const res = await request(buildApp()) + .post('/api/internal/notify') + .set('x-notify-key', 'k') + .send({ channelId: '12345', content: 'hello' }) + expect(res.status).toBe(204) + expect(fetchMock).toHaveBeenCalledWith( + 'https://discord.com/api/v10/channels/12345/messages', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bot tok', + }), + }), + ) + }) + + it('returns 502 when Discord rejects', async () => { + global.fetch = jest.fn(async () => ({ + ok: false, + status: 403, + text: async () => 'forbidden', + })) as unknown as typeof fetch + + const res = await request(buildApp()) + .post('/api/internal/notify') + .set('x-notify-key', 'k') + .send({ channelId: '1', content: 'x' }) + expect(res.status).toBe(502) + }) + + it('returns 500 when DISCORD_TOKEN missing', async () => { + delete process.env.DISCORD_TOKEN + const res = await request(buildApp()) + .post('/api/internal/notify') + .set('x-notify-key', 'k') + .send({ channelId: '1', content: 'x' }) + expect(res.status).toBe(500) + }) +})