From aa1f8b10544534d922577907593c03f0093e8511 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Fri, 12 Dec 2025 22:24:08 +0000 Subject: [PATCH] feat: add dedicated News API with tenant isolation and security features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- src/app/(frontend)/api/news/[slug]/route.ts | 247 +++++++++++ src/app/(frontend)/api/news/route.ts | 436 ++++++++++++++++++++ 2 files changed, 683 insertions(+) create mode 100644 src/app/(frontend)/api/news/[slug]/route.ts create mode 100644 src/app/(frontend)/api/news/route.ts diff --git a/src/app/(frontend)/api/news/[slug]/route.ts b/src/app/(frontend)/api/news/[slug]/route.ts new file mode 100644 index 0000000..85d8054 --- /dev/null +++ b/src/app/(frontend)/api/news/[slug]/route.ts @@ -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= 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 = { + 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 = { + 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 }) + } +} diff --git a/src/app/(frontend)/api/news/route.ts b/src/app/(frontend)/api/news/route.ts new file mode 100644 index 0000000..5cafb18 --- /dev/null +++ b/src/app/(frontend)/api/news/route.ts @@ -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>, params: NewsQueryParams) { + const { + tenantId, + type, + categorySlug, + featured, + search, + year, + month, + locale, + page, + limit, + excludeIds, + } = params + + // Build where clause + const where: Record = { + 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>, + tenantId?: number, + locale: string = 'de' +) { + const where: Record = {} + 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>, + tenantId: number // Now required +) { + const where: Record = { + status: { equals: 'published' }, + publishedAt: { exists: true }, + tenant: { equals: tenantId }, + } + + // Group by year and month using pagination + const archive: Record> = {} + 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= 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 = { + 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 }) + } +}