From 817c2993327f2304736c8f4210c7ca750a196345 Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Tue, 20 Oct 2026 17:54:20 +0200 Subject: [PATCH 1/6] Move aiSpamFilter to server/utils Its only caller is server/api/email.post.ts, but the shared helpers barrel put the Gemini SDK into the client module graph. The response schema moves to server/utils/schema.ts as SpamFilterResponseSchema, rather than becoming a server-wide auto-import named ResponseSchema. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RE7Gq8fzzVAfBahzvcUqTP --- nuxt-app/helpers/index.ts | 1 - nuxt-app/server/api/email.post.ts | 3 +-- nuxt-app/{helpers => server/utils}/aiSpamFilter.ts | 10 ++-------- nuxt-app/server/utils/index.ts | 1 + nuxt-app/server/utils/schema.ts | 7 +++++++ 5 files changed, 11 insertions(+), 11 deletions(-) rename nuxt-app/{helpers => server/utils}/aiSpamFilter.ts (91%) diff --git a/nuxt-app/helpers/index.ts b/nuxt-app/helpers/index.ts index ad9fcd29..cb8b68f0 100644 --- a/nuxt-app/helpers/index.ts +++ b/nuxt-app/helpers/index.ts @@ -11,5 +11,4 @@ export * from './resolveNewsLink' export * from './setCookie' export * from './sleep' export * from './trackGoal' -export * from './aiSpamFilter' export * from './ipProcessing' diff --git a/nuxt-app/server/api/email.post.ts b/nuxt-app/server/api/email.post.ts index ca587adf..83f17763 100644 --- a/nuxt-app/server/api/email.post.ts +++ b/nuxt-app/server/api/email.post.ts @@ -1,5 +1,4 @@ -import { filterSpam } from '../../helpers' -import { EmailSchema, sendEmail } from '../utils' +import { EmailSchema, filterSpam, sendEmail } from '../utils' export default defineEventHandler(async (event) => { // Read body once and use for both honeypot check and validation diff --git a/nuxt-app/helpers/aiSpamFilter.ts b/nuxt-app/server/utils/aiSpamFilter.ts similarity index 91% rename from nuxt-app/helpers/aiSpamFilter.ts rename to nuxt-app/server/utils/aiSpamFilter.ts index 09f7104b..26fc978d 100644 --- a/nuxt-app/helpers/aiSpamFilter.ts +++ b/nuxt-app/server/utils/aiSpamFilter.ts @@ -1,11 +1,5 @@ import { GoogleGenerativeAI } from '@google/generative-ai' -import { z } from 'zod' - -export const ResponseSchema = z.object({ - confidenceScore: z.number().min(0).max(1), - isSpam: z.boolean(), - reason: z.string(), -}) +import { SpamFilterResponseSchema } from './schema' function getValidationPrompt(name: string, email: string, message: string): string { return ` @@ -65,7 +59,7 @@ export async function filterSpam(input: { name: string; email: string; message: .replace(/```json/g, '') .replace(/```/g, '') .trim() - const validation = ResponseSchema.parse(JSON.parse(jsonString)) + const validation = SpamFilterResponseSchema.parse(JSON.parse(jsonString)) console.log('Gemini Validation: isSpam=%s, confidenceScore=%s', validation.isSpam, validation.confidenceScore) diff --git a/nuxt-app/server/utils/index.ts b/nuxt-app/server/utils/index.ts index c4adccba..6fa49aa7 100644 --- a/nuxt-app/server/utils/index.ts +++ b/nuxt-app/server/utils/index.ts @@ -1,3 +1,4 @@ +export * from './aiSpamFilter' export * from './authenticatedDirectus' export * from './sendEmail' export * from './schema' diff --git a/nuxt-app/server/utils/schema.ts b/nuxt-app/server/utils/schema.ts index 1a7f93b4..7b63aa97 100644 --- a/nuxt-app/server/utils/schema.ts +++ b/nuxt-app/server/utils/schema.ts @@ -16,6 +16,13 @@ export const EmailSchema = z.object({ .max(2048, 'Deine Nachricht darf nicht länger als 2.048 Zeichen lang sein.'), }) +// The JSON the Gemini spam filter is asked to answer with +export const SpamFilterResponseSchema = z.object({ + confidenceScore: z.number().min(0).max(1), + isSpam: z.boolean(), + reason: z.string(), +}) + // Podcast vote (rating) export const VoteSchema = z.object({ From b8047c6f95c3b13c66cbaf9d1f799dc88763bfce Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Tue, 20 Oct 2026 17:54:20 +0200 Subject: [PATCH 2/6] Hash the vote IP in the route; move ipProcessing to server/utils The shared useDirectus composable received raw IPs and hashed them through a dynamic node:crypto import, so the client bundle would not break. The vote route now hashes once and passes only the hash, and getRecentRatingByIP becomes getRecentRatingByIPHash. Hashes are byte-identical to before, which test/ipProcessing.test.ts now pins, since stored ratings carry them. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RE7Gq8fzzVAfBahzvcUqTP --- nuxt-app/composables/useDirectus.ts | 18 +++------- nuxt-app/helpers/index.ts | 1 - nuxt-app/server/api/vote.post.ts | 11 +++--- nuxt-app/server/utils/index.ts | 1 + .../{helpers => server/utils}/ipProcessing.ts | 9 +++-- nuxt-app/test/ipProcessing.test.ts | 34 +++++++++++++++++++ nuxt-app/test/vote.route.test.ts | 31 +++++++++++------ 7 files changed, 71 insertions(+), 34 deletions(-) rename nuxt-app/{helpers => server/utils}/ipProcessing.ts (88%) create mode 100644 nuxt-app/test/ipProcessing.test.ts diff --git a/nuxt-app/composables/useDirectus.ts b/nuxt-app/composables/useDirectus.ts index defa7285..bb07b78a 100644 --- a/nuxt-app/composables/useDirectus.ts +++ b/nuxt-app/composables/useDirectus.ts @@ -29,7 +29,6 @@ import type { TagItem, } from '~/types' import { DIRECTUS_CMS_URL, WEBSITE_URL } from './../config' -import { anonymizeAndHashIP } from './../helpers/' // This import needs to be relative/file-based // so that it can be resolved during the nuxt build process import { directus, type Collections } from './../services' @@ -921,11 +920,9 @@ export function useDirectus() { ], } + // Already anonymized and hashed by the server route; the raw IP never reaches this composable. if (metadata?.ip) { - const hashedIP = await anonymizeAndHashIP(metadata.ip) - if (hashedIP) { - payload.ip = hashedIP - } + payload.ip = metadata.ip } if (metadata?.user_agent) { @@ -941,16 +938,11 @@ export function useDirectus() { } // Latest rating of the podcast from the same anonymized, hashed IP created at or after `since`. - async function getRecentRatingByIP(podcast: DirectusPodcastItem, rawIP: string, since: Date) { - const hashedIP = await anonymizeAndHashIP(rawIP) - if (!hashedIP) { - return null - } - + async function getRecentRatingByIPHash(podcast: DirectusPodcastItem, ipHash: string, since: Date) { const ratings = await directus.request( readItems('ratings', { filter: { - ip: { _eq: hashedIP }, + ip: { _eq: ipHash }, date_created: { _gte: since.toISOString() }, // `target` is a many-to-any relation, so the filter has to name the collection. target: { 'target:podcasts': { id: { _eq: podcast.id } } }, @@ -1099,7 +1091,7 @@ export function useDirectus() { getProfileById, getTestimonials, createRating, - getRecentRatingByIP, + getRecentRatingByIPHash, updateRatingVote, addCommentToRating, } diff --git a/nuxt-app/helpers/index.ts b/nuxt-app/helpers/index.ts index cb8b68f0..c7d260d0 100644 --- a/nuxt-app/helpers/index.ts +++ b/nuxt-app/helpers/index.ts @@ -11,4 +11,3 @@ export * from './resolveNewsLink' export * from './setCookie' export * from './sleep' export * from './trackGoal' -export * from './ipProcessing' diff --git a/nuxt-app/server/api/vote.post.ts b/nuxt-app/server/api/vote.post.ts index 646ad8c2..375c1775 100644 --- a/nuxt-app/server/api/vote.post.ts +++ b/nuxt-app/server/api/vote.post.ts @@ -1,7 +1,7 @@ import { useDirectus } from '~/composables/useDirectus' import { VOTE_COOLDOWN_MINUTES } from '~/config' import { isbot } from 'isbot' -import { VoteSchema } from '../utils' +import { anonymizeAndHashIP, VoteSchema } from '../utils' // The only path that writes a vote. The GET /podcast/[slug]/[up|down] links that // ship in RSS show notes no longer write — they redirect to the podcast page, @@ -39,10 +39,11 @@ export default defineEventHandler(async (event) => { // Split x-forwarded-for on comma and take the first entry (the client IP) const xForwardedFor = event.node.req.headers['x-forwarded-for'] as string | undefined const rawIP = xForwardedFor ? xForwardedFor.split(',')[0].trim() : event.node.req.socket.remoteAddress + const ipHash = rawIP ? anonymizeAndHashIP(rawIP) : undefined const referrer = event.node.req.headers['referer'] - if (rawIP) { - metadata['ip'] = rawIP + if (ipHash) { + metadata['ip'] = ipHash } if (referrer) { metadata['referer_url'] = referrer @@ -51,9 +52,9 @@ export default defineEventHandler(async (event) => { try { // A further vote from the same IP hash on the same episode within the cooldown replaces // the earlier one, so a corrected vote counts and a double click counts once. - if (rawIP) { + if (ipHash) { const since = new Date(Date.now() - VOTE_COOLDOWN_MINUTES * 60 * 1000) - const recentRating = await directus.getRecentRatingByIP(podcast, rawIP, since) + const recentRating = await directus.getRecentRatingByIPHash(podcast, ipHash, since) if (recentRating) { if (recentRating.up_or_down !== direction) { await directus.updateRatingVote(recentRating, direction) diff --git a/nuxt-app/server/utils/index.ts b/nuxt-app/server/utils/index.ts index 6fa49aa7..57476e86 100644 --- a/nuxt-app/server/utils/index.ts +++ b/nuxt-app/server/utils/index.ts @@ -1,5 +1,6 @@ export * from './aiSpamFilter' export * from './authenticatedDirectus' +export * from './ipProcessing' export * from './sendEmail' export * from './schema' export * from './stripe' diff --git a/nuxt-app/helpers/ipProcessing.ts b/nuxt-app/server/utils/ipProcessing.ts similarity index 88% rename from nuxt-app/helpers/ipProcessing.ts rename to nuxt-app/server/utils/ipProcessing.ts index 48d02791..5f04b805 100644 --- a/nuxt-app/helpers/ipProcessing.ts +++ b/nuxt-app/server/utils/ipProcessing.ts @@ -1,3 +1,6 @@ +import { createHash } from 'node:crypto' +import { isIPv4, isIPv6 } from 'node:net' + /** * Expand a compressed IPv6 address to its full 8-group form. * Handles ::1, ::ffff:x.x.x.x, and other compressed forms. @@ -33,12 +36,8 @@ function expandIPv6(ip: string): string { /** * Validate, anonymize, and SHA-256 hash an IP address. * Returns hex string or undefined if the IP is invalid. - * Uses dynamic imports for node:crypto and node:net to avoid client bundle errors. */ -async function anonymizeAndHashIP(rawIP: string): Promise { - const { createHash } = await import('node:crypto') - const { isIPv4, isIPv6 } = await import('node:net') - +function anonymizeAndHashIP(rawIP: string): string | undefined { const trimmed = rawIP.trim() if (isIPv4(trimmed)) { diff --git a/nuxt-app/test/ipProcessing.test.ts b/nuxt-app/test/ipProcessing.test.ts new file mode 100644 index 00000000..2f2ece5a --- /dev/null +++ b/nuxt-app/test/ipProcessing.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { anonymizeAndHashIP } from '../server/utils/ipProcessing' + +// Stored ratings carry these hashes, and the vote cooldown matches on them. Any change to the +// anonymization or hashing splits the cooldown across a deploy, so these values must not move. +describe('anonymizeAndHashIP', () => { + it.each([ + ['203.0.113.7', '2172243fafcf160d8b3bf50e372877cfd3700fe0f59db1c795e26b2201cdb65f'], + ['198.51.100.1', 'a1f1df59d2c345dbc1f874828f8e38d51d4f204b292a49476339d0cbe2c1cb8f'], + ['2001:db8:85a3::8a2e:370:7334', 'dab6247d103932741ae6fd88d67d510316f0e0334b117cf2c47cd95ab2b8514a'], + ['2001:db8::1', '45095fc532f8e21e39470bf8f3e0e4f7cfbcb77d47cdde8b75a8daa7aa0e7c6b'], + ['::1', '3078dd4fcbe4e59f28e7bb376037df6186a78cb47515f95812dcde2fd46116ef'], + ])('hashes %s to its stored value', (ip, hash) => { + expect(anonymizeAndHashIP(ip)).toBe(hash) + }) + + it('drops the last two IPv4 octets before hashing', () => { + expect(anonymizeAndHashIP('203.0.113.250')).toBe(anonymizeAndHashIP('203.0.113.7')) + }) + + it('treats the compressed and the full IPv6 form alike', () => { + expect(anonymizeAndHashIP('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe( + anonymizeAndHashIP('2001:db8:85a3::8a2e:370:7334') + ) + }) + + it('ignores surrounding whitespace', () => { + expect(anonymizeAndHashIP(' 203.0.113.7 ')).toBe(anonymizeAndHashIP('203.0.113.7')) + }) + + it.each(['not-an-ip', '', '999.1.1.1'])('returns undefined for %j', (ip) => { + expect(anonymizeAndHashIP(ip)).toBeUndefined() + }) +}) diff --git a/nuxt-app/test/vote.route.test.ts b/nuxt-app/test/vote.route.test.ts index 62407c9d..fdad423b 100644 --- a/nuxt-app/test/vote.route.test.ts +++ b/nuxt-app/test/vote.route.test.ts @@ -11,7 +11,7 @@ const directus = vi.hoisted(() => { g.createError = (input: any) => Object.assign(new Error(input.message), input) return { getPodcastBySlug: vi.fn(), - getRecentRatingByIP: vi.fn(), + getRecentRatingByIPHash: vi.fn(), updateRatingVote: vi.fn(), createRating: vi.fn(), } @@ -23,6 +23,9 @@ vi.mock('~/config', () => import('../config')) const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36' const PODCAST = { id: 'podcast-1', slug: 'folge-1' } +// anonymizeAndHashIP('203.0.113.7'): the SHA-256 of '203.0.0.0', the address with its last two octets +// zeroed. Stored ratings carry this value, so a change here would split the cooldown across a deploy. +const IP_HASH = '2172243fafcf160d8b3bf50e372877cfd3700fe0f59db1c795e26b2201cdb65f' const vote = (direction: 'up' | 'down', headers: Record = { 'user-agent': BROWSER_UA }) => (voteHandler as any)({ @@ -33,7 +36,7 @@ const vote = (direction: 'up' | 'down', headers: Record { Object.values(directus).forEach((fn) => fn.mockReset()) directus.getPodcastBySlug.mockResolvedValue(PODCAST) - directus.getRecentRatingByIP.mockResolvedValue(null) + directus.getRecentRatingByIPHash.mockResolvedValue(null) directus.createRating.mockResolvedValue({ id: 'rating-new' }) }) @@ -45,11 +48,19 @@ describe('POST /api/vote', () => { it('stores a vote from a browser', async () => { await expect(vote('up')).resolves.toEqual({ id: 'rating-new' }) expect(directus.createRating).toHaveBeenCalledWith('up', PODCAST, { - ip: '203.0.113.7', + ip: IP_HASH, user_agent: BROWSER_UA, }) }) + it('stores a vote without an IP, and skips the cooldown, when the IP is invalid', async () => { + await expect(vote('up', { 'user-agent': BROWSER_UA, 'x-forwarded-for': 'not-an-ip' })).resolves.toEqual({ + id: 'rating-new', + }) + expect(directus.getRecentRatingByIPHash).not.toHaveBeenCalled() + expect(directus.createRating).toHaveBeenCalledWith('up', PODCAST, { user_agent: BROWSER_UA }) + }) + it.each([ 'meta-externalagent/1.1 (+https://developers.facebook.com/docs/sharing/webmasters/crawler)', 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)', @@ -61,26 +72,26 @@ describe('POST /api/vote', () => { expect(directus.createRating).not.toHaveBeenCalled() }) - it('looks for an earlier vote from the same IP within the cooldown', async () => { + it('looks for an earlier vote from the same IP hash within the cooldown', async () => { const now = Date.parse('2026-10-20T08:00:00Z') vi.spyOn(Date, 'now').mockReturnValue(now) await vote('up') - expect(directus.getRecentRatingByIP).toHaveBeenCalledWith( + expect(directus.getRecentRatingByIPHash).toHaveBeenCalledWith( PODCAST, - '203.0.113.7', + IP_HASH, new Date(now - VOTE_COOLDOWN_MINUTES * 60 * 1000) ) }) it('replaces an earlier opposite vote within the cooldown instead of adding one', async () => { - directus.getRecentRatingByIP.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) + directus.getRecentRatingByIPHash.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) await expect(vote('down')).resolves.toEqual({ id: 'rating-old' }) expect(directus.updateRatingVote).toHaveBeenCalledWith({ id: 'rating-old', up_or_down: 'up' }, 'down') expect(directus.createRating).not.toHaveBeenCalled() }) it('counts a repeated vote within the cooldown once', async () => { - directus.getRecentRatingByIP.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) + directus.getRecentRatingByIPHash.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) await expect(vote('up')).resolves.toEqual({ id: 'rating-old' }) expect(directus.updateRatingVote).not.toHaveBeenCalled() expect(directus.createRating).not.toHaveBeenCalled() @@ -88,7 +99,7 @@ describe('POST /api/vote', () => { it('answers 500 when the earlier vote cannot be replaced', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) - directus.getRecentRatingByIP.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) + directus.getRecentRatingByIPHash.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) directus.updateRatingVote.mockRejectedValue(new Error('forbidden')) await expect(vote('down')).rejects.toMatchObject({ statusCode: 500 }) expect(directus.createRating).not.toHaveBeenCalled() @@ -96,7 +107,7 @@ describe('POST /api/vote', () => { it('answers 500 when the lookup fails', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) - directus.getRecentRatingByIP.mockRejectedValue(new Error('network')) + directus.getRecentRatingByIPHash.mockRejectedValue(new Error('network')) await expect(vote('up')).rejects.toMatchObject({ statusCode: 500 }) expect(directus.createRating).not.toHaveBeenCalled() }) From 13229e282bb0e6ee5b1ff917b305773bb61e7519 Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Tue, 20 Oct 2026 17:54:20 +0200 Subject: [PATCH 3/6] Plan: record the server-only helper move and the ::ffff: finding Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RE7Gq8fzzVAfBahzvcUqTP --- docs/dependency-upgrade-plan.md | 35 ++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/dependency-upgrade-plan.md b/docs/dependency-upgrade-plan.md index fc6e2c43..6e6f6800 100644 --- a/docs/dependency-upgrade-plan.md +++ b/docs/dependency-upgrade-plan.md @@ -2688,7 +2688,40 @@ browser-support decision nobody made. ~~**Hydration mismatch on `/kommt-bald` and `/api/cocktails`.**~~ Both pages logged `Hydration completed but contains mismatches.` in a local production build, identically on `main`. -- [ ] ⚠️ **Server-only helpers live in the shared `helpers/` barrel, and the fix is not a one-liner.** +- [x] ✅ **Moved both to `server/utils/`** — 2026-10-20. + + - **`ipProcessing`**: the vote route now hashes the IP once and passes only the hash. + `createRating` stores it as given, and `getRecentRatingByIP` became + `getRecentRatingByIPHash(podcast, ipHash, since)`. The raw IP no longer reaches the shared + composable. With static `node:crypto` and `node:net` imports, the function is synchronous, and + the dynamic-import workaround is gone. + - **`aiSpamFilter`**: moved unchanged, except that `ResponseSchema` went to + `server/utils/schema.ts` as `SpamFilterResponseSchema`. Exported from `server/utils/` under + its old, generic name, it would have become a server-wide auto-import. + + **Hashes are byte-identical**, which matters because stored ratings carry them and the cooldown + matches on them. Before the move, 13 inputs were hashed with the old helper (IPv4, IPv6 + compressed and full, `::1`, `::ffff:`, a zone index, whitespace, and three invalid inputs), and + the moved helper gives the same output for all of them. `test/ipProcessing.test.ts` now pins + those values, and the vote-route test asserts the exact hash, not just "some hash". A negative + control confirmed it: zeroing one octet differently made both hash-dependent route tests fail. + + **Verified beyond the gates:** both Vite warnings about `node:crypto` and `node:net` being + externalized for the browser are gone. The client bundle contains none of the moved code (no + `anonymizeAndHashIP`, no Gemini SDK). `/api/vote` and `/api/email` load in a production + server. + +- [ ] ⚠️ **IPv4-mapped IPv6 addresses all hash to the same value.** Found while pinning the hashes + above. `anonymizeAndHashIP` zeroes the last four groups of an IPv6 address, and in + `::ffff:a.b.c.d` those groups *are* the IPv4 address. So every IPv4-mapped address hashes like + `::1`, and the vote cooldown would treat all such voters as one person. **Probably not reachable + in production:** the route reads `x-forwarded-for`, which Vercel overwrites with the client's + public IP, so the `::ffff:` form only arrives through the `remoteAddress` fallback, which is used + locally. Not checked against stored ratings. The fix is to unwrap `::ffff:` to plain IPv4 before + anonymizing, but that **changes the hash for those inputs**, so it is deliberately left out of + the move, which had to keep every hash identical. + + ~~**Server-only helpers live in the shared `helpers/` barrel, and the fix is not a one-liner.**~~ The earlier version of this item said "server code should import it directly". That was wrong: the importer of `ipProcessing` is `composables/useDirectus.ts`, shared client/server code. Checked 2026-10-20: From f5d3a3d775d42c1898e4e14a81b651d19216a051 Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Wed, 21 Oct 2026 15:52:07 +0200 Subject: [PATCH 4/6] Run the vote cooldown lookup with the API token Every vote with an IP has failed with 500 since the cooldown shipped: useDirectus talks to the CMS without a token, and the public role may create ratings but not read their ip or target. The CMS answered 403 before the insert, so no vote was stored. getRecentRatingByIPHash and updateRatingVote move to useAuthenticatedDirectus. createRating stays public. The route test now mocks both clients separately; one mock for both is how the tests stayed green over the outage. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RE7Gq8fzzVAfBahzvcUqTP --- nuxt-app/composables/useDirectus.ts | 25 ------------- nuxt-app/server/api/vote.post.ts | 8 +++-- .../server/utils/authenticatedDirectus.ts | 29 +++++++++++++++ nuxt-app/test/vote.route.test.ts | 36 ++++++++++--------- 4 files changed, 54 insertions(+), 44 deletions(-) diff --git a/nuxt-app/composables/useDirectus.ts b/nuxt-app/composables/useDirectus.ts index bb07b78a..83187525 100644 --- a/nuxt-app/composables/useDirectus.ts +++ b/nuxt-app/composables/useDirectus.ts @@ -937,29 +937,6 @@ export function useDirectus() { return await directus.request(createItem('ratings', payload)) } - // Latest rating of the podcast from the same anonymized, hashed IP created at or after `since`. - async function getRecentRatingByIPHash(podcast: DirectusPodcastItem, ipHash: string, since: Date) { - const ratings = await directus.request( - readItems('ratings', { - filter: { - ip: { _eq: ipHash }, - date_created: { _gte: since.toISOString() }, - // `target` is a many-to-any relation, so the filter has to name the collection. - target: { 'target:podcasts': { id: { _eq: podcast.id } } }, - } as QueryFilter, - fields: ['id', 'up_or_down'], - sort: ['-date_created'], - limit: 1, - }) - ) - - return ratings?.[0] ?? null - } - - async function updateRatingVote(rating: { id: string }, vote: 'up' | 'down') { - return await directus.request(updateItem('ratings', rating.id, { up_or_down: vote })) - } - async function addCommentToRating(rating: { id: string }, comment: string) { return await directus.request(updateItem('ratings', rating.id, { comment })) } @@ -1091,8 +1068,6 @@ export function useDirectus() { getProfileById, getTestimonials, createRating, - getRecentRatingByIPHash, - updateRatingVote, addCommentToRating, } } diff --git a/nuxt-app/server/api/vote.post.ts b/nuxt-app/server/api/vote.post.ts index 375c1775..76969ff2 100644 --- a/nuxt-app/server/api/vote.post.ts +++ b/nuxt-app/server/api/vote.post.ts @@ -1,7 +1,7 @@ import { useDirectus } from '~/composables/useDirectus' import { VOTE_COOLDOWN_MINUTES } from '~/config' import { isbot } from 'isbot' -import { anonymizeAndHashIP, VoteSchema } from '../utils' +import { anonymizeAndHashIP, useAuthenticatedDirectus, VoteSchema } from '../utils' // The only path that writes a vote. The GET /podcast/[slug]/[up|down] links that // ship in RSS show notes no longer write — they redirect to the podcast page, @@ -53,11 +53,13 @@ export default defineEventHandler(async (event) => { // A further vote from the same IP hash on the same episode within the cooldown replaces // the earlier one, so a corrected vote counts and a double click counts once. if (ipHash) { + // Reading and changing existing ratings needs the API token; creating one does not. + const authenticatedDirectus = useAuthenticatedDirectus() const since = new Date(Date.now() - VOTE_COOLDOWN_MINUTES * 60 * 1000) - const recentRating = await directus.getRecentRatingByIPHash(podcast, ipHash, since) + const recentRating = await authenticatedDirectus.getRecentRatingByIPHash(podcast, ipHash, since) if (recentRating) { if (recentRating.up_or_down !== direction) { - await directus.updateRatingVote(recentRating, direction) + await authenticatedDirectus.updateRatingVote(recentRating, direction) } return { id: recentRating.id } } diff --git a/nuxt-app/server/utils/authenticatedDirectus.ts b/nuxt-app/server/utils/authenticatedDirectus.ts index 4f9eac28..240a0dca 100644 --- a/nuxt-app/server/utils/authenticatedDirectus.ts +++ b/nuxt-app/server/utils/authenticatedDirectus.ts @@ -15,12 +15,15 @@ import { updateItem, updateItems, uploadFiles, + type QueryFilter, } from '@directus/sdk' import type { Collections } from '~/services/directus' import type { DirectusContentVersion, DirectusMeetupRegistrationItem, DirectusNewsletterSubscriberItem, + DirectusPodcastItem, + DirectusRatingItem, DirectusTicketDiscountCodeItem, DirectusTicketItem, DirectusTicketOrderItem, @@ -574,6 +577,30 @@ export function useAuthenticatedDirectus() { return (updated?.length ?? 0) > 0 } + // Latest rating of the podcast from the same anonymized, hashed IP created at or after `since`. + // Needs the token: the public role may create ratings but not read their `ip` or `target`. + async function getRecentRatingByIPHash(podcast: Pick, ipHash: string, since: Date) { + const ratings = await client.request( + readItems('ratings', { + filter: { + ip: { _eq: ipHash }, + date_created: { _gte: since.toISOString() }, + // `target` is a many-to-any relation, so the filter has to name the collection. + target: { 'target:podcasts': { id: { _eq: podcast.id } } }, + } as QueryFilter, + fields: ['id', 'up_or_down'], + sort: ['-date_created'], + limit: 1, + }) + ) + + return ratings?.[0] ?? null + } + + async function updateRatingVote(rating: { id: string }, vote: 'up' | 'down') { + return await client.request(updateItem('ratings', rating.id, { up_or_down: vote })) + } + return { getSpeakerByPortalToken, updateSpeaker, @@ -609,5 +636,7 @@ export function useAuthenticatedDirectus() { createMeetupRegistration, readMeetupRegistrationByCancelToken, cancelMeetupRegistration, + getRecentRatingByIPHash, + updateRatingVote, } } diff --git a/nuxt-app/test/vote.route.test.ts b/nuxt-app/test/vote.route.test.ts index fdad423b..b6c85cfe 100644 --- a/nuxt-app/test/vote.route.test.ts +++ b/nuxt-app/test/vote.route.test.ts @@ -4,20 +4,24 @@ import voteHandler from '../server/api/vote.post' // Nuxt/Nitro auto-imports must exist before the handler is imported (its // `defineEventHandler(...)` runs at load time); vi.hoisted runs first. -const directus = vi.hoisted(() => { +const { directus, authenticated } = vi.hoisted(() => { const g = globalThis as any g.defineEventHandler = (fn: any) => fn g.readBody = async (event: any) => event.body ?? {} g.createError = (input: any) => Object.assign(new Error(input.message), input) return { - getPodcastBySlug: vi.fn(), - getRecentRatingByIPHash: vi.fn(), - updateRatingVote: vi.fn(), - createRating: vi.fn(), + // The public client: the CMS's public role may read podcasts and create ratings. + directus: { getPodcastBySlug: vi.fn(), createRating: vi.fn() }, + // The token client: the public role may not read a rating's `ip` or `target`, nor update one. + authenticated: { getRecentRatingByIPHash: vi.fn(), updateRatingVote: vi.fn() }, } }) vi.mock('~/composables/useDirectus', () => ({ useDirectus: () => directus })) +vi.mock('../server/utils', async (importOriginal) => ({ + ...(await importOriginal()), + useAuthenticatedDirectus: () => authenticated, +})) vi.mock('~/config', () => import('../config')) const BROWSER_UA = @@ -34,9 +38,9 @@ const vote = (direction: 'up' | 'down', headers: Record { - Object.values(directus).forEach((fn) => fn.mockReset()) + for (const fn of [...Object.values(directus), ...Object.values(authenticated)]) fn.mockReset() directus.getPodcastBySlug.mockResolvedValue(PODCAST) - directus.getRecentRatingByIPHash.mockResolvedValue(null) + authenticated.getRecentRatingByIPHash.mockResolvedValue(null) directus.createRating.mockResolvedValue({ id: 'rating-new' }) }) @@ -57,7 +61,7 @@ describe('POST /api/vote', () => { await expect(vote('up', { 'user-agent': BROWSER_UA, 'x-forwarded-for': 'not-an-ip' })).resolves.toEqual({ id: 'rating-new', }) - expect(directus.getRecentRatingByIPHash).not.toHaveBeenCalled() + expect(authenticated.getRecentRatingByIPHash).not.toHaveBeenCalled() expect(directus.createRating).toHaveBeenCalledWith('up', PODCAST, { user_agent: BROWSER_UA }) }) @@ -76,7 +80,7 @@ describe('POST /api/vote', () => { const now = Date.parse('2026-10-20T08:00:00Z') vi.spyOn(Date, 'now').mockReturnValue(now) await vote('up') - expect(directus.getRecentRatingByIPHash).toHaveBeenCalledWith( + expect(authenticated.getRecentRatingByIPHash).toHaveBeenCalledWith( PODCAST, IP_HASH, new Date(now - VOTE_COOLDOWN_MINUTES * 60 * 1000) @@ -84,30 +88,30 @@ describe('POST /api/vote', () => { }) it('replaces an earlier opposite vote within the cooldown instead of adding one', async () => { - directus.getRecentRatingByIPHash.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) + authenticated.getRecentRatingByIPHash.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) await expect(vote('down')).resolves.toEqual({ id: 'rating-old' }) - expect(directus.updateRatingVote).toHaveBeenCalledWith({ id: 'rating-old', up_or_down: 'up' }, 'down') + expect(authenticated.updateRatingVote).toHaveBeenCalledWith({ id: 'rating-old', up_or_down: 'up' }, 'down') expect(directus.createRating).not.toHaveBeenCalled() }) it('counts a repeated vote within the cooldown once', async () => { - directus.getRecentRatingByIPHash.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) + authenticated.getRecentRatingByIPHash.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) await expect(vote('up')).resolves.toEqual({ id: 'rating-old' }) - expect(directus.updateRatingVote).not.toHaveBeenCalled() + expect(authenticated.updateRatingVote).not.toHaveBeenCalled() expect(directus.createRating).not.toHaveBeenCalled() }) it('answers 500 when the earlier vote cannot be replaced', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) - directus.getRecentRatingByIPHash.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) - directus.updateRatingVote.mockRejectedValue(new Error('forbidden')) + authenticated.getRecentRatingByIPHash.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) + authenticated.updateRatingVote.mockRejectedValue(new Error('forbidden')) await expect(vote('down')).rejects.toMatchObject({ statusCode: 500 }) expect(directus.createRating).not.toHaveBeenCalled() }) it('answers 500 when the lookup fails', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) - directus.getRecentRatingByIPHash.mockRejectedValue(new Error('network')) + authenticated.getRecentRatingByIPHash.mockRejectedValue(new Error('network')) await expect(vote('up')).rejects.toMatchObject({ statusCode: 500 }) expect(directus.createRating).not.toHaveBeenCalled() }) From 5181c4cb8773aa3d4745eaad55933d2c58d43f53 Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Wed, 21 Oct 2026 15:52:07 +0200 Subject: [PATCH 5/6] Plan: record the vote outage and its fix Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RE7Gq8fzzVAfBahzvcUqTP --- docs/dependency-upgrade-plan.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/dependency-upgrade-plan.md b/docs/dependency-upgrade-plan.md index 6e6f6800..bb9a0cbf 100644 --- a/docs/dependency-upgrade-plan.md +++ b/docs/dependency-upgrade-plan.md @@ -2711,6 +2711,24 @@ browser-support decision nobody made. `anonymizeAndHashIP`, no Gemini SDK). `/api/vote` and `/api/email` load in a production server. + **The preview test exposed a production outage, fixed in the same PR (#285).** Every vote with + an IP answered 500, in production too, since the cooldown lookup shipped in `cd9acca` + (2026-10-20). `useDirectus` talks to the CMS without a token, so the server ran the lookup as the + **public role**, and that role may create ratings but not read a rating's `ip` or `target`. The + CMS answered `403 FORBIDDEN`, and the lookup runs before the insert, so **no vote was stored**. The + 403 reproduces with a plain unauthenticated request. Vercel's error list stayed empty, because + the route catches the error and only logs it. + + `getRecentRatingByIPHash` and `updateRatingVote` moved to `useAuthenticatedDirectus()`, which + uses the API token. `createRating` stays on the public client, which is allowed to create. + Opening up the public role instead would have let anyone query the IP hashes. The route test + now mocks the two clients separately. Before, a single mock stood in for both, which is how + the tests stayed green over the outage. A negative control confirmed it: with the lookup back + on the public client, 4 tests fail. Locally, without a token, a vote now fails with + `NUXT_DIRECTUS_API_TOKEN not configured`, which shows the route takes the token path. + **Requires** the token's role to have `read` and `update` on `ratings`, including `ip` and + `target`. Verified on the preview. + - [ ] ⚠️ **IPv4-mapped IPv6 addresses all hash to the same value.** Found while pinning the hashes above. `anonymizeAndHashIP` zeroes the last four groups of an IPv6 address, and in `::ffff:a.b.c.d` those groups *are* the IPv4 address. So every IPv4-mapped address hashes like From ec5c8b94350fd636bd4306bb1446f4fd399d1d97 Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Wed, 21 Oct 2026 16:33:56 +0200 Subject: [PATCH 6/6] Plan: record the CMS permissions the vote flow depends on Directus permissions are not in the repo, so the grants made by hand for the vote cooldown are written down here. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RE7Gq8fzzVAfBahzvcUqTP --- docs/dependency-upgrade-plan.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/dependency-upgrade-plan.md b/docs/dependency-upgrade-plan.md index bb9a0cbf..823d408d 100644 --- a/docs/dependency-upgrade-plan.md +++ b/docs/dependency-upgrade-plan.md @@ -2726,8 +2726,23 @@ browser-support decision nobody made. the tests stayed green over the outage. A negative control confirmed it: with the lookup back on the public client, 4 tests fail. Locally, without a token, a vote now fails with `NUXT_DIRECTUS_API_TOKEN not configured`, which shows the route takes the token path. - **Requires** the token's role to have `read` and `update` on `ratings`, including `ip` and - `target`. Verified on the preview. + **CMS permissions this depends on.** Directus roles and policies are **not versioned**: + `directus-cms/schema.json` holds collections, fields and relations only. These grants were set + by hand in the Directus admin on 2026-10-21, on the role or policy behind + `NUXT_DIRECTUS_API_TOKEN`. Anyone rebuilding the CMS has to set them again: + + | collection | action | fields | used by | + | --- | --- | --- | --- | + | `ratings` | read | `id`, `up_or_down`, `ip`, `date_created`, `target` | `getRecentRatingByIPHash`, which filters on `ip`, `date_created` and `target` and returns `id` and `up_or_down` | + | `ratings_target` | read | `rating`, `target`, `collection` | the same lookup. `target` is a many-to-any relation, and `target:podcasts` goes through this junction | + | `podcasts` | read | `id` | the end of that filter | + | `ratings` | update | `up_or_down` | `updateRatingVote`, for an opposite vote within the cooldown | + + Creating a rating stays with the **public role**, which already had `create` on `ratings`. A + first preview test with only the code fix failed with `403` on `id` and `up_or_down`, now from the + token client. Directus reports the requested fields first, so the error does not list everything + that is missing. With all four grants, the preview stored a vote, and the opposite vote within + 10 minutes replaced it: **one** rating, with the second direction, as intended. - [ ] ⚠️ **IPv4-mapped IPv6 addresses all hash to the same value.** Found while pinning the hashes above. `anonymizeAndHashIP` zeroes the last four groups of an IPv6 address, and in