feat: add dedicated News API with tenant isolation and security features

- /api/news: List endpoint with filtering by type, category, featured, search, date
- /api/news/[slug]: Detail endpoint with related posts and navigation
- Required tenant ID for strict tenant isolation (security fix)
- Related posts filtered by same type AND category for relevance
- Navigation (prev/next) filtered by same type
- Archive with pagination (500/page, max 10k posts) instead of hard limit
- Rate limiting, IP blocking, caching headers included

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2025-12-12 22:24:08 +00:00
parent da735cab46
commit aa1f8b1054
2 changed files with 683 additions and 0 deletions

View file

@ -0,0 +1,247 @@
// src/app/(frontend)/api/news/[slug]/route.ts
// Single news article detail with related posts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import type { Category, Media, Post } from '@/payload-types'
import {
searchLimiter,
rateLimitHeaders,
getClientIpFromRequest,
isIpBlocked,
} from '@/lib/security'
const NEWS_RATE_LIMIT = 30
const RELATED_POSTS_LIMIT = 4
interface RouteParams {
params: Promise<{ slug: string }>
}
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
// IP-Blocklist prüfen
const ip = getClientIpFromRequest(request)
if (isIpBlocked(ip)) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Rate limiting
const rateLimit = await searchLimiter.check(ip)
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429, headers: rateLimitHeaders(rateLimit, NEWS_RATE_LIMIT) }
)
}
// Get slug from params
const { slug } = await params
if (!slug) {
return NextResponse.json({ error: 'Slug is required' }, { status: 400 })
}
// Parse query parameters
const { searchParams } = new URL(request.url)
const tenantParam = searchParams.get('tenant')
const localeParam = searchParams.get('locale')?.trim()
const includeRelated = searchParams.get('includeRelated') !== 'false' // Default true
// Validate locale
const validLocales = ['de', 'en']
const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de'
// Parse tenant ID - REQUIRED for tenant isolation
const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined
if (!tenantParam) {
return NextResponse.json(
{ error: 'Tenant ID is required. Use ?tenant=<id> to specify the tenant.' },
{ status: 400 }
)
}
if (isNaN(tenantId!) || tenantId! < 1) {
return NextResponse.json({ error: 'Invalid tenant ID' }, { status: 400 })
}
// Get payload instance
const payload = await getPayload({ config })
// Build where clause (tenant is now required)
const where: Record<string, unknown> = {
slug: { equals: slug },
status: { equals: 'published' },
tenant: { equals: tenantId },
}
// Find the post
const result = await payload.find({
collection: 'posts',
where,
limit: 1,
locale,
depth: 2,
})
if (result.docs.length === 0) {
return NextResponse.json(
{ error: 'Article not found' },
{ status: 404 }
)
}
const post = result.docs[0] as Post
const featuredImage = post.featuredImage as Media | null
const ogImage = post.seo?.ogImage as Media | null
const categories = (post.categories || []) as Category[]
// Transform the main article
const article = {
id: post.id,
title: post.title,
slug: post.slug,
type: post.type || 'blog',
excerpt: post.excerpt || null,
content: post.content, // Full rich text content
author: post.author || null,
publishedAt: post.publishedAt || null,
isFeatured: post.isFeatured || false,
featuredImage: featuredImage
? {
url: featuredImage.url,
alt: featuredImage.alt || post.title,
width: featuredImage.width,
height: featuredImage.height,
sizes: featuredImage.sizes || null,
}
: null,
categories: categories
.filter((cat): cat is Category => cat !== null && typeof cat === 'object' && 'name' in cat)
.map((cat) => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
})),
seo: {
metaTitle: post.seo?.metaTitle || post.title,
metaDescription: post.seo?.metaDescription || post.excerpt || null,
ogImage: ogImage
? {
url: ogImage.url,
width: ogImage.width,
height: ogImage.height,
}
: featuredImage
? {
url: featuredImage.url,
width: featuredImage.width,
height: featuredImage.height,
}
: null,
},
}
// Build response
const response: Record<string, unknown> = {
article,
locale,
}
// Get related posts if requested (filtered by same type AND category for relevance)
if (includeRelated && categories.length > 0) {
const categoryIds = categories
.filter((cat): cat is Category => cat !== null && typeof cat === 'object' && 'id' in cat)
.map((cat) => cat.id)
const relatedResult = await payload.find({
collection: 'posts',
where: {
id: { not_equals: post.id },
status: { equals: 'published' },
type: { equals: post.type || 'blog' }, // Same type for better relevance
categories: { in: categoryIds },
tenant: { equals: tenantId }, // Tenant is now required
},
sort: '-publishedAt',
limit: RELATED_POSTS_LIMIT,
locale,
depth: 1,
})
response.relatedPosts = relatedResult.docs.map((relatedPost: Post) => {
const relatedImage = relatedPost.featuredImage as Media | null
return {
id: relatedPost.id,
title: relatedPost.title,
slug: relatedPost.slug,
type: relatedPost.type || 'blog',
excerpt: relatedPost.excerpt || null,
publishedAt: relatedPost.publishedAt || null,
featuredImage: relatedImage
? {
url: relatedImage.url,
alt: relatedImage.alt || relatedPost.title,
width: relatedImage.width,
height: relatedImage.height,
}
: null,
}
})
}
// Get previous and next posts for navigation (same tenant, same type for consistency)
const [prevResult, nextResult] = await Promise.all([
payload.find({
collection: 'posts',
where: {
status: { equals: 'published' },
type: { equals: post.type || 'blog' },
tenant: { equals: tenantId },
publishedAt: { less_than: post.publishedAt || new Date().toISOString() },
},
sort: '-publishedAt',
limit: 1,
locale,
depth: 0,
}),
payload.find({
collection: 'posts',
where: {
status: { equals: 'published' },
type: { equals: post.type || 'blog' },
tenant: { equals: tenantId },
publishedAt: { greater_than: post.publishedAt || new Date().toISOString() },
},
sort: 'publishedAt',
limit: 1,
locale,
depth: 0,
}),
])
response.navigation = {
previous: prevResult.docs[0]
? {
title: prevResult.docs[0].title,
slug: prevResult.docs[0].slug,
}
: null,
next: nextResult.docs[0]
? {
title: nextResult.docs[0].title,
slug: nextResult.docs[0].slug,
}
: null,
}
return NextResponse.json(response, {
headers: {
...rateLimitHeaders(rateLimit, NEWS_RATE_LIMIT),
'Cache-Control': 'public, max-age=60, s-maxage=300',
},
})
} catch (error) {
console.error('[News Detail API] Error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,436 @@
// src/app/(frontend)/api/news/route.ts
// Dedizierte News-API mit erweiterten Features für Frontend-News-Seiten
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import type { Category, Media, Post } from '@/payload-types'
import {
searchLimiter,
rateLimitHeaders,
getClientIpFromRequest,
isIpBlocked,
} from '@/lib/security'
// Validation constants
const MAX_LIMIT = 50
const DEFAULT_LIMIT = 12
const NEWS_RATE_LIMIT = 30
// Valid post types for news
const NEWS_TYPES = ['news', 'press', 'announcement', 'blog'] as const
type NewsType = (typeof NEWS_TYPES)[number]
interface NewsQueryParams {
tenantId?: number
type?: NewsType | NewsType[]
categorySlug?: string
featured?: boolean
search?: string
year?: number
month?: number
locale: string
page: number
limit: number
excludeIds?: number[]
}
async function getNews(payload: Awaited<ReturnType<typeof getPayload>>, params: NewsQueryParams) {
const {
tenantId,
type,
categorySlug,
featured,
search,
year,
month,
locale,
page,
limit,
excludeIds,
} = params
// Build where clause
const where: Record<string, unknown> = {
status: { equals: 'published' },
}
// Tenant filter
if (tenantId) {
where.tenant = { equals: tenantId }
}
// Type filter (single or multiple)
if (type) {
if (Array.isArray(type)) {
where.type = { in: type }
} else {
where.type = { equals: type }
}
}
// Featured filter
if (featured !== undefined) {
where.isFeatured = { equals: featured }
}
// Search filter
if (search) {
where.or = [
{ title: { contains: search } },
{ excerpt: { contains: search } },
]
}
// Date filters
if (year) {
const startDate = new Date(year, month ? month - 1 : 0, 1)
const endDate = month
? new Date(year, month, 0, 23, 59, 59) // Last day of month
: new Date(year, 11, 31, 23, 59, 59) // Last day of year
where.publishedAt = {
greater_than_equal: startDate.toISOString(),
less_than_equal: endDate.toISOString(),
}
}
// Exclude specific IDs (useful for "more articles" sections)
if (excludeIds && excludeIds.length > 0) {
where.id = { not_in: excludeIds }
}
// Category filter via join
let categoryId: number | undefined
if (categorySlug) {
const categoryResult = await payload.find({
collection: 'categories',
where: {
slug: { equals: categorySlug },
...(tenantId ? { tenant: { equals: tenantId } } : {}),
},
limit: 1,
locale,
})
if (categoryResult.docs.length > 0) {
categoryId = categoryResult.docs[0].id
where.categories = { contains: categoryId }
} else {
// Category not found - return empty result
return {
docs: [],
page: 1,
totalPages: 0,
totalDocs: 0,
hasNextPage: false,
hasPrevPage: false,
}
}
}
// Execute query
return payload.find({
collection: 'posts',
where,
sort: '-publishedAt',
page,
limit,
locale,
depth: 2, // Load relations (categories, media)
})
}
// Helper to get available categories for a tenant
async function getCategories(
payload: Awaited<ReturnType<typeof getPayload>>,
tenantId?: number,
locale: string = 'de'
) {
const where: Record<string, unknown> = {}
if (tenantId) {
where.tenant = { equals: tenantId }
}
const result = await payload.find({
collection: 'categories',
where,
sort: 'name',
limit: 100,
locale,
})
return result.docs.map((cat) => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
}))
}
// Helper to get archive data (years/months with post counts)
// Uses pagination to handle large datasets without memory issues
async function getArchive(
payload: Awaited<ReturnType<typeof getPayload>>,
tenantId: number // Now required
) {
const where: Record<string, unknown> = {
status: { equals: 'published' },
publishedAt: { exists: true },
tenant: { equals: tenantId },
}
// Group by year and month using pagination
const archive: Record<number, Record<number, number>> = {}
let page = 1
const pageSize = 500
let hasMore = true
while (hasMore) {
const result = await payload.find({
collection: 'posts',
where,
sort: '-publishedAt',
page,
limit: pageSize,
depth: 0,
})
for (const post of result.docs) {
if (post.publishedAt) {
const date = new Date(post.publishedAt)
const year = date.getFullYear()
const month = date.getMonth() + 1
if (!archive[year]) {
archive[year] = {}
}
archive[year][month] = (archive[year][month] || 0) + 1
}
}
hasMore = result.hasNextPage
page++
// Safety limit: max 20 pages (10,000 posts)
if (page > 20) break
}
// Transform to sorted array
return Object.entries(archive)
.sort(([a], [b]) => Number(b) - Number(a))
.map(([year, months]) => ({
year: Number(year),
months: Object.entries(months)
.sort(([a], [b]) => Number(b) - Number(a))
.map(([month, count]) => ({
month: Number(month),
count,
})),
total: Object.values(months).reduce((sum, count) => sum + count, 0),
}))
}
export async function GET(request: NextRequest) {
try {
// IP-Blocklist prüfen
const ip = getClientIpFromRequest(request)
if (isIpBlocked(ip)) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Rate limiting
const rateLimit = await searchLimiter.check(ip)
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429, headers: rateLimitHeaders(rateLimit, NEWS_RATE_LIMIT) }
)
}
// Parse query parameters
const { searchParams } = new URL(request.url)
// Basic params
const tenantParam = searchParams.get('tenant')
const pageParam = searchParams.get('page')
const limitParam = searchParams.get('limit')
const localeParam = searchParams.get('locale')?.trim()
// Filter params
const typeParam = searchParams.get('type')?.trim()
const typesParam = searchParams.get('types')?.trim() // Comma-separated for multiple
const categoryParam = searchParams.get('category')?.trim()
const featuredParam = searchParams.get('featured')
const searchParam = searchParams.get('search')?.trim()
const yearParam = searchParams.get('year')
const monthParam = searchParams.get('month')
const excludeParam = searchParams.get('exclude')?.trim() // Comma-separated IDs
// Meta params
const includeCategories = searchParams.get('includeCategories') === 'true'
const includeArchive = searchParams.get('includeArchive') === 'true'
// Validate locale
const validLocales = ['de', 'en']
const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de'
// Validate and parse types
let types: NewsType | NewsType[] | undefined
if (typesParam) {
const typeList = typesParam.split(',').map((t) => t.trim()) as NewsType[]
const invalidTypes = typeList.filter((t) => !NEWS_TYPES.includes(t))
if (invalidTypes.length > 0) {
return NextResponse.json(
{ error: `Invalid types: ${invalidTypes.join(', ')}. Valid: ${NEWS_TYPES.join(', ')}` },
{ status: 400 }
)
}
types = typeList
} else if (typeParam) {
if (!NEWS_TYPES.includes(typeParam as NewsType)) {
return NextResponse.json(
{ error: `Invalid type. Must be one of: ${NEWS_TYPES.join(', ')}` },
{ status: 400 }
)
}
types = typeParam as NewsType
}
// Parse numeric parameters
const page = Math.max(1, parseInt(pageParam || '1', 10) || 1)
const limit = Math.min(
Math.max(1, parseInt(limitParam || String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT),
MAX_LIMIT
)
const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined
const year = yearParam ? parseInt(yearParam, 10) : undefined
const month = monthParam ? parseInt(monthParam, 10) : undefined
// Validate tenant ID - REQUIRED for tenant isolation
if (!tenantParam) {
return NextResponse.json(
{ error: 'Tenant ID is required. Use ?tenant=<id> to specify the tenant.' },
{ status: 400 }
)
}
if (isNaN(tenantId!) || tenantId! < 1) {
return NextResponse.json({ error: 'Invalid tenant ID' }, { status: 400 })
}
// Validate year/month
if (year && (year < 2000 || year > 2100)) {
return NextResponse.json({ error: 'Invalid year' }, { status: 400 })
}
if (month && (month < 1 || month > 12)) {
return NextResponse.json({ error: 'Invalid month (1-12)' }, { status: 400 })
}
// Parse featured
const featured = featuredParam === 'true' ? true : featuredParam === 'false' ? false : undefined
// Parse exclude IDs
const excludeIds = excludeParam
? excludeParam.split(',').map((id) => parseInt(id.trim(), 10)).filter((id) => !isNaN(id))
: undefined
// Get payload instance
const payload = await getPayload({ config })
// Execute main query
const result = await getNews(payload, {
tenantId,
type: types,
categorySlug: categoryParam,
featured,
search: searchParam,
year,
month,
locale,
page,
limit,
excludeIds,
})
// Transform posts
const transformedDocs = result.docs.map((post: Post) => {
const featuredImage = post.featuredImage as Media | null
const categories = (post.categories || []) as Category[]
return {
id: post.id,
title: post.title,
slug: post.slug,
type: post.type || 'blog',
excerpt: post.excerpt || null,
author: post.author || null,
publishedAt: post.publishedAt || null,
isFeatured: post.isFeatured || false,
featuredImage: featuredImage
? {
url: featuredImage.url,
alt: featuredImage.alt || post.title,
width: featuredImage.width,
height: featuredImage.height,
// Responsive sizes for frontend
sizes: featuredImage.sizes || null,
}
: null,
categories: categories
.filter((cat): cat is Category => cat !== null && typeof cat === 'object' && 'name' in cat)
.map((cat) => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
})),
seo: post.seo
? {
metaTitle: post.seo.metaTitle || post.title,
metaDescription: post.seo.metaDescription || post.excerpt || null,
}
: null,
}
})
// Build response
const response: Record<string, unknown> = {
docs: transformedDocs,
pagination: {
page: result.page,
limit,
totalPages: result.totalPages,
totalDocs: result.totalDocs,
hasNextPage: result.hasNextPage,
hasPrevPage: result.hasPrevPage,
},
filters: {
type: types,
category: categoryParam,
featured,
search: searchParam,
year,
month,
locale,
tenant: tenantId,
},
}
// Include categories if requested
if (includeCategories) {
response.categories = await getCategories(payload, tenantId, locale)
}
// Include archive if requested
if (includeArchive) {
response.archive = await getArchive(payload, tenantId!)
}
return NextResponse.json(response, {
headers: {
...rateLimitHeaders(rateLimit, NEWS_RATE_LIMIT),
'Cache-Control': 'public, max-age=60, s-maxage=120',
},
})
} catch (error) {
console.error('[News API] Error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}