Skip to content
68 changes: 67 additions & 1 deletion docs/dependency-upgrade-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -2688,7 +2688,73 @@ 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.

**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.
**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 |
Comment thread
Jan0707 marked this conversation as resolved.
| `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
`::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:
Expand Down
37 changes: 2 additions & 35 deletions nuxt-app/composables/useDirectus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
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'
Expand Down Expand Up @@ -678,52 +677,52 @@
}

async function getSpeakersForBuild(limit: number) {
const ctos = await directus.request(
readItems('speakers', {
fields: [
'id',
'slug',
'published_on',
'academic_title',
'first_name',
'last_name',
'occupation',
'description',
'profile_image.*',
'tags.tag.id',
'tags.tag.name',
'podcasts.podcast.type',
],
limit: Math.round(limit / 2),
sort: ['sort', '-published_on'],
filter: {
listed_hof: { _eq: true },
podcasts: { podcast: { type: { _eq: 'cto_special' } } },

Check warning on line 700 in nuxt-app/composables/useDirectus.ts

View workflow job for this annotation

GitHub Actions / nuxt-app-test

Code duplication

21 duplicated lines (54 tokens) 2 instances found. Also in: → composables/useDirectus.ts:705-725 Extract a shared function to eliminate this duplication.
},
})
)

const others = await directus.request(
readItems('speakers', {
fields: [
'id',
'slug',
'published_on',
'academic_title',
'first_name',
'last_name',
'occupation',
'description',
'profile_image.*',
'tags.tag.id',
'tags.tag.name',
'podcasts.podcast.type',
],
limit: Math.round(limit / 2),
sort: ['sort', '-published_on'],
filter: {
listed_hof: { _eq: true },
podcasts: { podcast: { type: { _neq: 'cto_special' } } },

Check warning on line 725 in nuxt-app/composables/useDirectus.ts

View workflow job for this annotation

GitHub Actions / nuxt-app-test

Code duplication

21 duplicated lines (54 tokens) 2 instances found. Also in: → composables/useDirectus.ts:680-700 Extract a shared function to eliminate this duplication.
},
})
)
Expand Down Expand Up @@ -921,11 +920,9 @@
],
}

// 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) {
Expand All @@ -940,34 +937,6 @@
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 getRecentRatingByIP(podcast: DirectusPodcastItem, rawIP: string, since: Date) {
const hashedIP = await anonymizeAndHashIP(rawIP)
if (!hashedIP) {
return null
}

const ratings = await directus.request(
readItems('ratings', {
filter: {
ip: { _eq: hashedIP },
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<Collections, DirectusRatingItem>,
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 }))
}
Expand Down Expand Up @@ -1099,8 +1068,6 @@
getProfileById,
getTestimonials,
createRating,
getRecentRatingByIP,
updateRatingVote,
addCommentToRating,
}
}
2 changes: 0 additions & 2 deletions nuxt-app/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,3 @@ export * from './resolveNewsLink'
export * from './setCookie'
export * from './sleep'
export * from './trackGoal'
export * from './aiSpamFilter'
export * from './ipProcessing'
3 changes: 1 addition & 2 deletions nuxt-app/server/api/email.post.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -12,11 +11,11 @@
}

// Validate the body with Zod schema
const parseResult = EmailSchema.safeParse(rawBody)
if (!parseResult.success) {
const issue = parseResult.error.issues[0]
const key = String(issue?.path?.[0] ?? 'input')
const message = issue?.message ?? 'Validation error'

Check warning on line 18 in nuxt-app/server/api/email.post.ts

View workflow job for this annotation

GitHub Actions / nuxt-app-test

Code duplication

5 duplicated lines (50 tokens) 4 instances found. Also in: → api/tickets/create-checkout.post.ts:145-149 → api/tickets/validate-discount.post.ts:7-11 → server/api/vote.post.ts:13-17 Extract a shared function to eliminate this duplication.
throw createError({ statusCode: 400, message: `${key}: ${message}` })
}
const clientData = parseResult.data

Check warning on line 21 in nuxt-app/server/api/email.post.ts

View workflow job for this annotation

GitHub Actions / nuxt-app-test

Code duplication

8 duplicated lines (67 tokens) 2 instances found. Also in: → server/api/vote.post.ts:13-20 Extract a shared function to eliminate this duplication.
Expand Down
15 changes: 9 additions & 6 deletions nuxt-app/server/api/vote.post.ts
Original file line number Diff line number Diff line change
@@ -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, 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,
Expand All @@ -10,11 +10,11 @@
export default defineEventHandler(async (event) => {
const rawBody = await readBody(event)

const parseResult = VoteSchema.safeParse(rawBody)
if (!parseResult.success) {
const issue = parseResult.error.issues[0]
const key = String(issue?.path?.[0] ?? 'input')
const message = issue?.message ?? 'Validation error'

Check warning on line 17 in nuxt-app/server/api/vote.post.ts

View workflow job for this annotation

GitHub Actions / nuxt-app-test

Code duplication

5 duplicated lines (50 tokens) 4 instances found. Also in: → server/api/email.post.ts:14-18 → api/tickets/create-checkout.post.ts:145-149 → api/tickets/validate-discount.post.ts:7-11 Extract a shared function to eliminate this duplication.
throw createError({ statusCode: 400, message: `${key}: ${message}` })
}
const { slug, direction } = parseResult.data

Check warning on line 20 in nuxt-app/server/api/vote.post.ts

View workflow job for this annotation

GitHub Actions / nuxt-app-test

Code duplication

8 duplicated lines (67 tokens) 2 instances found. Also in: → server/api/email.post.ts:14-21 Extract a shared function to eliminate this duplication.
Expand All @@ -39,10 +39,11 @@
// 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
Expand All @@ -51,12 +52,14 @@
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) {
// 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.getRecentRatingByIP(podcast, rawIP, 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 }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `
Expand Down Expand Up @@ -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)

Expand Down
29 changes: 29 additions & 0 deletions nuxt-app/server/utils/authenticatedDirectus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<DirectusPodcastItem, 'id'>, 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<Collections, DirectusRatingItem>,
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,
Expand Down Expand Up @@ -609,5 +636,7 @@ export function useAuthenticatedDirectus() {
createMeetupRegistration,
readMeetupRegistrationByCancelToken,
cancelMeetupRegistration,
getRecentRatingByIPHash,
updateRatingVote,
}
}
2 changes: 2 additions & 0 deletions nuxt-app/server/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from './aiSpamFilter'
export * from './authenticatedDirectus'
export * from './ipProcessing'
export * from './sendEmail'
export * from './schema'
export * from './stripe'
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<string | undefined> {
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)) {
Expand Down
7 changes: 7 additions & 0 deletions nuxt-app/server/utils/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
34 changes: 34 additions & 0 deletions nuxt-app/test/ipProcessing.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Loading