mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 15:04:14 +00:00
security: harden payload endpoints and access controls
This commit is contained in:
parent
01a0a43f39
commit
063dae411c
31 changed files with 507 additions and 10210 deletions
16
.env.example
16
.env.example
|
|
@ -21,6 +21,11 @@ REDIS_URL=redis://localhost:6379
|
|||
# Security
|
||||
CSRF_SECRET=your-csrf-secret
|
||||
TRUST_PROXY=true
|
||||
BLOCKED_IPS=
|
||||
SEND_EMAIL_ALLOWED_IPS=
|
||||
GENERATE_PDF_ALLOWED_IPS=
|
||||
ADMIN_ALLOWED_IPS=
|
||||
WEBHOOK_ALLOWED_IPS=
|
||||
|
||||
# YouTube OAuth (optional)
|
||||
GOOGLE_CLIENT_ID=your-client-id
|
||||
|
|
@ -32,8 +37,17 @@ META_APP_ID=your-app-id
|
|||
META_APP_SECRET=your-app-secret
|
||||
META_REDIRECT_URI=http://localhost:3000/api/auth/meta/callback
|
||||
|
||||
# Cron Jobs (optional)
|
||||
# Cron Jobs (required in production)
|
||||
CRON_SECRET=your-64-char-hex
|
||||
|
||||
# PDF Security
|
||||
PDF_ALLOWED_HOSTS=example.com,.example.com
|
||||
# Nur in non-production und nur falls zwingend notwendig aktivieren:
|
||||
PDF_ALLOW_HTTP_URLS=false
|
||||
|
||||
# Scheduler
|
||||
# In Production standardmäßig deaktiviert, um Doppel-Ausführungen in Multi-Instance-Deployments zu vermeiden
|
||||
ENABLE_IN_PROCESS_SCHEDULER=false
|
||||
|
||||
# Tests
|
||||
EMAIL_DELIVERY_DISABLED=false
|
||||
|
|
|
|||
9855
backup.sql
9855
backup.sql
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
# Security-Richtlinien - Payload CMS Multi-Tenant
|
||||
|
||||
> Letzte Aktualisierung: 29.12.2025
|
||||
> Letzte Aktualisierung: 17.02.2026
|
||||
|
||||
## Übersicht
|
||||
|
||||
|
|
@ -25,6 +25,7 @@ Alle Security-Funktionen befinden sich in `src/lib/security/`:
|
|||
| IP Allowlist | `ip-allowlist.ts` | IP-basierte Zugriffskontrolle |
|
||||
| CSRF Protection | `csrf.ts` | Cross-Site Request Forgery Schutz |
|
||||
| Data Masking | `data-masking.ts` | Sensitive Daten in Logs maskieren |
|
||||
| Cron Auth | `cron-auth.ts` | Verbindliche Authentifizierung für Cron-Endpunkte |
|
||||
|
||||
### Rate Limiter
|
||||
|
||||
|
|
@ -86,6 +87,7 @@ export async function GET(req: NextRequest) {
|
|||
| `TRUST_PROXY` | Proxy-Header vertrauen | `true` oder leer |
|
||||
| `BLOCKED_IPS` | Globale Blocklist | IP, CIDR, Wildcard |
|
||||
| `SEND_EMAIL_ALLOWED_IPS` | E-Mail-Endpoint Allowlist | IP, CIDR, Wildcard |
|
||||
| `GENERATE_PDF_ALLOWED_IPS` | PDF-Endpoint Allowlist | IP, CIDR, Wildcard |
|
||||
| `ADMIN_ALLOWED_IPS` | Admin-Panel Allowlist | IP, CIDR, Wildcard |
|
||||
| `WEBHOOK_ALLOWED_IPS` | Webhook-Endpoint Allowlist | IP, CIDR, Wildcard |
|
||||
|
||||
|
|
@ -173,6 +175,32 @@ if (!csrf.valid) {
|
|||
- Requests ohne `Origin`/`Referer` Header werden als Server-to-Server behandelt
|
||||
- API-Key-basierte Authentifizierung umgeht CSRF-Check
|
||||
|
||||
### Cron Endpoint Auth
|
||||
|
||||
Alle `/api/cron/*` Endpunkte sind fail-closed abgesichert:
|
||||
|
||||
- Ohne gültigen `Authorization: Bearer <CRON_SECRET>` Header: `401`
|
||||
- Ohne gesetztes `CRON_SECRET`: `503`
|
||||
- Gilt für `GET`, `POST` und `HEAD`
|
||||
|
||||
In Production wird `CRON_SECRET` beim Startup validiert.
|
||||
|
||||
### PDF URL Hardening (SSRF-Schutz)
|
||||
|
||||
PDF-Generierung per URL (`/api/generate-pdf`) validiert jetzt strikt:
|
||||
|
||||
- Nur `https://` URLs (Ausnahme: `PDF_ALLOW_HTTP_URLS=true` nur non-production)
|
||||
- Keine URL-Credentials (`user:pass@host`)
|
||||
- Keine localhost/private/loopback Ziele
|
||||
- DNS-Auflösung wird geprüft (Rebinding-Schutz gegen private Zieladressen)
|
||||
- Optionaler Host-Allowlist-Modus via `PDF_ALLOWED_HOSTS`
|
||||
|
||||
Beispiel:
|
||||
```bash
|
||||
PDF_ALLOWED_HOSTS=example.com,.example.com
|
||||
PDF_ALLOW_HTTP_URLS=false
|
||||
```
|
||||
|
||||
### Data Masking
|
||||
|
||||
**Automatisch maskierte Felder:**
|
||||
|
|
@ -275,9 +303,13 @@ pnpm test:unit
|
|||
|
||||
- [ ] **`TRUST_PROXY=true`** setzen (Pflicht hinter Reverse-Proxy wie Caddy)
|
||||
- [ ] **`CSRF_SECRET`** oder **`PAYLOAD_SECRET`** setzen (Server startet nicht ohne)
|
||||
- [ ] **`CRON_SECRET`** setzen (Pflicht für Cron-Endpunkte in Production)
|
||||
- [ ] Alle `BLOCKED_IPS` für bekannte Angreifer setzen
|
||||
- [ ] `SEND_EMAIL_ALLOWED_IPS` auf vertrauenswürdige IPs beschränken
|
||||
- [ ] `GENERATE_PDF_ALLOWED_IPS` auf vertrauenswürdige IPs beschränken
|
||||
- [ ] `ADMIN_ALLOWED_IPS` auf Office/VPN-IPs setzen
|
||||
- [ ] `PDF_ALLOWED_HOSTS` für erlaubte externe Render-Ziele konfigurieren
|
||||
- [ ] `ENABLE_IN_PROCESS_SCHEDULER` in Multi-Instance-Deployments nur gezielt aktivieren
|
||||
- [ ] Redis für verteiltes Rate Limiting konfigurieren
|
||||
- [ ] Pre-Commit Hook aktivieren
|
||||
|
||||
|
|
@ -329,6 +361,7 @@ email=admin@example.com&password=secret
|
|||
|
||||
| Datum | Änderung |
|
||||
|-------|----------|
|
||||
| 17.02.2026 | **Security-Hardening:** Users-Update fail-closed, `isSuperAdmin` field-protected, Cron-Auth fail-closed, PDF-SSRF-Schutz, Newsletter-Unsubscribe ohne ID-Enumeration |
|
||||
| 29.12.2025 | **Dokumentation aktualisiert:** Custom Login Page Abschnitt entfernt (wurde am 27.12.2025 entfernt) |
|
||||
| 17.12.2025 | **Security-Audit Fixes:** TRUST_PROXY für IP-Header-Spoofing, CSRF_SECRET Pflicht in Production, IP-Allowlist Startup-Warnungen, Tests auf 177 erweitert |
|
||||
| 09.12.2025 | Custom Login Route Dokumentation, multipart/form-data _payload Support |
|
||||
|
|
|
|||
|
|
@ -2,14 +2,6 @@ import { withPayload } from '@payloadcms/next/withPayload'
|
|||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Skip type checking during build (done separately in CI)
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
// Skip ESLint during build to save memory
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
// Reduce memory usage during build
|
||||
experimental: {
|
||||
// Use fewer workers for builds on low-memory systems
|
||||
|
|
|
|||
|
|
@ -12,22 +12,10 @@ import { getPayload } from 'payload'
|
|||
import config from '@payload-config'
|
||||
import { subDays, differenceInHours } from 'date-fns'
|
||||
import { createSafeLogger } from '@/lib/security'
|
||||
import { hasCommunityApiAccess } from '@/lib/communityAccess'
|
||||
|
||||
const logger = createSafeLogger('API:ChannelComparison')
|
||||
|
||||
interface UserWithCommunityAccess {
|
||||
id: number
|
||||
isSuperAdmin?: boolean
|
||||
is_super_admin?: boolean
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
|
||||
if (user.isSuperAdmin || user.is_super_admin) return true
|
||||
const communityRoles = ['community_manager', 'community_agent', 'admin']
|
||||
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
|
@ -41,7 +29,7 @@ export async function GET(request: NextRequest) {
|
|||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasCommunityAccess(user as UserWithCommunityAccess)) {
|
||||
if (!hasCommunityApiAccess(user as any, 'viewer')) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,22 +12,10 @@ import { getPayload } from 'payload'
|
|||
import config from '@payload-config'
|
||||
import { subDays, differenceInHours } from 'date-fns'
|
||||
import { createSafeLogger } from '@/lib/security'
|
||||
import { hasCommunityApiAccess } from '@/lib/communityAccess'
|
||||
|
||||
const logger = createSafeLogger('API:AnalyticsOverview')
|
||||
|
||||
interface UserWithCommunityAccess {
|
||||
id: number
|
||||
isSuperAdmin?: boolean
|
||||
is_super_admin?: boolean
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
|
||||
if (user.isSuperAdmin || user.is_super_admin) return true
|
||||
const communityRoles = ['community_manager', 'community_agent', 'admin']
|
||||
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
|
@ -42,7 +30,7 @@ export async function GET(request: NextRequest) {
|
|||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasCommunityAccess(user as UserWithCommunityAccess)) {
|
||||
if (!hasCommunityApiAccess(user as any, 'viewer')) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,22 +12,10 @@ import { getPayload } from 'payload'
|
|||
import config from '@payload-config'
|
||||
import { subDays, differenceInHours } from 'date-fns'
|
||||
import { createSafeLogger } from '@/lib/security'
|
||||
import { hasCommunityApiAccess } from '@/lib/communityAccess'
|
||||
|
||||
const logger = createSafeLogger('API:ResponseMetrics')
|
||||
|
||||
interface UserWithCommunityAccess {
|
||||
id: number
|
||||
isSuperAdmin?: boolean
|
||||
is_super_admin?: boolean
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
|
||||
if (user.isSuperAdmin || user.is_super_admin) return true
|
||||
const communityRoles = ['community_manager', 'community_agent', 'admin']
|
||||
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
|
||||
}
|
||||
|
||||
function median(arr: number[]): number {
|
||||
if (arr.length === 0) return 0
|
||||
const sorted = [...arr].sort((a, b) => a - b)
|
||||
|
|
@ -56,7 +44,7 @@ export async function GET(request: NextRequest) {
|
|||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasCommunityAccess(user as UserWithCommunityAccess)) {
|
||||
if (!hasCommunityApiAccess(user as any, 'viewer')) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,22 +12,10 @@ import { getPayload } from 'payload'
|
|||
import config from '@payload-config'
|
||||
import { subDays, format, eachDayOfInterval, eachWeekOfInterval } from 'date-fns'
|
||||
import { createSafeLogger } from '@/lib/security'
|
||||
import { hasCommunityApiAccess } from '@/lib/communityAccess'
|
||||
|
||||
const logger = createSafeLogger('API:SentimentTrend')
|
||||
|
||||
interface UserWithCommunityAccess {
|
||||
id: number
|
||||
isSuperAdmin?: boolean
|
||||
is_super_admin?: boolean
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
|
||||
if (user.isSuperAdmin || user.is_super_admin) return true
|
||||
const communityRoles = ['community_manager', 'community_agent', 'admin']
|
||||
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
|
@ -48,7 +36,7 @@ export async function GET(request: NextRequest) {
|
|||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasCommunityAccess(user as UserWithCommunityAccess)) {
|
||||
if (!hasCommunityApiAccess(user as any, 'viewer')) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,22 +12,10 @@ import { getPayload } from 'payload'
|
|||
import config from '@payload-config'
|
||||
import { subDays } from 'date-fns'
|
||||
import { createSafeLogger } from '@/lib/security'
|
||||
import { hasCommunityApiAccess } from '@/lib/communityAccess'
|
||||
|
||||
const logger = createSafeLogger('API:TopContent')
|
||||
|
||||
interface UserWithCommunityAccess {
|
||||
id: number
|
||||
isSuperAdmin?: boolean
|
||||
is_super_admin?: boolean
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
|
||||
if (user.isSuperAdmin || user.is_super_admin) return true
|
||||
const communityRoles = ['community_manager', 'community_agent', 'admin']
|
||||
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
|
@ -44,7 +32,7 @@ export async function GET(request: NextRequest) {
|
|||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasCommunityAccess(user as UserWithCommunityAccess)) {
|
||||
if (!hasCommunityApiAccess(user as any, 'viewer')) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,22 +12,10 @@ import { getPayload } from 'payload'
|
|||
import config from '@payload-config'
|
||||
import { subDays } from 'date-fns'
|
||||
import { createSafeLogger } from '@/lib/security'
|
||||
import { hasCommunityApiAccess } from '@/lib/communityAccess'
|
||||
|
||||
const logger = createSafeLogger('API:TopicCloud')
|
||||
|
||||
interface UserWithCommunityAccess {
|
||||
id: number
|
||||
isSuperAdmin?: boolean
|
||||
is_super_admin?: boolean
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
|
||||
if (user.isSuperAdmin || user.is_super_admin) return true
|
||||
const communityRoles = ['community_manager', 'community_agent', 'admin']
|
||||
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
|
@ -43,7 +31,7 @@ export async function GET(request: NextRequest) {
|
|||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasCommunityAccess(user as UserWithCommunityAccess)) {
|
||||
if (!hasCommunityApiAccess(user as any, 'viewer')) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,33 +12,11 @@ import { getPayload } from 'payload'
|
|||
import config from '@payload-config'
|
||||
import ExcelJS from 'exceljs'
|
||||
import PDFDocument from 'pdfkit'
|
||||
import { createSafeLogger, validateCsrf } from '@/lib/security'
|
||||
import { createSafeLogger } from '@/lib/security'
|
||||
import { hasCommunityApiAccess } from '@/lib/communityAccess'
|
||||
|
||||
const logger = createSafeLogger('API:CommunityExport')
|
||||
|
||||
interface UserWithCommunityAccess {
|
||||
id: number
|
||||
isSuperAdmin?: boolean
|
||||
is_super_admin?: boolean
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob User Community-Zugriff hat
|
||||
*/
|
||||
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
|
||||
if (user.isSuperAdmin || user.is_super_admin) {
|
||||
return true
|
||||
}
|
||||
|
||||
const communityRoles = ['community_manager', 'community_agent', 'admin']
|
||||
if (user.roles?.some((role) => communityRoles.includes(role))) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const searchParams = req.nextUrl.searchParams
|
||||
|
|
@ -60,10 +38,10 @@ export async function GET(req: NextRequest) {
|
|||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const typedUser = user as UserWithCommunityAccess
|
||||
const typedUser = user as { id: number }
|
||||
|
||||
// Community-Zugriff prüfen
|
||||
if (!hasCommunityAccess(typedUser)) {
|
||||
if (!hasCommunityApiAccess(user as any, 'viewer')) {
|
||||
logger.warn('Access denied for export', { userId: typedUser.id })
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,32 +10,10 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import { createSafeLogger } from '@/lib/security'
|
||||
import { hasCommunityApiAccess } from '@/lib/communityAccess'
|
||||
|
||||
const logger = createSafeLogger('API:CommunityStats')
|
||||
|
||||
interface UserWithCommunityAccess {
|
||||
id: number
|
||||
isSuperAdmin?: boolean
|
||||
is_super_admin?: boolean
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob User Community-Zugriff hat
|
||||
*/
|
||||
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
|
||||
if (user.isSuperAdmin || user.is_super_admin) {
|
||||
return true
|
||||
}
|
||||
|
||||
const communityRoles = ['community_manager', 'community_agent', 'admin']
|
||||
if (user.roles?.some((role) => communityRoles.includes(role))) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
|
|
@ -47,10 +25,10 @@ export async function GET(req: NextRequest) {
|
|||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const typedUser = user as UserWithCommunityAccess
|
||||
const typedUser = user as { id: number }
|
||||
|
||||
// Community-Zugriff prüfen
|
||||
if (!hasCommunityAccess(typedUser)) {
|
||||
if (!hasCommunityApiAccess(user as any, 'viewer')) {
|
||||
logger.warn('Access denied for stats', { userId: typedUser.id })
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@ import {
|
|||
type SupportedPlatform,
|
||||
type UnifiedSyncOptions,
|
||||
} from '@/lib/jobs/UnifiedSyncService'
|
||||
|
||||
// Geheimer Token für Cron-Authentifizierung
|
||||
const CRON_SECRET = process.env.CRON_SECRET
|
||||
import { requireCronAuth } from '@/lib/security'
|
||||
|
||||
/**
|
||||
* GET /api/cron/community-sync
|
||||
|
|
@ -23,14 +21,10 @@ const CRON_SECRET = process.env.CRON_SECRET
|
|||
* - maxItems: Maximale Items pro Account (default: 100)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
// Auth prüfen wenn CRON_SECRET gesetzt
|
||||
if (CRON_SECRET) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
||||
console.warn('[Cron] Unauthorized request to community-sync')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const authError = requireCronAuth(request)
|
||||
if (authError) {
|
||||
console.warn('[Cron] Unauthorized request to community-sync')
|
||||
return authError
|
||||
}
|
||||
|
||||
// Query-Parameter parsen
|
||||
|
|
@ -118,14 +112,10 @@ export async function GET(request: NextRequest) {
|
|||
* Manueller Sync-Trigger mit erweiterten Optionen
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
// Auth prüfen
|
||||
if (CRON_SECRET) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
||||
console.warn('[Cron] Unauthorized POST to community-sync')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const authError = requireCronAuth(request)
|
||||
if (authError) {
|
||||
console.warn('[Cron] Unauthorized POST to community-sync')
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -184,7 +174,12 @@ export async function POST(request: NextRequest) {
|
|||
* HEAD /api/cron/community-sync
|
||||
* Status-Check für Monitoring
|
||||
*/
|
||||
export async function HEAD() {
|
||||
export async function HEAD(request: NextRequest) {
|
||||
const authError = requireCronAuth(request)
|
||||
if (authError) {
|
||||
return new NextResponse(null, { status: authError.status })
|
||||
}
|
||||
|
||||
const status = getUnifiedSyncStatus()
|
||||
|
||||
return new NextResponse(null, {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import { ReportGeneratorService, ReportSchedule } from '@/lib/services/ReportGeneratorService'
|
||||
|
||||
// Geheimer Token für Cron-Authentifizierung
|
||||
const CRON_SECRET = process.env.CRON_SECRET
|
||||
import { requireCronAuth } from '@/lib/security'
|
||||
|
||||
// Status für Monitoring
|
||||
let isRunning = false
|
||||
|
|
@ -21,14 +19,10 @@ let lastResult: { success: boolean; sent: number; failed: number } | null = null
|
|||
* Läuft stündlich und prüft welche Reports gesendet werden müssen
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
// Auth prüfen wenn CRON_SECRET gesetzt
|
||||
if (CRON_SECRET) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
||||
console.warn('[Cron] Unauthorized request to send-reports')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const authError = requireCronAuth(request)
|
||||
if (authError) {
|
||||
console.warn('[Cron] Unauthorized request to send-reports')
|
||||
return authError
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
|
|
@ -119,14 +113,10 @@ export async function GET(request: NextRequest) {
|
|||
* Manuelles Senden eines bestimmten Reports
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
// Auth prüfen
|
||||
if (CRON_SECRET) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
||||
console.warn('[Cron] Unauthorized POST to send-reports')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const authError = requireCronAuth(request)
|
||||
if (authError) {
|
||||
console.warn('[Cron] Unauthorized POST to send-reports')
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -182,7 +172,12 @@ export async function POST(request: NextRequest) {
|
|||
* HEAD /api/cron/send-reports
|
||||
* Status-Check für Monitoring
|
||||
*/
|
||||
export async function HEAD() {
|
||||
export async function HEAD(request: NextRequest) {
|
||||
const authError = requireCronAuth(request)
|
||||
if (authError) {
|
||||
return new NextResponse(null, { status: authError.status })
|
||||
}
|
||||
|
||||
return new NextResponse(null, {
|
||||
status: isRunning ? 423 : 200,
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@ import {
|
|||
type TokenRefreshOptions,
|
||||
type TokenPlatform,
|
||||
} from '@/lib/jobs/TokenRefreshService'
|
||||
|
||||
// Geheimer Token für Cron-Authentifizierung
|
||||
const CRON_SECRET = process.env.CRON_SECRET
|
||||
import { requireCronAuth } from '@/lib/security'
|
||||
|
||||
/**
|
||||
* GET /api/cron/token-refresh
|
||||
|
|
@ -23,14 +21,10 @@ const CRON_SECRET = process.env.CRON_SECRET
|
|||
* - dryRun: true/false - nur prüfen, nicht erneuern
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
// Auth prüfen wenn CRON_SECRET gesetzt
|
||||
if (CRON_SECRET) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
||||
console.warn('[Cron] Unauthorized request to token-refresh')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const authError = requireCronAuth(request)
|
||||
if (authError) {
|
||||
console.warn('[Cron] Unauthorized request to token-refresh')
|
||||
return authError
|
||||
}
|
||||
|
||||
// Query-Parameter parsen
|
||||
|
|
@ -103,14 +97,10 @@ export async function GET(request: NextRequest) {
|
|||
* Manueller Token-Refresh mit erweiterten Optionen
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
// Auth prüfen
|
||||
if (CRON_SECRET) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
||||
console.warn('[Cron] Unauthorized POST to token-refresh')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const authError = requireCronAuth(request)
|
||||
if (authError) {
|
||||
console.warn('[Cron] Unauthorized POST to token-refresh')
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -166,7 +156,12 @@ export async function POST(request: NextRequest) {
|
|||
* HEAD /api/cron/token-refresh
|
||||
* Status-Check für Monitoring
|
||||
*/
|
||||
export async function HEAD() {
|
||||
export async function HEAD(request: NextRequest) {
|
||||
const authError = requireCronAuth(request)
|
||||
if (authError) {
|
||||
return new NextResponse(null, { status: authError.status })
|
||||
}
|
||||
|
||||
const status = getTokenRefreshStatus()
|
||||
|
||||
return new NextResponse(null, {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@ import { getPayload } from 'payload'
|
|||
import config from '@payload-config'
|
||||
|
||||
import { ChannelMetricsSyncService } from '@/lib/integrations/youtube/ChannelMetricsSyncService'
|
||||
|
||||
const CRON_SECRET = process.env.CRON_SECRET
|
||||
import { requireCronAuth } from '@/lib/security'
|
||||
|
||||
// Monitoring state
|
||||
let isRunning = false
|
||||
|
|
@ -19,13 +18,10 @@ let lastRunAt: Date | null = null
|
|||
* Scheduled daily at 04:00 UTC via Vercel Cron.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
if (CRON_SECRET) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
||||
console.warn('[Cron] Unauthorized request to youtube-channel-sync')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const authError = requireCronAuth(request)
|
||||
if (authError) {
|
||||
console.warn('[Cron] Unauthorized request to youtube-channel-sync')
|
||||
return authError
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
|
|
@ -73,7 +69,12 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|||
* HEAD /api/cron/youtube-channel-sync
|
||||
* Status check for monitoring.
|
||||
*/
|
||||
export async function HEAD(): Promise<NextResponse> {
|
||||
export async function HEAD(request: NextRequest): Promise<NextResponse> {
|
||||
const authError = requireCronAuth(request)
|
||||
if (authError) {
|
||||
return new NextResponse(null, { status: authError.status })
|
||||
}
|
||||
|
||||
return new NextResponse(null, {
|
||||
status: isRunning ? 423 : 200,
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@ import { getPayload } from 'payload'
|
|||
import config from '@payload-config'
|
||||
|
||||
import { VideoMetricsSyncService } from '@/lib/integrations/youtube/VideoMetricsSyncService'
|
||||
|
||||
const CRON_SECRET = process.env.CRON_SECRET
|
||||
import { requireCronAuth } from '@/lib/security'
|
||||
|
||||
// Monitoring state
|
||||
let isRunning = false
|
||||
|
|
@ -19,13 +18,10 @@ let lastRunAt: Date | null = null
|
|||
* Scheduled every 6 hours via Vercel Cron.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
if (CRON_SECRET) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
||||
console.warn('[Cron] Unauthorized request to youtube-metrics-sync')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const authError = requireCronAuth(request)
|
||||
if (authError) {
|
||||
console.warn('[Cron] Unauthorized request to youtube-metrics-sync')
|
||||
return authError
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
|
|
@ -102,7 +98,12 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|||
* HEAD /api/cron/youtube-metrics-sync
|
||||
* Status check for monitoring.
|
||||
*/
|
||||
export async function HEAD(): Promise<NextResponse> {
|
||||
export async function HEAD(request: NextRequest): Promise<NextResponse> {
|
||||
const authError = requireCronAuth(request)
|
||||
if (authError) {
|
||||
return new NextResponse(null, { status: authError.status })
|
||||
}
|
||||
|
||||
return new NextResponse(null, {
|
||||
status: isRunning ? 423 : 200,
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -2,23 +2,17 @@
|
|||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { runSync, getSyncStatus } from '@/lib/jobs/syncAllComments'
|
||||
|
||||
// Geheimer Token für Cron-Authentifizierung
|
||||
const CRON_SECRET = process.env.CRON_SECRET
|
||||
import { requireCronAuth } from '@/lib/security'
|
||||
|
||||
/**
|
||||
* GET /api/cron/youtube-sync
|
||||
* Wird von externem Cron-Job aufgerufen (z.B. Vercel Cron, cron-job.org)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
// Auth prüfen wenn CRON_SECRET gesetzt
|
||||
if (CRON_SECRET) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
||||
console.warn('[Cron] Unauthorized request to youtube-sync')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const authError = requireCronAuth(request)
|
||||
if (authError) {
|
||||
console.warn('[Cron] Unauthorized request to youtube-sync')
|
||||
return authError
|
||||
}
|
||||
|
||||
console.log('[Cron] Starting scheduled YouTube sync')
|
||||
|
|
@ -49,7 +43,12 @@ export async function GET(request: NextRequest) {
|
|||
* HEAD /api/cron/youtube-sync
|
||||
* Status-Check für Monitoring
|
||||
*/
|
||||
export async function HEAD() {
|
||||
export async function HEAD(request: NextRequest) {
|
||||
const authError = requireCronAuth(request)
|
||||
if (authError) {
|
||||
return new NextResponse(null, { status: authError.status })
|
||||
}
|
||||
|
||||
const status = getSyncStatus()
|
||||
|
||||
return new NextResponse(null, {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export async function GET(request: Request): Promise<Response> {
|
|||
* Newsletter abbestellen (API-Version für AJAX)
|
||||
*
|
||||
* Body:
|
||||
* - token (required): Token oder Subscriber-ID
|
||||
* - token (required): Unsubscribe-Token
|
||||
* - email (alternative): E-Mail-Adresse + tenantId
|
||||
* - tenantId (optional): Tenant-ID
|
||||
*/
|
||||
|
|
@ -80,7 +80,25 @@ export async function POST(request: Request): Promise<Response> {
|
|||
)
|
||||
}
|
||||
|
||||
const result = await newsletterService.unsubscribe(String(subscriber.docs[0].id))
|
||||
const foundSubscriber = subscriber.docs[0] as {
|
||||
id: number
|
||||
confirmationToken?: string | null
|
||||
}
|
||||
|
||||
let actionToken = foundSubscriber.confirmationToken || null
|
||||
if (!actionToken) {
|
||||
actionToken = crypto.randomUUID()
|
||||
await payload.update({
|
||||
collection: 'newsletter-subscribers',
|
||||
id: foundSubscriber.id,
|
||||
data: {
|
||||
confirmationToken: actionToken,
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await newsletterService.unsubscribe(actionToken)
|
||||
return NextResponse.json(result, {
|
||||
status: result.success ? 200 : 400,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@
|
|||
* - IP-Blocklist-Prüfung
|
||||
* - CSRF-Schutz für Browser-Requests
|
||||
*
|
||||
* Wichtig: Jedes Security-Feature ist in try-catch gewrappt,
|
||||
* damit ein Fehler in einem Feature nicht den gesamten Login blockiert.
|
||||
* Wichtig: Security-Checks laufen fail-closed.
|
||||
* Wenn ein Sicherheitsmodul nicht verfügbar ist oder ein Check fehlschlägt,
|
||||
* wird der Request abgelehnt.
|
||||
*/
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
|
|
@ -92,88 +93,96 @@ async function getAuditService(): Promise<AuditService> {
|
|||
/**
|
||||
* Extrahiert Client-IP aus Request
|
||||
*/
|
||||
function getClientIp(req: NextRequest): string {
|
||||
const forwarded = req.headers.get('x-forwarded-for')
|
||||
const realIp = req.headers.get('x-real-ip')
|
||||
return (forwarded ? forwarded.split(',')[0]?.trim() : undefined) || realIp || 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert Client-Informationen für Audit-Logging
|
||||
*/
|
||||
function getClientInfo(req: NextRequest): { ipAddress: string; userAgent: string } {
|
||||
function getClientInfo(req: NextRequest, ipAddress: string): { ipAddress: string; userAgent: string } {
|
||||
return {
|
||||
ipAddress: getClientIp(req),
|
||||
ipAddress,
|
||||
userAgent: req.headers.get('user-agent') || 'unknown',
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const clientIp = getClientIp(req)
|
||||
const security = await getSecurityModules()
|
||||
if (
|
||||
!security.getClientIpFromRequest ||
|
||||
!security.isIpBlocked ||
|
||||
!security.validateCsrf ||
|
||||
!security.authLimiter ||
|
||||
!security.rateLimitHeaders
|
||||
) {
|
||||
console.error('[Login] Required security modules are unavailable')
|
||||
return NextResponse.json(
|
||||
{ errors: [{ message: 'Security checks unavailable' }] },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
// 1. IP-Blocklist prüfen (optional, fail-safe)
|
||||
const clientIp = security.getClientIpFromRequest(req)
|
||||
|
||||
// 1. IP-Blocklist prüfen
|
||||
try {
|
||||
const security = await getSecurityModules()
|
||||
if (security.isIpBlocked?.(clientIp)) {
|
||||
if (security.isIpBlocked(clientIp)) {
|
||||
return NextResponse.json(
|
||||
{ errors: [{ message: 'Access denied' }] },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Login] IP blocklist check failed:', err)
|
||||
// Continue - don't block login if check fails
|
||||
console.error('[Login] IP blocklist check failed:', err)
|
||||
return NextResponse.json(
|
||||
{ errors: [{ message: 'Security check failed' }] },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
// 2. CSRF-Schutz (nur für Browser-Requests, fail-safe)
|
||||
// 2. CSRF-Schutz (nur für Browser-Requests)
|
||||
// API-Requests ohne Origin-Header (CLI, Server-to-Server) brauchen kein CSRF
|
||||
const origin = req.headers.get('origin')
|
||||
const isApiRequest = !origin && req.headers.get('content-type')?.includes('application/json')
|
||||
|
||||
if (!isApiRequest) {
|
||||
try {
|
||||
const security = await getSecurityModules()
|
||||
if (security.validateCsrf) {
|
||||
const csrfResult = security.validateCsrf(req)
|
||||
if (!csrfResult.valid) {
|
||||
console.log('[Login] CSRF validation failed:', csrfResult.reason)
|
||||
return NextResponse.json(
|
||||
{ errors: [{ message: 'CSRF validation failed' }] },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
const csrfResult = security.validateCsrf(req)
|
||||
if (!csrfResult.valid) {
|
||||
console.log('[Login] CSRF validation failed:', csrfResult.reason)
|
||||
return NextResponse.json(
|
||||
{ errors: [{ message: 'CSRF validation failed' }] },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Login] CSRF check failed:', err)
|
||||
// Continue - don't block login if check fails
|
||||
console.error('[Login] CSRF check failed:', err)
|
||||
return NextResponse.json(
|
||||
{ errors: [{ message: 'Security check failed' }] },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Rate-Limiting prüfen (optional, fail-safe)
|
||||
// 3. Rate-Limiting prüfen
|
||||
try {
|
||||
const security = await getSecurityModules()
|
||||
if (security.authLimiter) {
|
||||
const rateLimit = await security.authLimiter.check(clientIp)
|
||||
if (!rateLimit.allowed) {
|
||||
// Optionally log rate limit hit
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
const audit = await getAuditService()
|
||||
await audit.logRateLimit?.(payload, '/api/users/login', undefined, undefined)
|
||||
} catch {
|
||||
// Ignore audit logging errors
|
||||
}
|
||||
|
||||
const headers = security.rateLimitHeaders?.(rateLimit, 5) || {}
|
||||
return NextResponse.json(
|
||||
{ errors: [{ message: 'Too many login attempts. Please try again later.' }] },
|
||||
{ status: 429, headers },
|
||||
)
|
||||
const rateLimit = await security.authLimiter.check(clientIp)
|
||||
if (!rateLimit.allowed) {
|
||||
// Optionally log rate limit hit
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
const audit = await getAuditService()
|
||||
await audit.logRateLimit?.(payload, '/api/users/login', undefined, undefined)
|
||||
} catch {
|
||||
// Ignore audit logging errors
|
||||
}
|
||||
|
||||
const headers = security.rateLimitHeaders(rateLimit, 5)
|
||||
return NextResponse.json(
|
||||
{ errors: [{ message: 'Too many login attempts. Please try again later.' }] },
|
||||
{ status: 429, headers },
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Login] Rate limiting check failed:', err)
|
||||
// Continue - don't block login if check fails
|
||||
console.error('[Login] Rate limiting check failed:', err)
|
||||
return NextResponse.json(
|
||||
{ errors: [{ message: 'Security check failed' }] },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Parse Request Body
|
||||
|
|
@ -274,14 +283,14 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||
reason = errorMessage
|
||||
}
|
||||
|
||||
// Audit-Log für fehlgeschlagenen Login (optional, fail-safe)
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
const audit = await getAuditService()
|
||||
const clientInfo = getClientInfo(req)
|
||||
await audit.logLoginFailed?.(payload, email, reason, clientInfo)
|
||||
console.log(`[Audit:Auth] Login failed for ${email}: ${reason} (IP: ${clientInfo.ipAddress})`)
|
||||
} catch (auditErr) {
|
||||
// Audit-Log für fehlgeschlagenen Login (optional, fail-safe)
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
const audit = await getAuditService()
|
||||
const clientInfo = getClientInfo(req, clientIp)
|
||||
await audit.logLoginFailed?.(payload, email, reason, clientInfo)
|
||||
console.log(`[Audit:Auth] Login failed for ${email}: ${reason} (IP: ${clientInfo.ipAddress})`)
|
||||
} catch (auditErr) {
|
||||
console.warn('[Login] Audit logging failed:', auditErr)
|
||||
// Continue - don't let audit failure affect response
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { CollectionConfig, Access } from 'payload'
|
||||
import type { CollectionConfig, Access, FieldAccess } from 'payload'
|
||||
import { auditUserAfterChange, auditUserAfterDelete } from '../hooks/auditUserChanges'
|
||||
import {
|
||||
auditAfterLogin,
|
||||
|
|
@ -16,8 +16,12 @@ const canUpdateOwnAccount: Access = ({ req: { user }, id }) => {
|
|||
if (user?.id && id && String(user.id) === String(id)) {
|
||||
return true
|
||||
}
|
||||
// Ansonsten Multi-Tenant Access Control
|
||||
return true
|
||||
// Ansonsten kein Zugriff
|
||||
return false
|
||||
}
|
||||
|
||||
const superAdminFieldAccess: FieldAccess = ({ req: { user } }) => {
|
||||
return Boolean(user?.isSuperAdmin)
|
||||
}
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
|
|
@ -54,6 +58,11 @@ export const Users: CollectionConfig = {
|
|||
type: 'checkbox',
|
||||
label: 'Super Admin',
|
||||
defaultValue: false,
|
||||
access: {
|
||||
read: superAdminFieldAccess,
|
||||
create: superAdminFieldAccess,
|
||||
update: superAdminFieldAccess,
|
||||
},
|
||||
admin: {
|
||||
description: 'Super Admins haben Zugriff auf alle Tenants und können neue Tenants erstellen.',
|
||||
position: 'sidebar',
|
||||
|
|
|
|||
|
|
@ -9,15 +9,23 @@ export async function register() {
|
|||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
const { getPayload } = await import('payload')
|
||||
const config = await import('./payload.config')
|
||||
const { initScheduledJobs } = await import('./jobs/scheduler')
|
||||
|
||||
// Payload initialisieren
|
||||
const payload = await getPayload({
|
||||
config: config.default,
|
||||
})
|
||||
|
||||
// Scheduled Jobs starten
|
||||
initScheduledJobs(payload)
|
||||
const enableInProcessScheduler =
|
||||
process.env.ENABLE_IN_PROCESS_SCHEDULER === 'true' || process.env.NODE_ENV !== 'production'
|
||||
|
||||
if (enableInProcessScheduler) {
|
||||
const { initScheduledJobs } = await import('./jobs/scheduler')
|
||||
initScheduledJobs(payload)
|
||||
} else {
|
||||
console.log(
|
||||
'[Instrumentation] In-process scheduler disabled (set ENABLE_IN_PROCESS_SCHEDULER=true to enable).',
|
||||
)
|
||||
}
|
||||
|
||||
console.log('[Instrumentation] Payload und Scheduled Jobs initialisiert.')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ interface UserWithRoles {
|
|||
youtube_role?: 'none' | 'viewer' | 'editor' | 'producer' | 'creator' | 'manager'
|
||||
communityRole?: 'none' | 'viewer' | 'moderator' | 'manager'
|
||||
community_role?: 'none' | 'viewer' | 'moderator' | 'manager'
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
const checkIsSuperAdmin = (user: UserWithRoles | null): boolean => {
|
||||
|
|
@ -27,16 +28,52 @@ const getYouTubeRole = (user: UserWithRoles | null): string | undefined => {
|
|||
return user.youtubeRole || user.youtube_role
|
||||
}
|
||||
|
||||
function getLegacyCommunityRole(user: UserWithRoles | null): 'none' | 'viewer' | 'moderator' | 'manager' {
|
||||
if (!user?.roles?.length) return 'none'
|
||||
if (user.roles.includes('admin') || user.roles.includes('community_manager')) return 'manager'
|
||||
if (user.roles.includes('community_agent')) return 'moderator'
|
||||
return 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* Einheitlicher Rollen-Check für Community APIs.
|
||||
* Unterstützt camelCase/snake_case Felder und Legacy-Rollenlisten.
|
||||
*/
|
||||
export function hasCommunityApiAccess(
|
||||
user: UserWithRoles | null,
|
||||
minimumRole: 'viewer' | 'moderator' | 'manager' = 'viewer',
|
||||
): boolean {
|
||||
if (!user) return false
|
||||
if (checkIsSuperAdmin(user)) return true
|
||||
|
||||
const roleOrder: Record<string, number> = {
|
||||
none: 0,
|
||||
viewer: 1,
|
||||
moderator: 2,
|
||||
manager: 3,
|
||||
}
|
||||
|
||||
const communityRole = getCommunityRole(user) || getLegacyCommunityRole(user)
|
||||
const ytRole = getYouTubeRole(user)
|
||||
const ytCommunityLevel =
|
||||
ytRole === 'manager'
|
||||
? 'manager'
|
||||
: ytRole === 'creator' || ytRole === 'producer' || ytRole === 'editor'
|
||||
? 'moderator'
|
||||
: ytRole === 'viewer'
|
||||
? 'viewer'
|
||||
: 'none'
|
||||
|
||||
const effectiveRole = roleOrder[communityRole] >= roleOrder[ytCommunityLevel] ? communityRole : ytCommunityLevel
|
||||
return roleOrder[effectiveRole] >= roleOrder[minimumRole]
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob User Community-Manager oder Super-Admin ist
|
||||
*/
|
||||
export const isCommunityManager: Access = ({ req }) => {
|
||||
const user = req.user as UserWithRoles | null
|
||||
if (!user) return false
|
||||
if (checkIsSuperAdmin(user)) return true
|
||||
// YouTube-Manager haben auch Community-Zugriff
|
||||
if (getYouTubeRole(user) === 'manager') return true
|
||||
return getCommunityRole(user) === 'manager'
|
||||
return hasCommunityApiAccess(user, 'manager')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -44,10 +81,7 @@ export const isCommunityManager: Access = ({ req }) => {
|
|||
*/
|
||||
export const isCommunityModeratorOrAbove: Access = ({ req }) => {
|
||||
const user = req.user as UserWithRoles | null
|
||||
if (!user) return false
|
||||
if (checkIsSuperAdmin(user)) return true
|
||||
if (['manager', 'creator'].includes(getYouTubeRole(user) || '')) return true
|
||||
return ['moderator', 'manager'].includes(getCommunityRole(user) || '')
|
||||
return hasCommunityApiAccess(user, 'moderator')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -55,12 +89,7 @@ export const isCommunityModeratorOrAbove: Access = ({ req }) => {
|
|||
*/
|
||||
export const hasCommunityAccess: Access = ({ req }) => {
|
||||
const user = req.user as UserWithRoles | null
|
||||
if (!user) return false
|
||||
if (checkIsSuperAdmin(user)) return true
|
||||
const ytRole = getYouTubeRole(user)
|
||||
if (ytRole && ytRole !== 'none') return true
|
||||
const commRole = getCommunityRole(user)
|
||||
return commRole !== 'none' && commRole !== undefined
|
||||
return hasCommunityApiAccess(user, 'viewer')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
|
||||
// Token-Gültigkeitsdauer: 48 Stunden
|
||||
const TOKEN_EXPIRY_HOURS = 48
|
||||
const UNSUBSCRIBE_TOKEN_EXPIRY_DAYS = 365
|
||||
|
||||
export interface SubscribeResult {
|
||||
success: boolean
|
||||
|
|
@ -232,14 +233,15 @@ export class NewsletterService {
|
|||
}
|
||||
}
|
||||
|
||||
// Status auf bestätigt setzen
|
||||
// Status auf bestätigt setzen und Token rotieren
|
||||
const unsubscribeToken = crypto.randomUUID()
|
||||
await this.payload.update({
|
||||
collection: 'newsletter-subscribers',
|
||||
id: subscriber.id,
|
||||
data: {
|
||||
status: 'confirmed',
|
||||
confirmedAt: new Date().toISOString(),
|
||||
confirmationToken: null, // Token löschen nach Verwendung
|
||||
confirmationToken: unsubscribeToken,
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
|
@ -251,7 +253,10 @@ export class NewsletterService {
|
|||
|
||||
// Willkommens-E-Mail senden
|
||||
if (tenantId) {
|
||||
await this.sendWelcomeEmail(tenantId as number, subscriber)
|
||||
await this.sendWelcomeEmail(
|
||||
tenantId as number,
|
||||
{ ...subscriber, confirmationToken: unsubscribeToken } as NewsletterSubscriber,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -273,18 +278,13 @@ export class NewsletterService {
|
|||
*/
|
||||
async unsubscribe(token: string): Promise<UnsubscribeResult> {
|
||||
try {
|
||||
// Subscriber mit Token oder ID finden
|
||||
// Token kann entweder confirmationToken sein oder als ID interpretiert werden
|
||||
// Subscriber ausschließlich per zufälligem Token finden
|
||||
let subscriber: NewsletterSubscriber | null = null
|
||||
|
||||
// Versuche zuerst nach Token zu suchen
|
||||
const byToken = await this.payload.find({
|
||||
collection: 'newsletter-subscribers',
|
||||
where: {
|
||||
or: [
|
||||
{ confirmationToken: { equals: token } },
|
||||
{ id: { equals: parseInt(token) || 0 } },
|
||||
],
|
||||
confirmationToken: { equals: token },
|
||||
},
|
||||
limit: 1,
|
||||
depth: 1,
|
||||
|
|
@ -309,6 +309,20 @@ export class NewsletterService {
|
|||
}
|
||||
}
|
||||
|
||||
if (subscriber.status === 'confirmed' && subscriber.confirmedAt) {
|
||||
const confirmedAt = new Date(subscriber.confirmedAt)
|
||||
const expiresAt = new Date(
|
||||
confirmedAt.getTime() + UNSUBSCRIBE_TOKEN_EXPIRY_DAYS * 24 * 60 * 60 * 1000,
|
||||
)
|
||||
|
||||
if (new Date() > expiresAt) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Der Abmelde-Link ist abgelaufen. Bitte fordern Sie einen neuen Link an.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant-ID ermitteln
|
||||
const tenantId = typeof subscriber.tenant === 'object' && subscriber.tenant
|
||||
? subscriber.tenant.id
|
||||
|
|
@ -321,6 +335,7 @@ export class NewsletterService {
|
|||
data: {
|
||||
status: 'unsubscribed',
|
||||
unsubscribedAt: new Date().toISOString(),
|
||||
confirmationToken: crypto.randomUUID(),
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
|
@ -365,17 +380,14 @@ export class NewsletterService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Willkommens-E-Mail nach erfolgreicher Bestätigung senden
|
||||
* Verwendet immer die Subscriber-ID für Unsubscribe-Links,
|
||||
* da der Token nach Bestätigung gelöscht wird.
|
||||
* Willkommens-E-Mail nach erfolgreicher Bestätigung senden.
|
||||
*/
|
||||
private async sendWelcomeEmail(
|
||||
tenantId: number,
|
||||
subscriber: NewsletterSubscriber,
|
||||
): Promise<void> {
|
||||
const tenant = await this.getTenant(tenantId)
|
||||
// Immer ID für Unsubscribe verwenden, da Token nach Bestätigung null ist
|
||||
const templateData = this.buildTemplateData(tenant, subscriber, { useIdForUnsubscribe: true })
|
||||
const templateData = this.buildTemplateData(tenant, subscriber)
|
||||
|
||||
await sendTenantEmail(this.payload, tenantId, {
|
||||
to: subscriber.email,
|
||||
|
|
@ -391,16 +403,14 @@ export class NewsletterService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Abmelde-Bestätigung senden
|
||||
* Verwendet die Subscriber-ID für Links, da kein Token mehr benötigt wird.
|
||||
* Abmelde-Bestätigung senden.
|
||||
*/
|
||||
private async sendUnsubscribeEmail(
|
||||
tenantId: number,
|
||||
subscriber: NewsletterSubscriber,
|
||||
): Promise<void> {
|
||||
const tenant = await this.getTenant(tenantId)
|
||||
// ID für Links verwenden
|
||||
const templateData = this.buildTemplateData(tenant, subscriber, { useIdForUnsubscribe: true })
|
||||
const templateData = this.buildTemplateData(tenant, subscriber)
|
||||
|
||||
await sendTenantEmail(this.payload, tenantId, {
|
||||
to: subscriber.email,
|
||||
|
|
@ -430,14 +440,11 @@ export class NewsletterService {
|
|||
/**
|
||||
* Template-Daten zusammenstellen
|
||||
*
|
||||
* Für Confirmation-E-Mails wird der Token verwendet.
|
||||
* Für Willkommens- und andere E-Mails wird immer die ID verwendet,
|
||||
* da der Token nach Bestätigung gelöscht wird.
|
||||
* Für Confirmation- und Unsubscribe-Links wird ein zufälliger Token verwendet.
|
||||
*/
|
||||
private buildTemplateData(
|
||||
tenant: Tenant,
|
||||
subscriber: NewsletterSubscriber,
|
||||
options?: { useIdForUnsubscribe?: boolean },
|
||||
): NewsletterTemplateData {
|
||||
// Tenant-Website URL ermitteln
|
||||
const tenantWebsite = tenant.domains?.[0]?.domain
|
||||
|
|
@ -449,11 +456,7 @@ export class NewsletterService {
|
|||
? `${tenantWebsite}/datenschutz`
|
||||
: undefined
|
||||
|
||||
// Für Unsubscribe immer ID verwenden wenn kein Token vorhanden
|
||||
// oder wenn explizit angefordert (z.B. für Willkommens-E-Mail nach Bestätigung)
|
||||
const unsubscribeToken = options?.useIdForUnsubscribe || !subscriber.confirmationToken
|
||||
? String(subscriber.id)
|
||||
: subscriber.confirmationToken
|
||||
const unsubscribeToken = subscriber.confirmationToken || ''
|
||||
|
||||
return {
|
||||
firstName: subscriber.firstName || undefined,
|
||||
|
|
|
|||
|
|
@ -66,6 +66,11 @@ function validateEnvVar(name: string, value: string | undefined): string {
|
|||
* Wirft einen Fehler und beendet den Server-Start, wenn Variablen fehlen.
|
||||
*/
|
||||
export function validateRequiredEnvVars(): RequiredEnvVars {
|
||||
// Production-only security requirements
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
validateEnvVar('CRON_SECRET', process.env.CRON_SECRET)
|
||||
}
|
||||
|
||||
return {
|
||||
PAYLOAD_SECRET: validateEnvVar('PAYLOAD_SECRET', process.env.PAYLOAD_SECRET),
|
||||
DATABASE_URI: validateEnvVar('DATABASE_URI', process.env.DATABASE_URI),
|
||||
|
|
|
|||
|
|
@ -8,14 +8,121 @@
|
|||
import { chromium, Browser, Page } from 'playwright'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { lookup } from 'dns/promises'
|
||||
import { isIP } from 'net'
|
||||
|
||||
// Umgebungsvariablen
|
||||
const PDF_OUTPUT_DIR = process.env.PDF_OUTPUT_DIR || '/tmp/payload-pdfs'
|
||||
const PDF_GENERATION_DISABLED = process.env.PDF_GENERATION_DISABLED === 'true'
|
||||
const PDF_ALLOWED_HOSTS = (process.env.PDF_ALLOWED_HOSTS || '')
|
||||
.split(',')
|
||||
.map((host) => host.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
const PDF_ALLOW_HTTP_URLS =
|
||||
process.env.NODE_ENV !== 'production' && process.env.PDF_ALLOW_HTTP_URLS === 'true'
|
||||
|
||||
// Browser-Instanz (wird wiederverwendet)
|
||||
let browserInstance: Browser | null = null
|
||||
|
||||
function isPrivateIPv4(ip: string): boolean {
|
||||
const octets = ip.split('.').map((part) => Number(part))
|
||||
if (octets.length !== 4 || octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const [a, b] = octets
|
||||
if (a === 10) return true
|
||||
if (a === 127) return true
|
||||
if (a === 0) return true
|
||||
if (a === 169 && b === 254) return true
|
||||
if (a === 172 && b >= 16 && b <= 31) return true
|
||||
if (a === 192 && b === 168) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isPrivateIPv6(ip: string): boolean {
|
||||
const normalized = ip.toLowerCase()
|
||||
if (normalized === '::1') return true
|
||||
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true
|
||||
if (normalized.startsWith('fe80')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function isPrivateOrLoopbackIp(ip: string): boolean {
|
||||
const version = isIP(ip)
|
||||
if (version === 4) return isPrivateIPv4(ip)
|
||||
if (version === 6) return isPrivateIPv6(ip)
|
||||
return true
|
||||
}
|
||||
|
||||
function hostnameMatchesAllowedHost(hostname: string): boolean {
|
||||
if (PDF_ALLOWED_HOSTS.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return PDF_ALLOWED_HOSTS.some((allowedHost) => {
|
||||
if (allowedHost.startsWith('.')) {
|
||||
return hostname.endsWith(allowedHost)
|
||||
}
|
||||
return hostname === allowedHost
|
||||
})
|
||||
}
|
||||
|
||||
async function validatePdfSourceUrl(rawUrl: string): Promise<{ valid: boolean; reason?: string }> {
|
||||
let parsedUrl: URL
|
||||
try {
|
||||
parsedUrl = new URL(rawUrl)
|
||||
} catch {
|
||||
return { valid: false, reason: 'Invalid URL format' }
|
||||
}
|
||||
|
||||
const isHttps = parsedUrl.protocol === 'https:'
|
||||
const isAllowedHttp = parsedUrl.protocol === 'http:' && PDF_ALLOW_HTTP_URLS
|
||||
if (!isHttps && !isAllowedHttp) {
|
||||
return { valid: false, reason: 'Only HTTPS URLs are allowed (or HTTP with PDF_ALLOW_HTTP_URLS=true in non-production)' }
|
||||
}
|
||||
|
||||
if (parsedUrl.username || parsedUrl.password) {
|
||||
return { valid: false, reason: 'URL credentials are not allowed' }
|
||||
}
|
||||
|
||||
const hostname = parsedUrl.hostname.trim().toLowerCase()
|
||||
if (!hostname) {
|
||||
return { valid: false, reason: 'URL host is required' }
|
||||
}
|
||||
|
||||
if (hostname === 'localhost' || hostname === '0.0.0.0') {
|
||||
return { valid: false, reason: 'Localhost is not allowed' }
|
||||
}
|
||||
|
||||
if (!hostnameMatchesAllowedHost(hostname)) {
|
||||
return { valid: false, reason: 'Host is not in PDF_ALLOWED_HOSTS allowlist' }
|
||||
}
|
||||
|
||||
if (isIP(hostname)) {
|
||||
if (isPrivateOrLoopbackIp(hostname)) {
|
||||
return { valid: false, reason: 'Private or loopback IP addresses are not allowed' }
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const records = await lookup(hostname, { all: true, verbatim: true })
|
||||
if (!records.length) {
|
||||
return { valid: false, reason: 'Host could not be resolved' }
|
||||
}
|
||||
|
||||
if (records.some((record) => isPrivateOrLoopbackIp(record.address))) {
|
||||
return { valid: false, reason: 'Host resolves to private or loopback addresses' }
|
||||
}
|
||||
} catch {
|
||||
return { valid: false, reason: 'Host lookup failed' }
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
export interface PdfOptions {
|
||||
format?: 'A4' | 'A3' | 'Letter' | 'Legal'
|
||||
landscape?: boolean
|
||||
|
|
@ -206,6 +313,15 @@ export async function generatePdfFromUrl(
|
|||
let page: Page | null = null
|
||||
|
||||
try {
|
||||
const urlValidation = await validatePdfSourceUrl(url)
|
||||
if (!urlValidation.valid) {
|
||||
return {
|
||||
success: false,
|
||||
error: `URL validation failed: ${urlValidation.reason || 'Invalid URL'}`,
|
||||
duration: Date.now() - startTime,
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await getBrowser()
|
||||
page = await browser.newPage()
|
||||
|
||||
|
|
|
|||
|
|
@ -445,7 +445,7 @@ export async function searchPosts(
|
|||
{ id: { in: ftsResult.postIds } },
|
||||
]
|
||||
if (categoryId) {
|
||||
whereConditions.push({ category: { equals: categoryId } })
|
||||
whereConditions.push({ categories: { contains: categoryId } })
|
||||
}
|
||||
|
||||
result = await payload.find({
|
||||
|
|
@ -486,7 +486,7 @@ export async function searchPosts(
|
|||
}
|
||||
|
||||
if (categoryId) {
|
||||
whereConditions.push({ category: { equals: categoryId } })
|
||||
whereConditions.push({ categories: { contains: categoryId } })
|
||||
}
|
||||
|
||||
const where: Where = whereConditions.length > 1 ? { and: whereConditions } : whereConditions[0]
|
||||
|
|
@ -584,7 +584,7 @@ export async function getSearchSuggestions(
|
|||
limit: 1,
|
||||
})
|
||||
if (categoryResult.docs.length > 0) {
|
||||
whereConditions.push({ category: { equals: categoryResult.docs[0].id } })
|
||||
whereConditions.push({ categories: { contains: categoryResult.docs[0].id } })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -647,7 +647,7 @@ export async function getPostsByCategory(
|
|||
limit: 1,
|
||||
})
|
||||
if (categoryResult.docs.length > 0) {
|
||||
whereConditions.push({ category: { equals: categoryResult.docs[0].id } })
|
||||
whereConditions.push({ categories: { contains: categoryResult.docs[0].id } })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
38
src/lib/security/cron-auth.ts
Normal file
38
src/lib/security/cron-auth.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { timingSafeEqual } from 'crypto'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
function safeTokenEquals(left: string, right: string): boolean {
|
||||
const leftBuffer = Buffer.from(left)
|
||||
const rightBuffer = Buffer.from(right)
|
||||
|
||||
if (leftBuffer.length !== rightBuffer.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return timingSafeEqual(leftBuffer, rightBuffer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces authorization for cron endpoints.
|
||||
* Returns a response when auth fails, otherwise null.
|
||||
*/
|
||||
export function requireCronAuth(request: NextRequest): NextResponse | null {
|
||||
const secret = process.env.CRON_SECRET?.trim()
|
||||
|
||||
// Fail closed when secret is missing.
|
||||
if (!secret) {
|
||||
return NextResponse.json({ error: 'Service unavailable' }, { status: 503 })
|
||||
}
|
||||
|
||||
const authorization = request.headers.get('authorization')
|
||||
if (!authorization?.startsWith('Bearer ')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const token = authorization.slice('Bearer '.length).trim()
|
||||
if (!token || !safeTokenEquals(token, secret)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -52,3 +52,6 @@ export {
|
|||
createSafeLogger,
|
||||
isSensitiveField,
|
||||
} from './data-masking'
|
||||
|
||||
// Cron Endpoint Authentication
|
||||
export { requireCronAuth } from './cron-auth'
|
||||
|
|
|
|||
|
|
@ -129,6 +129,11 @@ const allowlistConfigs: Record<string, AllowlistConfig> = {
|
|||
description: 'Admin Panel',
|
||||
allowAllIfEmpty: true,
|
||||
},
|
||||
generatePdf: {
|
||||
envVar: 'GENERATE_PDF_ALLOWED_IPS',
|
||||
description: '/api/generate-pdf',
|
||||
allowAllIfEmpty: true,
|
||||
},
|
||||
webhooks: {
|
||||
envVar: 'WEBHOOK_ALLOWED_IPS',
|
||||
description: 'Webhook Endpoints',
|
||||
|
|
@ -199,7 +204,7 @@ export function isIpAllowed(
|
|||
|
||||
const config = allowlistConfigs[endpoint]
|
||||
if (!config) {
|
||||
return { allowed: true } // Unbekannter Endpoint = erlaubt
|
||||
return { allowed: false, reason: `Unknown allowlist endpoint: ${String(endpoint)}` }
|
||||
}
|
||||
|
||||
const allowedIps = parseIpList(process.env[config.envVar])
|
||||
|
|
|
|||
|
|
@ -270,7 +270,7 @@ describe('Search API Integration', () => {
|
|||
version: 1,
|
||||
},
|
||||
},
|
||||
...(testCategoryId ? { category: testCategoryId } : {}),
|
||||
...(testCategoryId ? { categories: [testCategoryId] } : {}),
|
||||
},
|
||||
})
|
||||
testPostId = post.id
|
||||
|
|
|
|||
Loading…
Reference in a new issue