diff --git a/.env.example b/.env.example index c15a6df4..66039394 100644 --- a/.env.example +++ b/.env.example @@ -13,8 +13,18 @@ NEXT_PUBLIC_CMS_URL=https://cms.your-domain.com CMS_API_URL=https://cms.your-domain.com CMS_API_TOKEN=your_strapi_api_token +# Server-side only — listmonk newsletter integration +# Base URL of listmonk instance (no trailing slash preferred) +LISTMONK_BASE_URL=https://newsletter.project-sentiment.org +# listmonk API user (basic auth username) +LISTMONK_API_USER=auth +# listmonk API key (basic auth password) +LISTMONK_API_KEY=your_listmonk_api_key +# list ID to subscribe/unsubscribe against +LISTMONK_LIST_ID=1 + # ALTCHA bot protection — server-side only -# Must match ALTCHA_HMAC_SECRET in the Strapi CMS .env +# Used by Next.js API routes (`/api/newsletter/challenge` and `/api/newsletter/subscribe`) # Generate with: openssl rand -hex 32 ALTCHA_HMAC_SECRET=your_altcha_hmac_secret diff --git a/package.json b/package.json index 641c17aa..d2cf0e2a 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,8 @@ "@hookform/resolvers": "5.2.2", "@react-email/components": "1.0.12", "@strapi/blocks-react-renderer": "1.0.2", - "altcha": "2.3.0", - "altcha-lib": "1.4.1", + "altcha": "3.0.4", + "altcha-lib": "2.0.3", "clsx": "2.1.1", "lucide-react": "1.9.0", "next": "16.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adb27640..b07e2c78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,11 +22,11 @@ importers: specifier: 1.0.2 version: 1.0.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) altcha: - specifier: 2.3.0 - version: 2.3.0 + specifier: 3.0.4 + version: 3.0.4 altcha-lib: - specifier: 1.4.1 - version: 1.4.1 + specifier: 2.0.3 + version: 2.0.3 clsx: specifier: 2.1.1 version: 2.1.1 @@ -170,9 +170,6 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@altcha/crypto@0.0.1': - resolution: {integrity: sha512-qZMdnoD3lAyvfSUMNtC2adRi666Pxdcw9zqfMU5qBOaJWqpN9K+eqQGWqeiKDMqL0SF+EytNG4kR/Pr/99GJ6g==} - '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -1118,12 +1115,6 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc - '@rollup/rollup-linux-x64-gnu@4.18.0': - resolution: {integrity: sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==} - cpu: [x64] - os: [linux] - libc: [glibc] - '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1420,11 +1411,53 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - altcha-lib@1.4.1: - resolution: {integrity: sha512-MAXP9tkQOA2SE9Gwoe3LAcZbcDpp3XzYc5GDVej/y3eMNaFG/eVnRY1/7SGFW0RPsViEjPf+hi5eANjuZrH1xA==} + altcha-lib@2.0.3: + resolution: {integrity: sha512-rbBOrVcg5DiBPfn5dfhT+7J5k/Dqtj4seR5m1BB+w0QK4pZOBSS0ZpZ5PkolypmR315/SVbpXHM/QuL0kH5Seg==} + hasBin: true + peerDependencies: + '@fastify/cookie': ^11.0.0 + '@fastify/cors': ^11.0.0 + '@fastify/formbody': ^8.0.0 + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/platform-express': ^10.0.0 || ^11.0.0 + '@sveltejs/kit': ^2.0.0 + cookie-parser: ^1.0.0 + cors: ^2.0.0 + express: ^4.0.0 || ^5.0.0 + fastify: ^5.0.0 + h3: ^2.0.0 + hono: ^4.0.0 + peerDependenciesMeta: + '@fastify/cookie': + optional: true + '@fastify/cors': + optional: true + '@fastify/formbody': + optional: true + '@nestjs/common': + optional: true + '@nestjs/core': + optional: true + '@nestjs/platform-express': + optional: true + '@sveltejs/kit': + optional: true + cookie-parser: + optional: true + cors: + optional: true + express: + optional: true + fastify: + optional: true + h3: + optional: true + hono: + optional: true - altcha@2.3.0: - resolution: {integrity: sha512-vl8I0dQvSQB7/Mx09XuWZ1+LdSP7vEda6OLbg9kUQ2ZO2LT7MzgUyLK7Iips+GAV6c0ntVcS1XWOqhEPpwbDhQ==} + altcha@3.0.4: + resolution: {integrity: sha512-TA1N0vnKR7aI/usDN2y3dPDcg9DdsDQKvRuCwApMtw3kJHjxH7NRtt+hSV8NZ01tkfHqXEJOtCYRFr8sxw8Fbg==} ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} @@ -2403,6 +2436,9 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hash-wasm@4.12.0: + resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -4105,8 +4141,6 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@altcha/crypto@0.0.1': {} - '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.5 @@ -5078,9 +5112,6 @@ snapshots: dependencies: react: 19.2.5 - '@rollup/rollup-linux-x64-gnu@4.18.0': - optional: true - '@rtsao/scc@1.1.0': {} '@selderee/plugin-htmlparser2@0.11.0': @@ -5387,13 +5418,11 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - altcha-lib@1.4.1: {} + altcha-lib@2.0.3: {} - altcha@2.3.0: + altcha@3.0.4: dependencies: - '@altcha/crypto': 0.0.1 - optionalDependencies: - '@rollup/rollup-linux-x64-gnu': 4.18.0 + hash-wasm: 4.12.0 ansi-escapes@4.3.2: dependencies: @@ -6622,6 +6651,8 @@ snapshots: dependencies: has-symbols: 1.1.0 + hash-wasm@4.12.0: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 diff --git a/public/images/services/listmonk/sentiment_listmonk-logo.png b/public/images/services/listmonk/sentiment_listmonk-logo.png new file mode 100644 index 00000000..c326db84 Binary files /dev/null and b/public/images/services/listmonk/sentiment_listmonk-logo.png differ diff --git a/src/__tests__/app/api/newsletter/confirm.test.ts b/src/__tests__/app/api/newsletter/confirm.test.ts deleted file mode 100644 index 5cf798b1..00000000 --- a/src/__tests__/app/api/newsletter/confirm.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * @jest-environment node - */ - -// Mock @/constant/env with getters so values are read at call time (after beforeEach sets process.env) -jest.mock('@/constant/env', () => ({ - get cmsApiUrl() { - return process.env.CMS_API_URL; - }, - get cmsApiToken() { - return process.env.CMS_API_TOKEN; - }, - get altchaHmacSecret() { - return process.env.ALTCHA_HMAC_SECRET; - }, - get siteUrl() { - return process.env.NEXT_PUBLIC_SITE_URL; - }, - get cmsPublicUrl() { - return process.env.NEXT_PUBLIC_CMS_URL; - }, -})); - -// Import route module dynamically after polyfills (avoid hoisted import timing issues) -let GET: (request: Request) => Promise; -let confirmSubscription: (token: string) => Promise; - -beforeAll(async () => { - const mod = await import('@/app/api/newsletter/confirm/route'); - GET = mod.GET; - confirmSubscription = mod.confirmSubscription; -}); - -describe('Newsletter confirm route (unit)', () => { - beforeEach(() => { - jest.resetAllMocks(); - global.fetch = jest.fn() as jest.Mock; - process.env.CMS_API_URL = 'http://strapi.test'; - process.env.CMS_API_TOKEN = 'test-token-abc'; - }); - - afterEach(() => { - delete process.env.CMS_API_URL; - delete process.env.CMS_API_TOKEN; - }); - - describe('confirmSubscription helper', () => { - it('returns true when Strapi responds ok and calls fetch with correct headers', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockResolvedValueOnce({ ok: true }); - - const token = 'valid-token-123'; - const res = await confirmSubscription(token); - - expect(res).toBe(true); - expect(global.fetch).toHaveBeenCalledTimes(1); - expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe( - `${process.env.CMS_API_URL}/api/subscribers/confirm?token=${encodeURIComponent( - token, - )}`, - ); - - const opts = (global.fetch as jest.Mock).mock.calls[0][1]; - expect(opts.method).toBe('PUT'); - expect(opts.headers.Authorization).toBe( - `Bearer ${process.env.CMS_API_TOKEN}`, - ); - }); - - it('returns false when Strapi responds with non-ok', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockResolvedValueOnce({ ok: false, status: 400 }); - - const result = await confirmSubscription('bad-token'); - expect(result).toBe(false); - }); - - it('propagates network errors (rejects) so caller can handle server-error', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockRejectedValueOnce(new Error('network')); - - await expect(confirmSubscription('any')).rejects.toThrow('network'); - }); - }); - - describe('GET handler', () => { - it('redirects to missing-token when token param is absent', async () => { - const req = new Request('http://localhost/api/newsletter/confirm'); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')!).toContain( - '/newsletter/error?reason=missing-token', - ); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('redirects to invalid-token when token param is empty', async () => { - const req = new Request('http://localhost/api/newsletter/confirm?token='); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')!).toContain( - '/newsletter/error?reason=invalid-token', - ); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('redirects to success when confirmation succeeds', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockResolvedValueOnce({ ok: true }); - - const req = new Request( - 'http://localhost/api/newsletter/confirm?token=ok-token', - ); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')!).toContain('/newsletter/success'); - - // confirmSubscription should have called Strapi with token encoded - expect(global.fetch).toHaveBeenCalledWith( - `${process.env.CMS_API_URL}/api/subscribers/confirm?token=${encodeURIComponent('ok-token')}`, - expect.objectContaining({ - method: 'PUT', - headers: expect.objectContaining({ - Authorization: `Bearer ${process.env.CMS_API_TOKEN}`, - }), - }), - ); - }); - - it('redirects to invalid-token when Strapi responds non-ok', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockResolvedValueOnce({ ok: false, status: 400 }); - - const req = new Request( - 'http://localhost/api/newsletter/confirm?token=invalid-token', - ); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')!).toContain( - '/newsletter/error?reason=invalid-token', - ); - }); - - it('redirects to server-error when Strapi network error occurs', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockRejectedValueOnce(new Error('network')); - - const req = new Request( - 'http://localhost/api/newsletter/confirm?token=network-error', - ); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')!).toContain( - '/newsletter/error?reason=server-error', - ); - }); - }); -}); diff --git a/src/__tests__/app/api/newsletter/subscribe.test.ts b/src/__tests__/app/api/newsletter/subscribe.test.ts index b74eb2d1..f1c8eda6 100644 --- a/src/__tests__/app/api/newsletter/subscribe.test.ts +++ b/src/__tests__/app/api/newsletter/subscribe.test.ts @@ -14,34 +14,45 @@ import { newsletterSubscribeSchema } from '@/lib/newsletter-schema'; // --------------------------------------------------------------------------- jest.mock('@/constant/env', () => ({ - get cmsApiUrl() { - return process.env.CMS_API_URL; - }, - get cmsApiToken() { - return process.env.CMS_API_TOKEN; - }, get altchaHmacSecret() { return process.env.ALTCHA_HMAC_SECRET; }, - get siteUrl() { - return process.env.NEXT_PUBLIC_SITE_URL; + get listmonkBaseUrl() { + return process.env.LISTMONK_BASE_URL; + }, + get listmonkApiUser() { + return process.env.LISTMONK_API_USER; }, - get cmsPublicUrl() { - return process.env.NEXT_PUBLIC_CMS_URL; + get listmonkApiKey() { + return process.env.LISTMONK_API_KEY; + }, + get listmonkListId() { + return process.env.LISTMONK_LIST_ID; }, })); -// altcha-lib: verifySolution is controlled per-test -jest.mock('altcha-lib', () => ({ verifySolution: jest.fn() })); +// altcha-lib (v2): verifySolution is controlled per-test +jest.mock('altcha-lib', () => ({ + verifySolution: jest.fn(), +})); -// react-email: suppress actual rendering -jest.mock('@react-email/components', () => ({ - render: jest.fn().mockResolvedValue('
email
'), +jest.mock('altcha-lib/frameworks/shared', () => ({ + deriveHmacKeySecret: jest.fn(), })); -jest.mock('@/emails/confirm-subscription', () => ({ - __esModule: true, - default: jest.fn().mockReturnValue(null), +// listmonk client wrapper +jest.mock('@/lib/listmonk', () => ({ + createSubscriber: jest.fn(), + ListmonkError: class ListmonkError extends Error { + public readonly status: number; + public readonly responseBody: unknown; + constructor(message: string, status: number, responseBody: unknown) { + super(message); + this.name = 'ListmonkError'; + this.status = status; + this.responseBody = responseBody; + } + }, })); // POST handler — imported once after all mocks are set up @@ -52,17 +63,24 @@ beforeAll(async () => { POST = mod.POST; }); -// Valid ALTCHA payload: Base64-encoded JSON with all required fields -// Schema requires min(100) chars + valid Base64-JSON structure +// Valid ALTCHA payload: Base64-encoded JSON with {challenge, solution} +// Schema requires min(100) chars + valid Base64-JSON structure. const VALID_ALTCHA_PAYLOAD = btoa( JSON.stringify({ - algorithm: 'SHA-256', - challenge: 'test-challenge-string-abc', - number: 12345, - salt: 'test-salt-abc-123', - signature: 'test-signature-abc-xyz', + challenge: { + algorithm: 'PBKDF2/SHA-256', + challenge: 'c', + salt: 's', + signature: 'sig', + cost: 5000, + counter: 7000, + expiresAt: Math.floor(Date.now() / 1000) + 300, + }, + solution: { + counter: 7000, + }, }), -); +).padEnd(100, 'x'); describe('Newsletter Subscribe Validation', () => { describe('Input Schema Validation', () => { @@ -325,20 +343,25 @@ describe('Newsletter Subscribe Validation', () => { // --------------------------------------------------------------------------- describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { - beforeEach(() => { + beforeEach(async () => { jest.resetAllMocks(); - global.fetch = jest.fn() as jest.Mock; - process.env.CMS_API_URL = 'http://strapi.test'; - process.env.CMS_API_TOKEN = 'test-token-abc'; process.env.ALTCHA_HMAC_SECRET = 'test-secret-hex'; - process.env.NEXT_PUBLIC_SITE_URL = 'http://localhost:3000'; + process.env.LISTMONK_BASE_URL = 'http://listmonk.test'; + process.env.LISTMONK_API_USER = 'api'; + process.env.LISTMONK_API_KEY = 'key'; + process.env.LISTMONK_LIST_ID = '1'; + + const { deriveHmacKeySecret } = + await import('altcha-lib/frameworks/shared'); + (deriveHmacKeySecret as jest.Mock).mockResolvedValue('derived-secret'); }); afterEach(() => { - delete process.env.CMS_API_URL; - delete process.env.CMS_API_TOKEN; delete process.env.ALTCHA_HMAC_SECRET; - delete process.env.NEXT_PUBLIC_SITE_URL; + delete process.env.LISTMONK_BASE_URL; + delete process.env.LISTMONK_API_USER; + delete process.env.LISTMONK_API_KEY; + delete process.env.LISTMONK_LIST_ID; }); function makeRequest(body: object, ip = '1.2.3.4') { @@ -370,8 +393,8 @@ describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { expect(res.status).toBe(400); const json = await res.json(); expect(json.error).toMatch(/bot verification failed/i); - // Strapi must NOT be called when ALTCHA fails - expect(global.fetch).not.toHaveBeenCalled(); + const { createSubscriber } = await import('@/lib/listmonk'); + expect(createSubscriber).not.toHaveBeenCalled(); }); it('returns 400 "Bot verification failed" when ALTCHA_HMAC_SECRET is not configured', async () => { @@ -404,28 +427,22 @@ describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { // Schema rejects first (400 invalid input), which is fine — bot still blocked expect(res.status).toBe(400); - expect(global.fetch).not.toHaveBeenCalled(); + const { createSubscriber } = await import('@/lib/listmonk'); + expect(createSubscriber).not.toHaveBeenCalled(); }); - it('calls Strapi and returns 200 when verifySolution returns true', async () => { + it('calls listmonk and returns 200 when verifySolution returns true', async () => { const { verifySolution } = await import('altcha-lib'); - (verifySolution as jest.Mock).mockResolvedValueOnce(true); - - (global.fetch as jest.Mock) - // 1. createSubscriber - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - email: 'user@example.com', - token: 'tok-abc', - confirmed: false, - status: 'active', - }, - }), - }) - // 2. sendConfirmationEmail - .mockResolvedValueOnce({ ok: true }); + (verifySolution as jest.Mock).mockResolvedValueOnce({ verified: true }); + + const { createSubscriber } = await import('@/lib/listmonk'); + (createSubscriber as jest.Mock).mockResolvedValueOnce({ + id: 123, + uuid: 'sub-uuid', + email: 'user@example.com', + name: 'user@example.com', + status: 'enabled', + }); const res = await POST( makeRequest( @@ -441,8 +458,55 @@ describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { expect(res.status).toBe(200); const json = await res.json(); expect(json.message).toMatch(/confirm/i); - // Strapi called twice: create subscriber + send confirmation email - expect(global.fetch).toHaveBeenCalledTimes(2); + expect(createSubscriber).toHaveBeenCalledTimes(1); + }); + + it('returns 200 with generic message when listmonk reports duplicate (409)', async () => { + const { verifySolution } = await import('altcha-lib'); + (verifySolution as jest.Mock).mockResolvedValueOnce({ verified: true }); + + const { createSubscriber, ListmonkError } = await import('@/lib/listmonk'); + (createSubscriber as jest.Mock).mockRejectedValueOnce( + new ListmonkError('dup', 409, {}), + ); + + const res = await POST( + makeRequest( + { + email: 'dup@example.com', + altcha: VALID_ALTCHA_PAYLOAD, + privacy: true, + }, + '10.0.0.6', + ), + ); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(String(json.message)).toMatch(/thank you/i); + }); + + it('returns 500 when listmonk returns 400 from createSubscriber', async () => { + const { verifySolution } = await import('altcha-lib'); + (verifySolution as jest.Mock).mockResolvedValueOnce({ verified: true }); + + const { createSubscriber, ListmonkError } = await import('@/lib/listmonk'); + (createSubscriber as jest.Mock).mockRejectedValueOnce( + new ListmonkError('bad', 400, { reason: 'validation' }), + ); + + const res = await POST( + makeRequest( + { + email: 'bad@example.com', + altcha: VALID_ALTCHA_PAYLOAD, + privacy: true, + }, + '10.0.0.7', + ), + ); + + expect(res.status).toBe(500); }); it('returns 400 for missing privacy consent before ALTCHA check runs', async () => { @@ -462,6 +526,7 @@ describe('POST /api/newsletter/subscribe — ALTCHA bot protection', () => { expect(res.status).toBe(400); // verifySolution must never be called if privacy is not accepted expect(verifySolution).not.toHaveBeenCalled(); - expect(global.fetch).not.toHaveBeenCalled(); + const { createSubscriber } = await import('@/lib/listmonk'); + expect(createSubscriber).not.toHaveBeenCalled(); }); }); diff --git a/src/__tests__/app/api/newsletter/unsubscribe.test.ts b/src/__tests__/app/api/newsletter/unsubscribe.test.ts deleted file mode 100644 index 4cc28510..00000000 --- a/src/__tests__/app/api/newsletter/unsubscribe.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** - * @jest-environment node - */ - -// Mock render used by sendGoodbyeEmail so tests don't depend on react-email rendering -jest.mock('@react-email/components', () => ({ - render: jest.fn().mockResolvedValue('
email
'), -})); - -// Mock @/constant/env with getters so values are read at call time (after beforeEach sets process.env) -jest.mock('@/constant/env', () => ({ - get cmsApiUrl() { - return process.env.CMS_API_URL; - }, - get cmsApiToken() { - return process.env.CMS_API_TOKEN; - }, - get altchaHmacSecret() { - return process.env.ALTCHA_HMAC_SECRET; - }, - get siteUrl() { - return process.env.NEXT_PUBLIC_SITE_URL; - }, - get cmsPublicUrl() { - return process.env.NEXT_PUBLIC_CMS_URL; - }, -})); - -// Import route module dynamically after polyfills to avoid hoisting issues -let GET: (request: Request) => Promise; -let unsubscribeUser: ( - token: string, -) => Promise<{ success: boolean; email?: string }>; -let sendGoodbyeEmail: (email: string) => Promise; - -beforeAll(async () => { - const mod = await import('@/app/api/newsletter/unsubscribe/route'); - GET = mod.GET; - unsubscribeUser = mod.unsubscribeUser; - sendGoodbyeEmail = mod.sendGoodbyeEmail; -}); - -describe('Newsletter unsubscribe (unit)', () => { - beforeEach(() => { - // clearAllMocks preserves mock implementations (e.g. render's mockResolvedValue) - // while still resetting call counts — resetAllMocks would wipe the render stub. - jest.clearAllMocks(); - global.fetch = jest.fn() as jest.Mock; - process.env.CMS_API_URL = 'http://strapi.test'; - process.env.CMS_API_TOKEN = 'test-token-abc'; - }); - - afterEach(() => { - delete process.env.CMS_API_URL; - delete process.env.CMS_API_TOKEN; - }); - - describe('unsubscribeUser', () => { - it('returns success + email when Strapi responds ok with email', async () => { - (global.fetch as jest.Mock) = jest.fn().mockResolvedValueOnce({ - ok: true, - json: async () => ({ email: 'user@example.com' }), - }); - - const token = 't/oken+value'; - const result = await unsubscribeUser(token); - - expect(result).toEqual({ success: true, email: 'user@example.com' }); - expect(global.fetch).toHaveBeenCalledWith( - `${process.env.CMS_API_URL}/api/subscribers/unsubscribe?token=${encodeURIComponent( - token, - )}`, - expect.objectContaining({ - method: 'PUT', - headers: expect.objectContaining({ - Authorization: `Bearer ${process.env.CMS_API_TOKEN}`, - }), - }), - ); - }); - - it('returns success true with undefined email when response has no email', async () => { - (global.fetch as jest.Mock) = jest.fn().mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }); - - const res = await unsubscribeUser('token'); - expect(res).toEqual({ success: true, email: undefined }); - }); - - it('returns success:false when Strapi responds non-ok', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockResolvedValueOnce({ ok: false, status: 400 }); - - const res = await unsubscribeUser('bad'); - expect(res).toEqual({ success: false }); - }); - - it('returns success:false on network error', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockRejectedValueOnce(new Error('network')); - - const res = await unsubscribeUser('any'); - expect(res).toEqual({ success: false }); - }); - }); - - describe('sendGoodbyeEmail', () => { - it('posts email to Strapi /api/email with correct body and headers', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockResolvedValueOnce({ ok: true }); - - await sendGoodbyeEmail('bye@example.com'); - - expect(global.fetch).toHaveBeenCalledWith( - `${process.env.CMS_API_URL}/api/email`, - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - Authorization: `Bearer ${process.env.CMS_API_TOKEN}`, - }), - body: expect.any(String), - }), - ); - - const body = JSON.parse( - (global.fetch as jest.Mock).mock.calls[0][1].body, - ); - expect(body.to).toBe('bye@example.com'); - expect(body.subject).toMatch(/unsubscription confirmed/i); - // html may be present or omitted depending on renderer during tests — accept both - expect(body.html === undefined || typeof body.html === 'string').toBe( - true, - ); - }); - - it('does not throw when email send fails', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockRejectedValueOnce(new Error('fail')); - await expect(sendGoodbyeEmail('a@b.c')).resolves.toBeUndefined(); - }); - }); - - describe('GET handler', () => { - it('redirects to missing-token when token is absent', async () => { - const req = new Request('http://localhost/api/newsletter/unsubscribe'); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')).toContain( - '/newsletter/error?reason=missing-token', - ); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('redirects to unsubscribed and sends goodbye email when Strapi returns email', async () => { - // first call: unsubscribe -> returns email - // second call: sendGoodbyeEmail -> posts email - (global.fetch as jest.Mock) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ email: 'x@y.z' }), - }) - .mockResolvedValueOnce({ ok: true }); - - const req = new Request( - 'http://localhost/api/newsletter/unsubscribe?token=ok', - ); - const res = await GET(req); - - // sendGoodbyeEmail is fire-and-forget in the route — flush microtasks so - // its internal awaits (render + fetch) complete before we assert on them. - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(res.status).toBe(307); - expect(res.headers.get('location')).toContain('/newsletter/unsubscribed'); - expect(global.fetch).toHaveBeenCalledTimes(2); - - // verify unsubscribe call - expect((global.fetch as jest.Mock).mock.calls[0][0]).toContain( - '/api/subscribers/unsubscribe', - ); - - // verify email call - const emailCall = (global.fetch as jest.Mock).mock.calls[1]; - expect(emailCall[0]).toBe(`${process.env.CMS_API_URL}/api/email`); - const body = JSON.parse(emailCall[1].body); - expect(body.to).toBe('x@y.z'); - }); - - it('redirects to unsubscribed and does not send email when Strapi returns no email', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockResolvedValueOnce({ ok: true, json: async () => ({}) }); - - const req = new Request( - 'http://localhost/api/newsletter/unsubscribe?token=no-email', - ); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')).toContain('/newsletter/unsubscribed'); - expect(global.fetch).toHaveBeenCalledTimes(1); - }); - - it('redirects to invalid-token when Strapi unsubscribe fails', async () => { - (global.fetch as jest.Mock) = jest - .fn() - .mockResolvedValueOnce({ ok: false, status: 400 }); - - const req = new Request( - 'http://localhost/api/newsletter/unsubscribe?token=bad', - ); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')).toContain( - '/newsletter/error?reason=invalid-token', - ); - }); - - it('redirects to server-error when NextResponse.redirect inside try throws', async () => { - // Cause the first NextResponse.redirect call (inside try) to throw, then ensure - // the outer catch returns the server-error redirect. - const nextServer = await import('next/server'); - const originalRedirect = nextServer.NextResponse.redirect; - let calls = 0; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (nextServer.NextResponse as any).redirect = (url: any, init?: any) => { - calls += 1; - if (calls === 1) throw new Error('boom'); - return originalRedirect(url, init); - }; - - try { - // Make unsubscribe succeed so GET attempts a redirect inside try - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }); - - const req = new Request( - 'http://localhost/api/newsletter/unsubscribe?token=ok', - ); - const res = await GET(req); - - expect(res.status).toBe(307); - expect(res.headers.get('location')).toContain( - '/newsletter/error?reason=server-error', - ); - } finally { - // Always restore — even if assertions above throw - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (nextServer.NextResponse as any).redirect = originalRedirect; - } - }); - }); -}); diff --git a/src/__tests__/lib/listmonk.test.ts b/src/__tests__/lib/listmonk.test.ts new file mode 100644 index 00000000..dcc8ff71 --- /dev/null +++ b/src/__tests__/lib/listmonk.test.ts @@ -0,0 +1,167 @@ +/** + * @jest-environment node + */ + +import { + createSubscriber, + findSubscriberByUuid, + ListmonkError, + listmonkRequest, + sendOptInEmail, + unsubscribeSubscriberFromLists, +} from '@/lib/listmonk'; + +describe('listmonk client', () => { + beforeEach(() => { + jest.resetAllMocks(); + global.fetch = jest.fn() as jest.Mock; + process.env.LISTMONK_BASE_URL = 'https://newsletter.project-sentiment.org'; + process.env.LISTMONK_API_USER = 'auth'; + process.env.LISTMONK_API_KEY = 'test-key'; + }); + + afterEach(() => { + delete process.env.LISTMONK_BASE_URL; + delete process.env.LISTMONK_API_USER; + delete process.env.LISTMONK_API_KEY; + }); + + it('adds Basic auth header and parses {data}', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ data: { ok: true } }), + }); + + const res = await listmonkRequest<{ ok: boolean }>('/api/health', { + method: 'GET', + }); + + expect(res).toEqual({ ok: true }); + const [, init] = (global.fetch as jest.Mock).mock.calls[0]; + expect(init.headers.Authorization).toMatch(/^Basic\s+/); + expect(init.headers.Accept).toBe('application/json'); + }); + + it('throws ListmonkError with body on non-ok', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ error: 'boom' }), + text: async () => 'boom', + }); + + try { + await listmonkRequest('/api/health'); + throw new Error('Expected listmonkRequest to throw'); + } catch (e) { + expect(e).toBeInstanceOf(ListmonkError); + const err = e as ListmonkError; + expect(err.status).toBe(500); + expect(err.responseBody).toEqual({ error: 'boom' }); + } + }); + + it('createSubscriber posts expected body', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ + data: { + id: 1, + uuid: 'u', + email: 'a@b.c', + name: 'a@b.c', + status: 'enabled', + }, + }), + }); + + await createSubscriber({ email: 'a@b.c', listIds: [1] }); + + const [url, init] = (global.fetch as jest.Mock).mock.calls[0]; + expect(url).toBe( + 'https://newsletter.project-sentiment.org/api/subscribers', + ); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body); + expect(body.email).toBe('a@b.c'); + expect(body.lists).toEqual([1]); + }); + + it('sendOptInEmail calls optin endpoint', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ data: true }), + }); + + await expect(sendOptInEmail(42)).resolves.toBe(true); + const [url] = (global.fetch as jest.Mock).mock.calls[0]; + expect(url).toContain('/api/subscribers/42/optin'); + }); + + it('findSubscriberByUuid returns first match', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ + data: { + results: [ + { + id: 7, + uuid: '11111111-1111-4111-8111-111111111111', + email: 'x@y.z', + name: 'x', + status: 'enabled', + }, + ], + total: 1, + }, + }), + }); + + const sub = await findSubscriberByUuid( + '11111111-1111-4111-8111-111111111111', + ); + expect(sub?.id).toBe(7); + }); + + it('findSubscriberByUuid returns null for invalid uuid', async () => { + const sub = await findSubscriberByUuid("x'); DROP--"); + expect(sub).toBeNull(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('unsubscribeSubscriberFromLists calls /api/subscribers/lists', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ data: true }), + }); + + await unsubscribeSubscriberFromLists({ + subscriberIds: [1], + targetListIds: [2], + }); + + const [url, init] = (global.fetch as jest.Mock).mock.calls[0]; + expect(url).toContain('/api/subscribers/lists'); + const body = JSON.parse(init.body); + expect(body.action).toBe('unsubscribe'); + expect(body.ids).toEqual([1]); + expect(body.target_list_ids).toEqual([2]); + }); +}); diff --git a/src/app/api/newsletter/challenge/route.ts b/src/app/api/newsletter/challenge/route.ts index c1978ef6..72f8f38f 100644 --- a/src/app/api/newsletter/challenge/route.ts +++ b/src/app/api/newsletter/challenge/route.ts @@ -1,25 +1,33 @@ -import { createChallenge } from 'altcha-lib'; +import { createChallenge, randomInt } from 'altcha-lib'; +import { deriveKey } from 'altcha-lib/algorithms/pbkdf2'; +import { deriveHmacKeySecret } from 'altcha-lib/frameworks/shared'; import { NextResponse } from 'next/server'; import { altchaHmacSecret } from '@/constant/env'; export async function GET() { try { - const hmacKey = altchaHmacSecret; + const hmacSignatureSecret = altchaHmacSecret; - if (!hmacKey) { + if (!hmacSignatureSecret) { return NextResponse.json( { error: 'Server configuration error' }, { status: 500 }, ); } - // Generate ALTCHA challenge with proper HMAC signature + const hmacKeySignatureSecret = + await deriveHmacKeySecret(hmacSignatureSecret); + + // Generate ALTCHA v2 (PoW v2) challenge for Widget v3. const challenge = await createChallenge({ - hmacKey, - maxNumber: 100000, // Maximum number for proof-of-work - algorithm: 'SHA-256', - expires: new Date(Date.now() + 5 * 60 * 1000), // Challenge expires in 5 minutes + algorithm: 'PBKDF2/SHA-256', + cost: 5_000, + counter: randomInt(5_000, 10_000), + deriveKey, + hmacSignatureSecret, + hmacKeySignatureSecret, + expiresAt: new Date(Date.now() + 5 * 60 * 1000), }); return NextResponse.json(challenge); diff --git a/src/app/api/newsletter/confirm/route.ts b/src/app/api/newsletter/confirm/route.ts deleted file mode 100644 index 92a6da40..00000000 --- a/src/app/api/newsletter/confirm/route.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { NextResponse } from 'next/server'; - -import { cmsApiToken, cmsApiUrl } from '@/constant/env'; - -/** - * Confirm subscription endpoint helper - * Performs the PUT against Strapi to confirm a subscriber token. - */ -export async function confirmSubscription(token: string): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - try { - // Strapi API call to confirm subscriber (token is URL-encoded) - const response = await fetch( - `${cmsApiUrl}/api/subscribers/confirm?token=${encodeURIComponent(token)}`, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${cmsApiToken}`, - }, - signal: controller.signal, - }, - ); - - // propagate network errors and return boolean for API result - return response.ok; - } finally { - clearTimeout(timeoutId); - } -} - -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - - // Distinguish between missing token parameter and empty token value - if (!searchParams.has('token')) { - return NextResponse.redirect( - new URL('/newsletter/error?reason=missing-token', request.url), - ); - } - - const token = searchParams.get('token') ?? ''; - - if (!token) { - return NextResponse.redirect( - new URL('/newsletter/error?reason=invalid-token', request.url), - ); - } - - // Confirm the subscription - const success = await confirmSubscription(token); - - if (success) { - // Redirect to success page - return NextResponse.redirect(new URL('/newsletter/success', request.url)); - } else { - // Redirect to error page (token invalid, already used, or expired) - return NextResponse.redirect( - new URL('/newsletter/error?reason=invalid-token', request.url), - ); - } - } catch { - return NextResponse.redirect( - new URL('/newsletter/error?reason=server-error', request.url), - ); - } -} diff --git a/src/app/api/newsletter/subscribe/route.ts b/src/app/api/newsletter/subscribe/route.ts index 73865669..7cd67bac 100644 --- a/src/app/api/newsletter/subscribe/route.ts +++ b/src/app/api/newsletter/subscribe/route.ts @@ -1,15 +1,12 @@ -import { render } from '@react-email/components'; +import { verifySolution } from 'altcha-lib'; +import { deriveKey } from 'altcha-lib/algorithms/pbkdf2'; +import { deriveHmacKeySecret } from 'altcha-lib/frameworks/shared'; import { NextResponse } from 'next/server'; +import { createSubscriber, ListmonkError } from '@/lib/listmonk'; import { newsletterSubscribeSchema } from '@/lib/newsletter-schema'; -import { - altchaHmacSecret, - cmsApiToken, - cmsApiUrl, - siteUrl, -} from '@/constant/env'; -import ConfirmSubscriptionEmail from '@/emails/confirm-subscription'; +import { altchaHmacSecret, listmonkListId } from '@/constant/env'; // In-Memory Rate-Limiting (pro IP, 3 Versuche pro Stunde) const rateLimitMap = new Map(); @@ -48,9 +45,9 @@ function checkRateLimit(ip: string): boolean { async function verifyAltcha(payload: string): Promise { try { // Validate ALTCHA_HMAC_SECRET environment variable - const hmacKey = altchaHmacSecret; + const hmacSignatureSecret = altchaHmacSecret; - if (!hmacKey) { + if (!hmacSignatureSecret) { return false; } @@ -67,160 +64,36 @@ async function verifyAltcha(payload: string): Promise { return false; } - // Import verifySolution from altcha-lib for server-side verification - const { verifySolution } = await import('altcha-lib'); - - // Verify the ALTCHA solution with signature and expiration check - const isValid = await verifySolution(payload, hmacKey, true); - - return isValid; - } catch { - return false; - } -} - -/** - * Create subscriber in Strapi database - * GDPR: Data minimization - only stores email and confirmation token - */ -interface Subscriber { - email: string; - token: string; - confirmed: boolean; - status: 'active' | 'unsubscribed'; -} - -async function createSubscriber(email: string): Promise { - const token = crypto.randomUUID(); - - // Create AbortController with 10s timeout - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - try { - // Call Strapi API to create subscriber - const response = await fetch(`${cmsApiUrl}/api/subscribers`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${cmsApiToken}`, - }, - signal: controller.signal, - body: JSON.stringify({ - data: { - email, - token, - confirmed: false, // Requires confirmation (double opt-in) - status: 'active', - }, - }), - }); - - if (!response.ok) { - // Return null only for known duplicate/validation responses - if (response.status === 400 || response.status === 409) { - return null; - } - // All other errors (5xx, auth failures, etc.) bubble up to the POST handler - const errorText = await response.text().catch(() => ''); - throw new Error( - `Strapi responded with ${response.status} ${response.statusText}${errorText ? `: ${errorText}` : ''}`, - ); - } - - const data = await response.json(); - - // Strapi v5 returns data directly (not in attributes) - // null here means a Strapi-internal issue, not a duplicate — treat as error - if (!data.data) { - throw new Error('Strapi returned empty data after creating subscriber'); - } - - // Use the token we generated, as Strapi might not return it (if marked private) - return { - email: data.data.email, - token: data.data.token || token, // Fallback to generated token - confirmed: data.data.confirmed, - status: data.data.status, + const parsed = JSON.parse(atob(payload)) as { + challenge?: unknown; + solution?: unknown; }; - } finally { - clearTimeout(timeoutId); - } -} - -/** - * Delete a subscriber by token — called when confirmation email fails - * GDPR: Prevents orphaned unconfirmed records that block future retries - */ -async function deleteSubscriberByToken(token: string): Promise { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); - try { - await fetch( - `${cmsApiUrl}/api/subscribers/by-token?token=${encodeURIComponent(token)}`, - { - method: 'DELETE', - headers: { - Authorization: `Bearer ${cmsApiToken}`, - }, - signal: controller.signal, - }, - ); - } finally { - clearTimeout(timeoutId); + if (!parsed.challenge || !parsed.solution) { + return false; } - } catch { - // Best-effort cleanup — do not throw, unsubscribe still matters - // eslint-disable-next-line no-console - console.error('Failed to clean up subscriber after email delivery failure'); - } -} -/** - * Send double opt-in confirmation email - * GDPR Compliant: User must explicitly confirm subscription - */ -async function sendConfirmationEmail( - email: string, - token: string, -): Promise { - try { - // Create AbortController with 10s timeout - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); + const hmacKeySignatureSecret = + await deriveHmacKeySecret(hmacSignatureSecret); - try { - // Generate confirmation URL with token - const confirmUrl = `${siteUrl}/api/newsletter/confirm?token=${encodeURIComponent(token)}`; - - // Render email template - const emailHtml = await render(ConfirmSubscriptionEmail({ confirmUrl })); - - // Send email via Strapi email plugin - const response = await fetch(`${cmsApiUrl}/api/email`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${cmsApiToken}`, - }, - signal: controller.signal, - body: JSON.stringify({ - to: email, - subject: 'Confirm your newsletter subscription', - html: emailHtml, - }), - }); + const result = await verifySolution({ + challenge: parsed.challenge as never, + solution: parsed.solution as never, + deriveKey, + hmacSignatureSecret, + hmacKeySignatureSecret, + }); - return response.ok; - } finally { - clearTimeout(timeoutId); - } + return result.verified === true; } catch { return false; } } +function parseRequiredListId(raw: string | undefined): number | null { + const n = raw ? Number(raw) : NaN; + return Number.isInteger(n) && n > 0 ? n : null; +} + /** * Newsletter subscription endpoint * Implements double opt-in and GDPR compliance: @@ -245,11 +118,10 @@ export async function POST(request: Request) { ); } - // Guard: siteUrl is required to build confirmation email links. - // If missing, emails would contain "undefined/api/..." links. - if (!siteUrl) { + const listId = parseRequiredListId(listmonkListId); + if (!listId) { return NextResponse.json( - { error: 'Missing site configuration' }, + { error: 'Missing newsletter configuration' }, { status: 500 }, ); } @@ -285,29 +157,32 @@ export async function POST(request: Request) { ); } - // Create subscriber (unconfirmed) - const subscriber = await createSubscriber(email); - - // Silent failure if email already exists - // GDPR: Prevents user enumeration attack - if (!subscriber) { - return NextResponse.json({ - message: - 'Thank you! If the email address is not yet registered, you will receive a confirmation email.', - }); - } + try { + // Do not explicitly trigger opt-in email here to avoid duplicates. + // listmonk sends the confirmation email automatically for double opt-in lists. + await createSubscriber({ email, listIds: [listId] }); + } catch (err) { + // Only duplicate / conflict: silent success to avoid user enumeration. + if (err instanceof ListmonkError && err.status === 409) { + return NextResponse.json({ + message: + 'Thank you! If the email address is not yet registered, you will receive a confirmation email.', + }); + } - // Send double opt-in confirmation email with error handling - const emailSent = await sendConfirmationEmail(email, subscriber.token); + if (err instanceof ListmonkError && err.status === 400) { + // eslint-disable-next-line no-console + console.error('Newsletter subscribe: listmonk validation error', err); + return NextResponse.json( + { error: 'An internal error occurred' }, + { status: 500 }, + ); + } - // If email failed, delete the subscriber so the user can retry later - // GDPR: Prevents orphaned unconfirmed records that can never be confirmed - if (!emailSent) { - await deleteSubscriberByToken(subscriber.token); + // eslint-disable-next-line no-console + console.error('Newsletter subscribe failed', err); return NextResponse.json( - { - error: 'Email could not be sent. Please try again later.', - }, + { error: 'An internal error occurred' }, { status: 500 }, ); } diff --git a/src/app/api/newsletter/unsubscribe/route.ts b/src/app/api/newsletter/unsubscribe/route.ts deleted file mode 100644 index 9ba7d646..00000000 --- a/src/app/api/newsletter/unsubscribe/route.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { render } from '@react-email/components'; -import { NextResponse } from 'next/server'; - -import { cmsApiToken, cmsApiUrl } from '@/constant/env'; -import GoodbyeEmail from '@/emails/goodbye'; - -/** - * Unsubscribe user endpoint - * GDPR Compliant: Allows users to easily opt-out at any time - */ -export async function unsubscribeUser( - token: string, -): Promise<{ success: boolean; email?: string }> { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - try { - // URL-encode token to safely handle special characters - const encodedToken = encodeURIComponent(token); - const response = await fetch( - `${cmsApiUrl}/api/subscribers/unsubscribe?token=${encodedToken}`, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${cmsApiToken}`, - }, - signal: controller.signal, - }, - ); - - if (!response.ok) { - return { success: false }; - } - - const data = await response.json(); - return { success: true, email: data.email }; - } catch { - return { success: false }; - } finally { - clearTimeout(timeoutId); - } -} - -/** - * Send goodbye confirmation email - * GDPR Compliant: Confirms unsubscription action - */ -export async function sendGoodbyeEmail(email: string): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - try { - const emailHtml = await render(GoodbyeEmail()); - - await fetch(`${cmsApiUrl}/api/email`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${cmsApiToken}`, - }, - signal: controller.signal, - body: JSON.stringify({ - to: email, - subject: 'Newsletter unsubscription confirmed', - html: emailHtml, - }), - }); - } catch { - // Don't throw - unsubscribe still succeeded - } finally { - clearTimeout(timeoutId); - } -} - -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - const token = searchParams.get('token'); - - // Validate token parameter - if (!token) { - return NextResponse.redirect( - new URL('/newsletter/error?reason=missing-token', request.url), - ); - } - - // Unsubscribe the user - const result = await unsubscribeUser(token); - - if (result.success) { - // Send confirmation email (fire-and-forget with error handling) - if (result.email) { - sendGoodbyeEmail(result.email).catch(() => { - // Error handling for fire-and-forget promise - user still redirected successfully - }); - } - - // Redirect to unsubscribed confirmation page - return NextResponse.redirect( - new URL('/newsletter/unsubscribed', request.url), - ); - } else { - // Redirect to error page - return NextResponse.redirect( - new URL('/newsletter/error?reason=invalid-token', request.url), - ); - } - } catch { - return NextResponse.redirect( - new URL('/newsletter/error?reason=server-error', request.url), - ); - } -} diff --git a/src/app/newsletter/success/page.tsx b/src/app/newsletter/success/page.tsx deleted file mode 100644 index 0ca95b5a..00000000 --- a/src/app/newsletter/success/page.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Container } from '@/components/layout/Container'; -import { Button } from '@/components/ui/Button'; -import { Paragraph, Title } from '@/components/ui/typography'; - -export default function NewsletterSuccessPage() { - return ( -
- -
- - Subscription <span className='text-primary'>confirmed</span> - - - Thank you! Your newsletter subscription has been successfully - confirmed. You will now receive our latest updates. - - -
-
-
- ); -} diff --git a/src/app/newsletter/unsubscribed/page.tsx b/src/app/newsletter/unsubscribed/page.tsx deleted file mode 100644 index a016cd07..00000000 --- a/src/app/newsletter/unsubscribed/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Container } from '@/components/layout/Container'; -import { Button } from '@/components/ui/Button'; -import { Paragraph, Title } from '@/components/ui/typography'; - -export default function NewsletterUnsubscribedPage() { - return ( -
- -
- - Unsubscription <span className='text-primary'>confirmed</span> - - - You have been successfully unsubscribed from the newsletter. We are - sorry to see you go! If you change your mind, you can subscribe - again at any time. - - -
-
-
- ); -} diff --git a/src/components/helpers/AltchaScript.tsx b/src/components/helpers/AltchaScript.tsx index dbcd4d11..20b5923a 100644 --- a/src/components/helpers/AltchaScript.tsx +++ b/src/components/helpers/AltchaScript.tsx @@ -1,5 +1,6 @@ 'use client'; +import type {} from 'altcha/types/react'; import { useEffect } from 'react'; export function AltchaScript() { diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index d2cbc0a9..de541671 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -61,7 +61,7 @@ export default function Header() { return (
-
+
, string> +>; + +const altchaStyleLight: AltchaWidgetCssVars = { '--altcha-border-width': '1px', '--altcha-border-radius': '8px', '--altcha-color-base': '#ffffff', @@ -32,7 +38,7 @@ const altchaStyleLight: AltchaWidgetCSSProperties = { '--altcha-max-width': '100%', }; -const altchaStyleDark: AltchaWidgetCSSProperties = { +const altchaStyleDark: AltchaWidgetCssVars = { '--altcha-border-width': '1px', '--altcha-border-radius': '8px', '--altcha-color-base': '#171717', @@ -191,12 +197,8 @@ export function NewsletterForm() {
diff --git a/src/constant/env.ts b/src/constant/env.ts index 7ae401fe..635eff92 100644 --- a/src/constant/env.ts +++ b/src/constant/env.ts @@ -3,6 +3,12 @@ export const cmsApiUrl = process.env.CMS_API_URL; export const cmsApiToken = process.env.CMS_API_TOKEN; export const altchaHmacSecret = process.env.ALTCHA_HMAC_SECRET; +// Server-side only — listmonk newsletter integration +export const listmonkBaseUrl = process.env.LISTMONK_BASE_URL; +export const listmonkApiUser = process.env.LISTMONK_API_USER; +export const listmonkApiKey = process.env.LISTMONK_API_KEY; +export const listmonkListId = process.env.LISTMONK_LIST_ID; + // Public — accessible in both server and client export const siteUrl = process.env.NEXT_PUBLIC_SITE_URL; export const cmsPublicUrl = process.env.NEXT_PUBLIC_CMS_URL; diff --git a/src/emails/confirm-subscription.tsx b/src/emails/confirm-subscription.tsx deleted file mode 100644 index c0fd6bb7..00000000 --- a/src/emails/confirm-subscription.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { - Body, - Button, - Container, - Head, - Heading, - Hr, - Html, - Preview, - Section, - Text, -} from '@react-email/components'; - -interface ConfirmSubscriptionEmailProps { - confirmUrl: string; -} - -export default function ConfirmSubscriptionEmail({ - confirmUrl, -}: ConfirmSubscriptionEmailProps) { - return ( - - - Confirm your newsletter subscription - SENTIMENT - - - {/* Logo/Branding */} -
- - - - -
- - - - - - - - - - - - - - - - -
- SENTIMENT - - Creating Safe & Supportive Spaces for Intimate Communication with - Human-Chatbot Interactions - -
- -
- - {/* Main Content */} - Confirm newsletter subscription - - Thank you for your interest in our newsletter! - - - To complete your subscription and receive regular updates about the - SENTIMENT project, please click the button below: - - - {/* Call-to-Action Button */} -
- -
- - {/* Alternative Link */} - Or copy this link into your browser: - {confirmUrl} - -
- - {/* Footer */} - - If you did not sign up for our newsletter, you can ignore this - email. No further emails will be sent. - - - This email was sent as part of the SENTIMENT research project. - - - SENTIMENT is a research project under the funding guideline - "Platform Privacy - IT security protects privacy and supports - democracy" of the Federal Government's IT Security Research Program - "Digital. Secure. Sovereign". - -
- - - ); -} - -const main = { - backgroundColor: '#ffffff', - fontFamily: - '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', -}; - -const container = { - backgroundColor: '#ffffff', - margin: '0 auto', - padding: '20px 0 48px', - marginBottom: '64px', - maxWidth: '600px', -}; - -const header = { - textAlign: 'center' as const, - marginBottom: '32px', -}; - -const logo = { - color: '#1F35A5', // Secondary brand color - fontSize: '32px', - fontWeight: '900', - margin: '20px 0 8px', - padding: '0', - letterSpacing: '-0.5px', -}; - -const tagline = { - color: '#8b9094', // Tertiary color - fontSize: '12px', - lineHeight: '18px', - margin: '0 0 20px', - padding: '0 20px', -}; - -const divider = { - borderColor: '#f2f2f2', - margin: '24px 0', -}; - -const h1 = { - color: '#000000', - fontSize: '24px', - fontWeight: '700', - margin: '32px 0 24px', - padding: '0', - lineHeight: '32px', -}; - -const text = { - color: '#000000', - fontSize: '16px', - lineHeight: '26px', - marginBottom: '16px', -}; - -const buttonContainer = { - padding: '32px 0', - textAlign: 'center' as const, -}; - -const button = { - backgroundColor: '#FF5C24', // Primary brand color (Orange) - borderRadius: '8px', - color: '#ffffff', - fontSize: '16px', - fontWeight: '600', - textDecoration: 'none', - textAlign: 'center' as const, - display: 'inline-block', - padding: '14px 32px', - lineHeight: '24px', -}; - -const link = { - color: '#1F35A5', // Secondary brand color - fontSize: '14px', - textDecoration: 'underline', - wordBreak: 'break-all' as const, - marginTop: '8px', -}; - -const footer = { - color: '#666666', - fontSize: '14px', - lineHeight: '22px', - marginTop: '16px', -}; - -const disclaimer = { - color: '#8b9094', - fontSize: '11px', - lineHeight: '18px', - marginTop: '24px', - paddingTop: '16px', - borderTop: '1px solid #f2f2f2', -}; diff --git a/src/emails/goodbye.tsx b/src/emails/goodbye.tsx deleted file mode 100644 index 67ebc7b3..00000000 --- a/src/emails/goodbye.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { - Body, - Container, - Head, - Heading, - Hr, - Html, - Preview, - Section, - Text, -} from '@react-email/components'; - -export default function GoodbyeEmail() { - return ( - - - - You have been unsubscribed from the SENTIMENT newsletter - - - - {/* Logo/Branding */} -
- - - - -
- - - - - - - - - - - - - - - - -
- SENTIMENT - - Creating Safe & Supportive Spaces for Intimate Communication with - Human-Chatbot Interactions - -
- -
- - {/* Main Content */} - Unsubscription confirmed - - You have been successfully unsubscribed from the SENTIMENT - newsletter. - - - We are sorry to see you go! If you change your mind, you can - subscribe again anytime on our website. - - -
- - {/* Footer */} - - If you did not request this unsubscription, please contact us - immediately. - - - Thank you for your interest in the SENTIMENT project. - - - SENTIMENT is a research project under the funding guideline - "Platform Privacy - IT security protects privacy and supports - democracy" of the Federal Government's IT Security Research Program - "Digital. Secure. Sovereign". - -
- - - ); -} - -const main = { - backgroundColor: '#ffffff', - fontFamily: - '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', -}; - -const container = { - backgroundColor: '#ffffff', - margin: '0 auto', - padding: '20px 0 48px', - marginBottom: '64px', - maxWidth: '600px', -}; - -const header = { - textAlign: 'center' as const, - marginBottom: '32px', -}; - -const logo = { - color: '#1F35A5', // Secondary brand color - fontSize: '32px', - fontWeight: '900', - margin: '20px 0 8px', - padding: '0', - letterSpacing: '-0.5px', -}; - -const tagline = { - color: '#8b9094', // Tertiary color - fontSize: '12px', - lineHeight: '18px', - margin: '0 0 20px', - padding: '0 20px', -}; - -const divider = { - borderColor: '#f2f2f2', - margin: '24px 0', -}; - -const h1 = { - color: '#000000', - fontSize: '24px', - fontWeight: '700', - margin: '32px 0 24px', - padding: '0', - lineHeight: '32px', -}; - -const text = { - color: '#000000', - fontSize: '16px', - lineHeight: '26px', - marginBottom: '16px', -}; - -const footer = { - color: '#666666', - fontSize: '14px', - lineHeight: '22px', - marginTop: '16px', -}; - -const disclaimer = { - color: '#8b9094', - fontSize: '11px', - lineHeight: '18px', - marginTop: '24px', - paddingTop: '16px', - borderTop: '1px solid #f2f2f2', -}; diff --git a/src/lib/listmonk.ts b/src/lib/listmonk.ts new file mode 100644 index 00000000..280e606a --- /dev/null +++ b/src/lib/listmonk.ts @@ -0,0 +1,168 @@ +type ListmonkJson = { data: T }; + +export class ListmonkError extends Error { + public readonly status: number; + public readonly responseBody: unknown; + + constructor(message: string, status: number, responseBody: unknown) { + super(message); + this.name = 'ListmonkError'; + this.status = status; + this.responseBody = responseBody; + } +} + +function getRequiredEnv(name: string, value: string | undefined): string { + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function getListmonkConfig() { + // Read directly from process.env to avoid import-time capture of env values. + // This makes runtime configuration and tests (which set env per test) reliable. + const baseUrl = getRequiredEnv( + 'LISTMONK_BASE_URL', + process.env.LISTMONK_BASE_URL, + ).replace(/\/+$/, ''); + const apiUser = getRequiredEnv( + 'LISTMONK_API_USER', + process.env.LISTMONK_API_USER, + ); + const apiKey = getRequiredEnv( + 'LISTMONK_API_KEY', + process.env.LISTMONK_API_KEY, + ); + + return { baseUrl, apiUser, apiKey }; +} + +function getAuthHeader(): string { + const { apiUser, apiKey } = getListmonkConfig(); + const token = Buffer.from(`${apiUser}:${apiKey}`, 'utf8').toString('base64'); + return `Basic ${token}`; +} + +async function parseJsonBestEffort(res: Response): Promise { + const ct = res.headers.get('content-type') ?? ''; + if (ct.includes('application/json')) { + return res.json().catch(() => undefined); + } + return res.text().catch(() => undefined); +} + +export async function listmonkRequest( + path: string, + init: RequestInit & { timeoutMs?: number } = {}, +): Promise { + const { baseUrl } = getListmonkConfig(); + const url = `${baseUrl}${path.startsWith('/') ? '' : '/'}${path}`; + + const controller = new AbortController(); + const timeoutMs = init.timeoutMs ?? 10_000; + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(url, { + ...init, + signal: controller.signal, + headers: { + Accept: 'application/json', + ...(init.body ? { 'Content-Type': 'application/json' } : undefined), + Authorization: getAuthHeader(), + ...(init.headers ?? {}), + }, + }); + + if (!res.ok) { + const body = await parseJsonBestEffort(res); + throw new ListmonkError( + `Listmonk request failed: ${res.status} ${res.statusText}`, + res.status, + body, + ); + } + + const json = (await res.json()) as ListmonkJson; + return json.data; + } finally { + clearTimeout(timeoutId); + } +} + +export interface ListmonkSubscriber { + id: number; + uuid: string; + email: string; + name: string; + status: 'enabled' | 'disabled' | 'blocklisted'; + lists?: unknown; +} + +export async function createSubscriber(params: { + email: string; + name?: string; + listIds: number[]; +}): Promise { + return listmonkRequest('/api/subscribers', { + method: 'POST', + body: JSON.stringify({ + email: params.email, + name: params.name ?? params.email, + status: 'enabled', + lists: params.listIds, + }), + }); +} + +export async function sendOptInEmail(subscriberId: number): Promise { + const result = await listmonkRequest( + `/api/subscribers/${subscriberId}/optin`, + { method: 'POST', body: JSON.stringify({}) }, + ); + return Boolean(result); +} + +/** listmonk subscriber UUID (8-4-4-4-12 hex, version/variant per RFC 4122) */ +const SUBSCRIBER_UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function isListmonkSubscriberUuid(value: string): boolean { + return SUBSCRIBER_UUID_RE.test(value.trim()); +} + +export async function findSubscriberByUuid( + uuid: string, +): Promise { + const trimmed = uuid.trim(); + if (!isListmonkSubscriberUuid(trimmed)) { + return null; + } + + const query = encodeURIComponent( + `subscribers.uuid = '${trimmed.replace(/'/g, "''")}'`, + ); + const data = await listmonkRequest<{ + results: ListmonkSubscriber[]; + total: number; + }>(`/api/subscribers?query=${query}&page=1&per_page=100`); + + const match = data.results?.[0]; + return match ?? null; +} + +export async function unsubscribeSubscriberFromLists(params: { + subscriberIds: number[]; + targetListIds: number[]; +}): Promise { + const ok = await listmonkRequest('/api/subscribers/lists', { + method: 'PUT', + body: JSON.stringify({ + ids: params.subscriberIds, + action: 'unsubscribe', + target_list_ids: params.targetListIds, + }), + }); + return Boolean(ok); +} diff --git a/src/lib/newsletter-schema.ts b/src/lib/newsletter-schema.ts index a55bc698..7f1f3e46 100644 --- a/src/lib/newsletter-schema.ts +++ b/src/lib/newsletter-schema.ts @@ -16,11 +16,8 @@ export const newsletterSubscribeSchema = z.object({ return ( typeof parsed === 'object' && parsed !== null && - 'algorithm' in parsed && 'challenge' in parsed && - 'number' in parsed && - 'salt' in parsed && - 'signature' in parsed + 'solution' in parsed ); } catch { return false; diff --git a/src/types/altcha.d.ts b/src/types/altcha.d.ts new file mode 100644 index 00000000..e5505cd6 --- /dev/null +++ b/src/types/altcha.d.ts @@ -0,0 +1,25 @@ +import type React from 'react'; + +declare module 'react/jsx-runtime' { + namespace JSX { + interface IntrinsicElements { + 'altcha-widget': React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLElement + > & { + // v3: `challenge` can be a URL or inline JSON challenge. + challenge?: string; + // Legacy / alternative spellings used across versions/examples. + challengeurl?: string; + challengeUrl?: string; + hideLogo?: boolean; + hidelogo?: boolean; + hideFooter?: boolean; + hidefooter?: boolean; + name?: string; + strings?: string; + style?: React.CSSProperties; + }; + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 33bba74c..f729beea 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "moduleDetection": "force",