cms.c2sgmbh/src/app/(frontend)/api/news/[slug]/route.ts
Martin Porwoll aa1f8b1054 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>
2025-12-12 22:24:08 +00:00

247 lines
7.4 KiB
TypeScript

// 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 })
}
}