Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion apps/cow-fi/app/(learn)/learn/[article]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { Metadata } from 'next'

import { ArticlePageComponent } from '@/components/ArticlePageComponent'
import { FEATURED_ARTICLES_PAGE_SIZE } from '@/const/pagination'
import { isValidCmsSlug } from '@/util/cmsValidation'
import { fetchArticleWithRetry } from '@/util/fetchHelpers'
import { getPageMetadata } from '@/util/getPageMetadata'
import { stripHtmlTags } from '@/util/stripHTMLTags'
Expand Down Expand Up @@ -44,7 +45,12 @@ type Props = {
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const articleSlug = (await params).article

if (!articleSlug) return {}
if (!articleSlug || !isValidCmsSlug(articleSlug)) {
return getPageMetadata({
title: 'Article Not Found',
description: 'The requested article could not be found.',
})
}

try {
const article = await getArticleBySlug(articleSlug)
Expand Down Expand Up @@ -94,6 +100,10 @@ export async function generateStaticParams(): Promise<{ article: string }[]> {
export default async function ArticlePage({ params }: Props): Promise<ReactNode> {
const articleSlug = (await params).article

if (!isValidCmsSlug(articleSlug)) {
return notFound()
}

try {
const article = await fetchArticleWithRetry(articleSlug)

Expand Down
23 changes: 21 additions & 2 deletions apps/cow-fi/app/(learn)/learn/topic/[topicSlug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import type { Metadata } from 'next'

import { TopicPageComponent } from '@/components/TopicPageComponent'
import { isValidCmsSlug } from '@/util/cmsValidation'
import { getPageMetadata } from '@/util/getPageMetadata'

type Props = {
Expand All @@ -29,10 +30,23 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {

const { topicSlug } = await params

if (!topicSlug) return {}
if (!topicSlug || !isValidCmsSlug(topicSlug)) {
return getPageMetadata({
absoluteTitle: 'Topic Not Found - Knowledge base',
description: 'The requested topic could not be found.',
})
}

const category = await getCategoryBySlug(topicSlug)
const { name, description = '' } = category?.attributes || {}

if (!category || !category.attributes) {
return getPageMetadata({
absoluteTitle: 'Topic Not Found - Knowledge base',
description: 'The requested topic could not be found.',
})
}

const { name, description = '' } = category.attributes

return getPageMetadata({
absoluteTitle: `${name} - Knowledge base`,
Expand All @@ -50,6 +64,11 @@ export async function generateStaticParams(): Promise<{ topicSlug: string }[]> {

export default async function TopicPage({ params }: { params: Promise<{ topicSlug: string }> }): Promise<ReactNode> {
const { topicSlug } = await params

if (!isValidCmsSlug(topicSlug)) {
notFound()
}

const category = await getCategoryBySlug(topicSlug)

if (!category) {
Expand Down
16 changes: 9 additions & 7 deletions apps/cow-fi/app/actions.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
'use server'

import { searchArticles as searchArticlesService } from '../services/cms'
import { normalizeSearchArticlesInput } from '../util/cmsValidation'

/**
* Server action to search for articles
*/
export async function searchArticlesAction({
searchTerm,
page = 0,
pageSize = 10,
}: {
type SearchArticlesActionResult =
| { success: true; data: Awaited<ReturnType<typeof searchArticlesService>> }
| { success: false; error: string }

export async function searchArticlesAction(input: {
searchTerm: string
page?: number
pageSize?: number
}) {
}): Promise<SearchArticlesActionResult> {
try {
const results = await searchArticlesService({ searchTerm, page, pageSize })
const normalizedInput = normalizeSearchArticlesInput(input)
const results = await searchArticlesService(normalizedInput)

return { success: true, data: results }
} catch (error) {
Expand Down
37 changes: 24 additions & 13 deletions apps/cow-fi/app/api/revalidate/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import { revalidateTag, revalidatePath } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

import { normalizeRevalidateRequest } from '../../../util/cmsValidation'

// Secret key for protecting the revalidation endpoint
const REVALIDATE_SECRET = process.env.REVALIDATE_SECRET

export async function GET(request: NextRequest): Promise<NextResponse> {
const secret = request.nextUrl.searchParams.get('secret')
const tag = request.nextUrl.searchParams.get('tag') || 'cms-content'
const path = request.nextUrl.searchParams.get('path')
function getSecretFromHeaders(request: NextRequest): string | null {
const authorization = request.headers.get('authorization')

if (authorization?.startsWith('Bearer ')) {
return authorization.slice('Bearer '.length)
}

return request.headers.get('x-revalidate-secret')
}
Comment on lines +9 to +17

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recall where this is coming from (invalidating cache manually and so forth). But isn't there a middleware or similar thing built-in in Nextjs for handling authentication?
I don't want to over complicate this, though. Only if it makes sense and it's a small change.


export async function GET(): Promise<NextResponse> {
return NextResponse.json({ message: 'Use POST for revalidation requests' }, { status: 405 })
}

export async function POST(request: NextRequest): Promise<NextResponse> {
const secret = getSecretFromHeaders(request)

// Validate that the secret is configured
if (!REVALIDATE_SECRET) {
Expand All @@ -21,6 +35,9 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
}

try {
const requestBody: unknown = await request.json()
const { path, tag } = normalizeRevalidateRequest(requestBody)

// Revalidate the tag for data freshness
revalidateTag(tag)

Expand All @@ -35,21 +52,15 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
revalidatePath('/learn/[article]')

// If a specific path was provided, revalidate it to update the route manifest
if (path) {
// Ensure the incoming path starts with a slash
const formattedPath = path.startsWith('/') ? path : `/${path}`

// Revalidate the specific article page (e.g. /learn/my-new-article)
revalidatePath(formattedPath)
}
if (path) revalidatePath(path)

return NextResponse.json({
revalidated: true,
message: `Cache for tag '${tag}' has been revalidated${path ? `, path '${path}' has been revalidated` : ''}`,
date: new Date().toISOString(),
})
} catch (err) {
// If there was an error, return 500
return NextResponse.json({ message: 'Error revalidating', error: (err as Error).message }, { status: 500 })
const errorMessage = err instanceof Error ? err.message : 'Unknown revalidation error'
return NextResponse.json({ message: 'Error revalidating', error: errorMessage }, { status: 400 })
}
}
64 changes: 33 additions & 31 deletions apps/cow-fi/services/cms/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { components } from '@cowprotocol/cms'
import { getCmsClient } from '@cowprotocol/core'

import qs from 'qs'
import { PaginationParam } from 'types'

import { isValidCmsSlug, normalizeSearchArticlesInput } from 'util/cmsValidation'
import { toQueryParams } from 'util/queryParams'

import { DEFAULT_PAGE_SIZE, clientAddons } from './config'
Expand Down Expand Up @@ -183,38 +183,44 @@ export async function searchArticles({
page?: number
pageSize?: number
}): Promise<ArticleListResponse> {
const trimmedSearchTerm = searchTerm.trim()
const {
searchTerm: trimmedSearchTerm,
page: normalizedPage,
pageSize: normalizedPageSize,
} = normalizeSearchArticlesInput({ searchTerm, page, pageSize })

if (!trimmedSearchTerm) {
return { data: [], meta: { pagination: { page, pageSize, pageCount: 0, total: 0 } } }
return {
data: [],
meta: { pagination: { page: normalizedPage, pageSize: normalizedPageSize, pageCount: 0, total: 0 } },
}
}

try {
// Build query parameters with explicit array indices
const queryParams = {
'filters[$or][0][title][$startsWithi]': trimmedSearchTerm,
'filters[$or][1][title][$containsi]': trimmedSearchTerm,
'filters[$or][2][description][$containsi]': trimmedSearchTerm,
'pagination[page]': page,
'pagination[pageSize]': pageSize,
'sort[0]': 'title:asc',
'populate[0]': 'cover',
'populate[1]': 'blocks',
'populate[2]': 'seo',
'populate[3]': 'authorsBio',
filters: {
$or: [
{ title: { $startsWithi: trimmedSearchTerm } },
{ title: { $containsi: trimmedSearchTerm } },
{ description: { $containsi: trimmedSearchTerm } },
],
},
pagination: {
page: normalizedPage,
pageSize: normalizedPageSize,
},
sort: ['title:asc'],
populate: ['cover', 'blocks', 'seo', 'authorsBio'],
publicationState: 'live', // Ensure published content
}

// Manual query string construction for absolute clarity
const queryString = qs.stringify(queryParams, {
encodeValuesOnly: true,
arrayFormat: 'brackets',
encode: false,
const { data, error, response } = await client.GET('/articles', {
params: {
query: toQueryParams(queryParams),
},
...clientAddons,
})

const url = `/articles?${queryString}`
const { data, error, response } = await client.GET(url, clientAddons)

if (error) {
console.error(`Search failed (${response.status}):`, error)
throw new Error(`Search failed: ${error.message}`)
Expand Down Expand Up @@ -285,6 +291,7 @@ async function getBySlugAux(slug: string, endpoint: '/pages'): Promise<Page | nu

async function getBySlugAux(slug: string, endpoint: '/categories' | '/articles' | '/pages'): Promise<unknown | null> {
if (!slug) throw new Error('Slug is required') // Fail fast - no silent failures per CMS architecture
if (!isValidCmsSlug(slug)) return null

try {
const entity = endpoint.slice(1, -1)
Expand All @@ -296,15 +303,10 @@ async function getBySlugAux(slug: string, endpoint: '/categories' | '/articles'
populate,
}

const queryString = endpoint === '/pages' ? qs.stringify(queryParams, { encodeValuesOnly: true }) : null

const { data, error } =
endpoint === '/pages'
? await client.GET(`${endpoint}?${queryString}`, clientAddons)
: await client.GET(endpoint, {
params: { query: toQueryParams(queryParams) },
...clientAddons,
})
const { data, error } = await client.GET(endpoint, {
params: { query: toQueryParams(queryParams) },
...clientAddons,
})

if (error) {
console.error(`Error getting slug ${slug} for ${entity}`, error)
Expand Down
110 changes: 110 additions & 0 deletions apps/cow-fi/util/cmsValidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {
CMS_REVALIDATE_TAG,
isAllowedRevalidatePath,
isValidCmsSlug,
normalizeRevalidateRequest,
normalizeSearchArticlesInput,
} from './cmsValidation'

describe('cmsValidation', () => {
describe('isValidCmsSlug', () => {
it('accepts canonical lowercase hyphenated slugs', () => {
expect(isValidCmsSlug('aave-trade-breakdown')).toBe(true)
expect(isValidCmsSlug('ens')).toBe(true)
})

it('rejects malformed or delimiter-based slugs', () => {
expect(isValidCmsSlug('')).toBe(false)
expect(isValidCmsSlug('Aave')).toBe(false)
expect(isValidCmsSlug('../preview')).toBe(false)
expect(isValidCmsSlug('aave&publicationState=preview')).toBe(false)
})
})

describe('normalizeSearchArticlesInput', () => {
it('trims valid input and clamps pagination to safe limits', () => {
expect(
normalizeSearchArticlesInput({
searchTerm: ' cowswap ',
page: 500,
pageSize: 1_000,
}),
).toEqual({
searchTerm: 'cowswap',
page: 100,
pageSize: 100,
})
})

it('returns an empty search safely', () => {
expect(
normalizeSearchArticlesInput({
searchTerm: ' ',
}),
).toEqual({
searchTerm: '',
page: 0,
pageSize: 10,
})
})

it('rejects invalid pagination even for blank searches', () => {
expect(() => normalizeSearchArticlesInput({ searchTerm: ' ', page: -1 })).toThrow(
'Pagination parameters must be non-negative integers',
)
expect(() => normalizeSearchArticlesInput({ searchTerm: ' ', page: 'oops' })).toThrow(
'Pagination parameters must be non-negative integers',
)
})

it('rejects invalid search payloads', () => {
expect(() => normalizeSearchArticlesInput('cowswap')).toThrow('Search input must be an object')
expect(() => normalizeSearchArticlesInput({ searchTerm: 1 })).toThrow('Search term must be a string')
expect(() => normalizeSearchArticlesInput({ searchTerm: 'cow', page: -1 })).toThrow(
'Pagination parameters must be non-negative integers',
)
expect(() => normalizeSearchArticlesInput({ searchTerm: 'a'.repeat(101) })).toThrow(
'Search term must be at most 100 characters',
)
})
})

describe('revalidation allowlist', () => {
it('accepts only approved learn paths', () => {
expect(isAllowedRevalidatePath('/learn')).toBe(true)
expect(isAllowedRevalidatePath('/learn/articles')).toBe(true)
expect(isAllowedRevalidatePath('/learn/articles/2')).toBe(true)
expect(isAllowedRevalidatePath('/learn/topic/amm')).toBe(true)
expect(isAllowedRevalidatePath('/learn/aave-trade-breakdown')).toBe(true)
})

it('rejects paths outside the learn allowlist', () => {
expect(isAllowedRevalidatePath('/')).toBe(false)
expect(isAllowedRevalidatePath('/api/revalidate')).toBe(false)
expect(isAllowedRevalidatePath('/learn//double-slash')).toBe(false)
expect(isAllowedRevalidatePath('/learn/topic/Bad-Slug')).toBe(false)
})

it('normalizes and validates revalidation payloads', () => {
expect(
normalizeRevalidateRequest({
tag: CMS_REVALIDATE_TAG,
path: 'learn/topic/amm',
}),
).toEqual({
tag: CMS_REVALIDATE_TAG,
path: '/learn/topic/amm',
})
})

it('rejects invalid revalidation payloads', () => {
expect(() => normalizeRevalidateRequest(null)).toThrow('Revalidation body must be an object')
expect(() => normalizeRevalidateRequest([])).toThrow('Revalidation body must be an object')
expect(() => normalizeRevalidateRequest(new Date())).toThrow('Revalidation body must be an object')
expect(() => normalizeRevalidateRequest({ tag: 'preview' })).toThrow('Unsupported revalidation tag "preview"')
expect(() => normalizeRevalidateRequest({ tag: CMS_REVALIDATE_TAG, path: '/admin' })).toThrow(
'Unsupported revalidation path "/admin"',
)
})
})
})
Loading
Loading