mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 19:44:12 +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
|
# Security
|
||||||
CSRF_SECRET=your-csrf-secret
|
CSRF_SECRET=your-csrf-secret
|
||||||
TRUST_PROXY=true
|
TRUST_PROXY=true
|
||||||
|
BLOCKED_IPS=
|
||||||
|
SEND_EMAIL_ALLOWED_IPS=
|
||||||
|
GENERATE_PDF_ALLOWED_IPS=
|
||||||
|
ADMIN_ALLOWED_IPS=
|
||||||
|
WEBHOOK_ALLOWED_IPS=
|
||||||
|
|
||||||
# YouTube OAuth (optional)
|
# YouTube OAuth (optional)
|
||||||
GOOGLE_CLIENT_ID=your-client-id
|
GOOGLE_CLIENT_ID=your-client-id
|
||||||
|
|
@ -32,8 +37,17 @@ META_APP_ID=your-app-id
|
||||||
META_APP_SECRET=your-app-secret
|
META_APP_SECRET=your-app-secret
|
||||||
META_REDIRECT_URI=http://localhost:3000/api/auth/meta/callback
|
META_REDIRECT_URI=http://localhost:3000/api/auth/meta/callback
|
||||||
|
|
||||||
# Cron Jobs (optional)
|
# Cron Jobs (required in production)
|
||||||
CRON_SECRET=your-64-char-hex
|
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
|
# Tests
|
||||||
EMAIL_DELIVERY_DISABLED=false
|
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
|
# Security-Richtlinien - Payload CMS Multi-Tenant
|
||||||
|
|
||||||
> Letzte Aktualisierung: 29.12.2025
|
> Letzte Aktualisierung: 17.02.2026
|
||||||
|
|
||||||
## Übersicht
|
## Übersicht
|
||||||
|
|
||||||
|
|
@ -25,6 +25,7 @@ Alle Security-Funktionen befinden sich in `src/lib/security/`:
|
||||||
| IP Allowlist | `ip-allowlist.ts` | IP-basierte Zugriffskontrolle |
|
| IP Allowlist | `ip-allowlist.ts` | IP-basierte Zugriffskontrolle |
|
||||||
| CSRF Protection | `csrf.ts` | Cross-Site Request Forgery Schutz |
|
| CSRF Protection | `csrf.ts` | Cross-Site Request Forgery Schutz |
|
||||||
| Data Masking | `data-masking.ts` | Sensitive Daten in Logs maskieren |
|
| Data Masking | `data-masking.ts` | Sensitive Daten in Logs maskieren |
|
||||||
|
| Cron Auth | `cron-auth.ts` | Verbindliche Authentifizierung für Cron-Endpunkte |
|
||||||
|
|
||||||
### Rate Limiter
|
### Rate Limiter
|
||||||
|
|
||||||
|
|
@ -86,6 +87,7 @@ export async function GET(req: NextRequest) {
|
||||||
| `TRUST_PROXY` | Proxy-Header vertrauen | `true` oder leer |
|
| `TRUST_PROXY` | Proxy-Header vertrauen | `true` oder leer |
|
||||||
| `BLOCKED_IPS` | Globale Blocklist | IP, CIDR, Wildcard |
|
| `BLOCKED_IPS` | Globale Blocklist | IP, CIDR, Wildcard |
|
||||||
| `SEND_EMAIL_ALLOWED_IPS` | E-Mail-Endpoint Allowlist | 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 |
|
| `ADMIN_ALLOWED_IPS` | Admin-Panel Allowlist | IP, CIDR, Wildcard |
|
||||||
| `WEBHOOK_ALLOWED_IPS` | Webhook-Endpoint 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
|
- Requests ohne `Origin`/`Referer` Header werden als Server-to-Server behandelt
|
||||||
- API-Key-basierte Authentifizierung umgeht CSRF-Check
|
- 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
|
### Data Masking
|
||||||
|
|
||||||
**Automatisch maskierte Felder:**
|
**Automatisch maskierte Felder:**
|
||||||
|
|
@ -275,9 +303,13 @@ pnpm test:unit
|
||||||
|
|
||||||
- [ ] **`TRUST_PROXY=true`** setzen (Pflicht hinter Reverse-Proxy wie Caddy)
|
- [ ] **`TRUST_PROXY=true`** setzen (Pflicht hinter Reverse-Proxy wie Caddy)
|
||||||
- [ ] **`CSRF_SECRET`** oder **`PAYLOAD_SECRET`** setzen (Server startet nicht ohne)
|
- [ ] **`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
|
- [ ] Alle `BLOCKED_IPS` für bekannte Angreifer setzen
|
||||||
- [ ] `SEND_EMAIL_ALLOWED_IPS` auf vertrauenswürdige IPs beschränken
|
- [ ] `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
|
- [ ] `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
|
- [ ] Redis für verteiltes Rate Limiting konfigurieren
|
||||||
- [ ] Pre-Commit Hook aktivieren
|
- [ ] Pre-Commit Hook aktivieren
|
||||||
|
|
||||||
|
|
@ -329,6 +361,7 @@ email=admin@example.com&password=secret
|
||||||
|
|
||||||
| Datum | Änderung |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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} */
|
/** @type {import('next').NextConfig} */
|
||||||
const 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
|
// Reduce memory usage during build
|
||||||
experimental: {
|
experimental: {
|
||||||
// Use fewer workers for builds on low-memory systems
|
// Use fewer workers for builds on low-memory systems
|
||||||
|
|
|
||||||
|
|
@ -12,22 +12,10 @@ import { getPayload } from 'payload'
|
||||||
import config from '@payload-config'
|
import config from '@payload-config'
|
||||||
import { subDays, differenceInHours } from 'date-fns'
|
import { subDays, differenceInHours } from 'date-fns'
|
||||||
import { createSafeLogger } from '@/lib/security'
|
import { createSafeLogger } from '@/lib/security'
|
||||||
|
import { hasCommunityApiAccess } from '@/lib/communityAccess'
|
||||||
|
|
||||||
const logger = createSafeLogger('API:ChannelComparison')
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
|
|
@ -41,7 +29,7 @@ export async function GET(request: NextRequest) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
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 })
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,22 +12,10 @@ import { getPayload } from 'payload'
|
||||||
import config from '@payload-config'
|
import config from '@payload-config'
|
||||||
import { subDays, differenceInHours } from 'date-fns'
|
import { subDays, differenceInHours } from 'date-fns'
|
||||||
import { createSafeLogger } from '@/lib/security'
|
import { createSafeLogger } from '@/lib/security'
|
||||||
|
import { hasCommunityApiAccess } from '@/lib/communityAccess'
|
||||||
|
|
||||||
const logger = createSafeLogger('API:AnalyticsOverview')
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
|
|
@ -42,7 +30,7 @@ export async function GET(request: NextRequest) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
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 })
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,22 +12,10 @@ import { getPayload } from 'payload'
|
||||||
import config from '@payload-config'
|
import config from '@payload-config'
|
||||||
import { subDays, differenceInHours } from 'date-fns'
|
import { subDays, differenceInHours } from 'date-fns'
|
||||||
import { createSafeLogger } from '@/lib/security'
|
import { createSafeLogger } from '@/lib/security'
|
||||||
|
import { hasCommunityApiAccess } from '@/lib/communityAccess'
|
||||||
|
|
||||||
const logger = createSafeLogger('API:ResponseMetrics')
|
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 {
|
function median(arr: number[]): number {
|
||||||
if (arr.length === 0) return 0
|
if (arr.length === 0) return 0
|
||||||
const sorted = [...arr].sort((a, b) => a - b)
|
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 })
|
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 })
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,22 +12,10 @@ import { getPayload } from 'payload'
|
||||||
import config from '@payload-config'
|
import config from '@payload-config'
|
||||||
import { subDays, format, eachDayOfInterval, eachWeekOfInterval } from 'date-fns'
|
import { subDays, format, eachDayOfInterval, eachWeekOfInterval } from 'date-fns'
|
||||||
import { createSafeLogger } from '@/lib/security'
|
import { createSafeLogger } from '@/lib/security'
|
||||||
|
import { hasCommunityApiAccess } from '@/lib/communityAccess'
|
||||||
|
|
||||||
const logger = createSafeLogger('API:SentimentTrend')
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
|
|
@ -48,7 +36,7 @@ export async function GET(request: NextRequest) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
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 })
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,22 +12,10 @@ import { getPayload } from 'payload'
|
||||||
import config from '@payload-config'
|
import config from '@payload-config'
|
||||||
import { subDays } from 'date-fns'
|
import { subDays } from 'date-fns'
|
||||||
import { createSafeLogger } from '@/lib/security'
|
import { createSafeLogger } from '@/lib/security'
|
||||||
|
import { hasCommunityApiAccess } from '@/lib/communityAccess'
|
||||||
|
|
||||||
const logger = createSafeLogger('API:TopContent')
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
|
|
@ -44,7 +32,7 @@ export async function GET(request: NextRequest) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
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 })
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,22 +12,10 @@ import { getPayload } from 'payload'
|
||||||
import config from '@payload-config'
|
import config from '@payload-config'
|
||||||
import { subDays } from 'date-fns'
|
import { subDays } from 'date-fns'
|
||||||
import { createSafeLogger } from '@/lib/security'
|
import { createSafeLogger } from '@/lib/security'
|
||||||
|
import { hasCommunityApiAccess } from '@/lib/communityAccess'
|
||||||
|
|
||||||
const logger = createSafeLogger('API:TopicCloud')
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
|
|
@ -43,7 +31,7 @@ export async function GET(request: NextRequest) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
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 })
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,33 +12,11 @@ import { getPayload } from 'payload'
|
||||||
import config from '@payload-config'
|
import config from '@payload-config'
|
||||||
import ExcelJS from 'exceljs'
|
import ExcelJS from 'exceljs'
|
||||||
import PDFDocument from 'pdfkit'
|
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')
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const searchParams = req.nextUrl.searchParams
|
const searchParams = req.nextUrl.searchParams
|
||||||
|
|
@ -60,10 +38,10 @@ export async function GET(req: NextRequest) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const typedUser = user as UserWithCommunityAccess
|
const typedUser = user as { id: number }
|
||||||
|
|
||||||
// Community-Zugriff prüfen
|
// Community-Zugriff prüfen
|
||||||
if (!hasCommunityAccess(typedUser)) {
|
if (!hasCommunityApiAccess(user as any, 'viewer')) {
|
||||||
logger.warn('Access denied for export', { userId: typedUser.id })
|
logger.warn('Access denied for export', { userId: typedUser.id })
|
||||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,32 +10,10 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getPayload } from 'payload'
|
import { getPayload } from 'payload'
|
||||||
import config from '@payload-config'
|
import config from '@payload-config'
|
||||||
import { createSafeLogger } from '@/lib/security'
|
import { createSafeLogger } from '@/lib/security'
|
||||||
|
import { hasCommunityApiAccess } from '@/lib/communityAccess'
|
||||||
|
|
||||||
const logger = createSafeLogger('API:CommunityStats')
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config })
|
const payload = await getPayload({ config })
|
||||||
|
|
@ -47,10 +25,10 @@ export async function GET(req: NextRequest) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const typedUser = user as UserWithCommunityAccess
|
const typedUser = user as { id: number }
|
||||||
|
|
||||||
// Community-Zugriff prüfen
|
// Community-Zugriff prüfen
|
||||||
if (!hasCommunityAccess(typedUser)) {
|
if (!hasCommunityApiAccess(user as any, 'viewer')) {
|
||||||
logger.warn('Access denied for stats', { userId: typedUser.id })
|
logger.warn('Access denied for stats', { userId: typedUser.id })
|
||||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,7 @@ import {
|
||||||
type SupportedPlatform,
|
type SupportedPlatform,
|
||||||
type UnifiedSyncOptions,
|
type UnifiedSyncOptions,
|
||||||
} from '@/lib/jobs/UnifiedSyncService'
|
} from '@/lib/jobs/UnifiedSyncService'
|
||||||
|
import { requireCronAuth } from '@/lib/security'
|
||||||
// Geheimer Token für Cron-Authentifizierung
|
|
||||||
const CRON_SECRET = process.env.CRON_SECRET
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/cron/community-sync
|
* GET /api/cron/community-sync
|
||||||
|
|
@ -23,14 +21,10 @@ const CRON_SECRET = process.env.CRON_SECRET
|
||||||
* - maxItems: Maximale Items pro Account (default: 100)
|
* - maxItems: Maximale Items pro Account (default: 100)
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
// Auth prüfen wenn CRON_SECRET gesetzt
|
const authError = requireCronAuth(request)
|
||||||
if (CRON_SECRET) {
|
if (authError) {
|
||||||
const authHeader = request.headers.get('authorization')
|
console.warn('[Cron] Unauthorized request to community-sync')
|
||||||
|
return authError
|
||||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
|
||||||
console.warn('[Cron] Unauthorized request to community-sync')
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query-Parameter parsen
|
// Query-Parameter parsen
|
||||||
|
|
@ -118,14 +112,10 @@ export async function GET(request: NextRequest) {
|
||||||
* Manueller Sync-Trigger mit erweiterten Optionen
|
* Manueller Sync-Trigger mit erweiterten Optionen
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
// Auth prüfen
|
const authError = requireCronAuth(request)
|
||||||
if (CRON_SECRET) {
|
if (authError) {
|
||||||
const authHeader = request.headers.get('authorization')
|
console.warn('[Cron] Unauthorized POST to community-sync')
|
||||||
|
return authError
|
||||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
|
||||||
console.warn('[Cron] Unauthorized POST to community-sync')
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -184,7 +174,12 @@ export async function POST(request: NextRequest) {
|
||||||
* HEAD /api/cron/community-sync
|
* HEAD /api/cron/community-sync
|
||||||
* Status-Check für Monitoring
|
* 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()
|
const status = getUnifiedSyncStatus()
|
||||||
|
|
||||||
return new NextResponse(null, {
|
return new NextResponse(null, {
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getPayload } from 'payload'
|
import { getPayload } from 'payload'
|
||||||
import config from '@payload-config'
|
import config from '@payload-config'
|
||||||
import { ReportGeneratorService, ReportSchedule } from '@/lib/services/ReportGeneratorService'
|
import { ReportGeneratorService, ReportSchedule } from '@/lib/services/ReportGeneratorService'
|
||||||
|
import { requireCronAuth } from '@/lib/security'
|
||||||
// Geheimer Token für Cron-Authentifizierung
|
|
||||||
const CRON_SECRET = process.env.CRON_SECRET
|
|
||||||
|
|
||||||
// Status für Monitoring
|
// Status für Monitoring
|
||||||
let isRunning = false
|
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
|
* Läuft stündlich und prüft welche Reports gesendet werden müssen
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
// Auth prüfen wenn CRON_SECRET gesetzt
|
const authError = requireCronAuth(request)
|
||||||
if (CRON_SECRET) {
|
if (authError) {
|
||||||
const authHeader = request.headers.get('authorization')
|
console.warn('[Cron] Unauthorized request to send-reports')
|
||||||
|
return authError
|
||||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
|
||||||
console.warn('[Cron] Unauthorized request to send-reports')
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
|
|
@ -119,14 +113,10 @@ export async function GET(request: NextRequest) {
|
||||||
* Manuelles Senden eines bestimmten Reports
|
* Manuelles Senden eines bestimmten Reports
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
// Auth prüfen
|
const authError = requireCronAuth(request)
|
||||||
if (CRON_SECRET) {
|
if (authError) {
|
||||||
const authHeader = request.headers.get('authorization')
|
console.warn('[Cron] Unauthorized POST to send-reports')
|
||||||
|
return authError
|
||||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
|
||||||
console.warn('[Cron] Unauthorized POST to send-reports')
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -182,7 +172,12 @@ export async function POST(request: NextRequest) {
|
||||||
* HEAD /api/cron/send-reports
|
* HEAD /api/cron/send-reports
|
||||||
* Status-Check für Monitoring
|
* 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, {
|
return new NextResponse(null, {
|
||||||
status: isRunning ? 423 : 200,
|
status: isRunning ? 423 : 200,
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,7 @@ import {
|
||||||
type TokenRefreshOptions,
|
type TokenRefreshOptions,
|
||||||
type TokenPlatform,
|
type TokenPlatform,
|
||||||
} from '@/lib/jobs/TokenRefreshService'
|
} from '@/lib/jobs/TokenRefreshService'
|
||||||
|
import { requireCronAuth } from '@/lib/security'
|
||||||
// Geheimer Token für Cron-Authentifizierung
|
|
||||||
const CRON_SECRET = process.env.CRON_SECRET
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/cron/token-refresh
|
* GET /api/cron/token-refresh
|
||||||
|
|
@ -23,14 +21,10 @@ const CRON_SECRET = process.env.CRON_SECRET
|
||||||
* - dryRun: true/false - nur prüfen, nicht erneuern
|
* - dryRun: true/false - nur prüfen, nicht erneuern
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
// Auth prüfen wenn CRON_SECRET gesetzt
|
const authError = requireCronAuth(request)
|
||||||
if (CRON_SECRET) {
|
if (authError) {
|
||||||
const authHeader = request.headers.get('authorization')
|
console.warn('[Cron] Unauthorized request to token-refresh')
|
||||||
|
return authError
|
||||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
|
||||||
console.warn('[Cron] Unauthorized request to token-refresh')
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query-Parameter parsen
|
// Query-Parameter parsen
|
||||||
|
|
@ -103,14 +97,10 @@ export async function GET(request: NextRequest) {
|
||||||
* Manueller Token-Refresh mit erweiterten Optionen
|
* Manueller Token-Refresh mit erweiterten Optionen
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
// Auth prüfen
|
const authError = requireCronAuth(request)
|
||||||
if (CRON_SECRET) {
|
if (authError) {
|
||||||
const authHeader = request.headers.get('authorization')
|
console.warn('[Cron] Unauthorized POST to token-refresh')
|
||||||
|
return authError
|
||||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
|
||||||
console.warn('[Cron] Unauthorized POST to token-refresh')
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -166,7 +156,12 @@ export async function POST(request: NextRequest) {
|
||||||
* HEAD /api/cron/token-refresh
|
* HEAD /api/cron/token-refresh
|
||||||
* Status-Check für Monitoring
|
* 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()
|
const status = getTokenRefreshStatus()
|
||||||
|
|
||||||
return new NextResponse(null, {
|
return new NextResponse(null, {
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,7 @@ import { getPayload } from 'payload'
|
||||||
import config from '@payload-config'
|
import config from '@payload-config'
|
||||||
|
|
||||||
import { ChannelMetricsSyncService } from '@/lib/integrations/youtube/ChannelMetricsSyncService'
|
import { ChannelMetricsSyncService } from '@/lib/integrations/youtube/ChannelMetricsSyncService'
|
||||||
|
import { requireCronAuth } from '@/lib/security'
|
||||||
const CRON_SECRET = process.env.CRON_SECRET
|
|
||||||
|
|
||||||
// Monitoring state
|
// Monitoring state
|
||||||
let isRunning = false
|
let isRunning = false
|
||||||
|
|
@ -19,13 +18,10 @@ let lastRunAt: Date | null = null
|
||||||
* Scheduled daily at 04:00 UTC via Vercel Cron.
|
* Scheduled daily at 04:00 UTC via Vercel Cron.
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||||
if (CRON_SECRET) {
|
const authError = requireCronAuth(request)
|
||||||
const authHeader = request.headers.get('authorization')
|
if (authError) {
|
||||||
|
console.warn('[Cron] Unauthorized request to youtube-channel-sync')
|
||||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
return authError
|
||||||
console.warn('[Cron] Unauthorized request to youtube-channel-sync')
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
|
|
@ -73,7 +69,12 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||||
* HEAD /api/cron/youtube-channel-sync
|
* HEAD /api/cron/youtube-channel-sync
|
||||||
* Status check for monitoring.
|
* 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, {
|
return new NextResponse(null, {
|
||||||
status: isRunning ? 423 : 200,
|
status: isRunning ? 423 : 200,
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,7 @@ import { getPayload } from 'payload'
|
||||||
import config from '@payload-config'
|
import config from '@payload-config'
|
||||||
|
|
||||||
import { VideoMetricsSyncService } from '@/lib/integrations/youtube/VideoMetricsSyncService'
|
import { VideoMetricsSyncService } from '@/lib/integrations/youtube/VideoMetricsSyncService'
|
||||||
|
import { requireCronAuth } from '@/lib/security'
|
||||||
const CRON_SECRET = process.env.CRON_SECRET
|
|
||||||
|
|
||||||
// Monitoring state
|
// Monitoring state
|
||||||
let isRunning = false
|
let isRunning = false
|
||||||
|
|
@ -19,13 +18,10 @@ let lastRunAt: Date | null = null
|
||||||
* Scheduled every 6 hours via Vercel Cron.
|
* Scheduled every 6 hours via Vercel Cron.
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||||
if (CRON_SECRET) {
|
const authError = requireCronAuth(request)
|
||||||
const authHeader = request.headers.get('authorization')
|
if (authError) {
|
||||||
|
console.warn('[Cron] Unauthorized request to youtube-metrics-sync')
|
||||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
return authError
|
||||||
console.warn('[Cron] Unauthorized request to youtube-metrics-sync')
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
|
|
@ -102,7 +98,12 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||||
* HEAD /api/cron/youtube-metrics-sync
|
* HEAD /api/cron/youtube-metrics-sync
|
||||||
* Status check for monitoring.
|
* 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, {
|
return new NextResponse(null, {
|
||||||
status: isRunning ? 423 : 200,
|
status: isRunning ? 423 : 200,
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,17 @@
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { runSync, getSyncStatus } from '@/lib/jobs/syncAllComments'
|
import { runSync, getSyncStatus } from '@/lib/jobs/syncAllComments'
|
||||||
|
import { requireCronAuth } from '@/lib/security'
|
||||||
// Geheimer Token für Cron-Authentifizierung
|
|
||||||
const CRON_SECRET = process.env.CRON_SECRET
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/cron/youtube-sync
|
* GET /api/cron/youtube-sync
|
||||||
* Wird von externem Cron-Job aufgerufen (z.B. Vercel Cron, cron-job.org)
|
* Wird von externem Cron-Job aufgerufen (z.B. Vercel Cron, cron-job.org)
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
// Auth prüfen wenn CRON_SECRET gesetzt
|
const authError = requireCronAuth(request)
|
||||||
if (CRON_SECRET) {
|
if (authError) {
|
||||||
const authHeader = request.headers.get('authorization')
|
console.warn('[Cron] Unauthorized request to youtube-sync')
|
||||||
|
return authError
|
||||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
|
||||||
console.warn('[Cron] Unauthorized request to youtube-sync')
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Cron] Starting scheduled YouTube sync')
|
console.log('[Cron] Starting scheduled YouTube sync')
|
||||||
|
|
@ -49,7 +43,12 @@ export async function GET(request: NextRequest) {
|
||||||
* HEAD /api/cron/youtube-sync
|
* HEAD /api/cron/youtube-sync
|
||||||
* Status-Check für Monitoring
|
* 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()
|
const status = getSyncStatus()
|
||||||
|
|
||||||
return new NextResponse(null, {
|
return new NextResponse(null, {
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export async function GET(request: Request): Promise<Response> {
|
||||||
* Newsletter abbestellen (API-Version für AJAX)
|
* Newsletter abbestellen (API-Version für AJAX)
|
||||||
*
|
*
|
||||||
* Body:
|
* Body:
|
||||||
* - token (required): Token oder Subscriber-ID
|
* - token (required): Unsubscribe-Token
|
||||||
* - email (alternative): E-Mail-Adresse + tenantId
|
* - email (alternative): E-Mail-Adresse + tenantId
|
||||||
* - tenantId (optional): Tenant-ID
|
* - 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, {
|
return NextResponse.json(result, {
|
||||||
status: result.success ? 200 : 400,
|
status: result.success ? 200 : 400,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,9 @@
|
||||||
* - IP-Blocklist-Prüfung
|
* - IP-Blocklist-Prüfung
|
||||||
* - CSRF-Schutz für Browser-Requests
|
* - CSRF-Schutz für Browser-Requests
|
||||||
*
|
*
|
||||||
* Wichtig: Jedes Security-Feature ist in try-catch gewrappt,
|
* Wichtig: Security-Checks laufen fail-closed.
|
||||||
* damit ein Fehler in einem Feature nicht den gesamten Login blockiert.
|
* Wenn ein Sicherheitsmodul nicht verfügbar ist oder ein Check fehlschlägt,
|
||||||
|
* wird der Request abgelehnt.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getPayload } from 'payload'
|
import { getPayload } from 'payload'
|
||||||
|
|
@ -92,88 +93,96 @@ async function getAuditService(): Promise<AuditService> {
|
||||||
/**
|
/**
|
||||||
* Extrahiert Client-IP aus Request
|
* Extrahiert Client-IP aus Request
|
||||||
*/
|
*/
|
||||||
function getClientIp(req: NextRequest): string {
|
function getClientInfo(req: NextRequest, ipAddress: string): { ipAddress: string; userAgent: 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 } {
|
|
||||||
return {
|
return {
|
||||||
ipAddress: getClientIp(req),
|
ipAddress,
|
||||||
userAgent: req.headers.get('user-agent') || 'unknown',
|
userAgent: req.headers.get('user-agent') || 'unknown',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
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 {
|
try {
|
||||||
const security = await getSecurityModules()
|
if (security.isIpBlocked(clientIp)) {
|
||||||
if (security.isIpBlocked?.(clientIp)) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ errors: [{ message: 'Access denied' }] },
|
{ errors: [{ message: 'Access denied' }] },
|
||||||
{ status: 403 },
|
{ status: 403 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Login] IP blocklist check failed:', err)
|
console.error('[Login] IP blocklist check failed:', err)
|
||||||
// Continue - don't block login if check fails
|
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
|
// API-Requests ohne Origin-Header (CLI, Server-to-Server) brauchen kein CSRF
|
||||||
const origin = req.headers.get('origin')
|
const origin = req.headers.get('origin')
|
||||||
const isApiRequest = !origin && req.headers.get('content-type')?.includes('application/json')
|
const isApiRequest = !origin && req.headers.get('content-type')?.includes('application/json')
|
||||||
|
|
||||||
if (!isApiRequest) {
|
if (!isApiRequest) {
|
||||||
try {
|
try {
|
||||||
const security = await getSecurityModules()
|
const csrfResult = security.validateCsrf(req)
|
||||||
if (security.validateCsrf) {
|
if (!csrfResult.valid) {
|
||||||
const csrfResult = security.validateCsrf(req)
|
console.log('[Login] CSRF validation failed:', csrfResult.reason)
|
||||||
if (!csrfResult.valid) {
|
return NextResponse.json(
|
||||||
console.log('[Login] CSRF validation failed:', csrfResult.reason)
|
{ errors: [{ message: 'CSRF validation failed' }] },
|
||||||
return NextResponse.json(
|
{ status: 403 },
|
||||||
{ errors: [{ message: 'CSRF validation failed' }] },
|
)
|
||||||
{ status: 403 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Login] CSRF check failed:', err)
|
console.error('[Login] CSRF check failed:', err)
|
||||||
// Continue - don't block login if check fails
|
return NextResponse.json(
|
||||||
|
{ errors: [{ message: 'Security check failed' }] },
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Rate-Limiting prüfen (optional, fail-safe)
|
// 3. Rate-Limiting prüfen
|
||||||
try {
|
try {
|
||||||
const security = await getSecurityModules()
|
const rateLimit = await security.authLimiter.check(clientIp)
|
||||||
if (security.authLimiter) {
|
if (!rateLimit.allowed) {
|
||||||
const rateLimit = await security.authLimiter.check(clientIp)
|
// Optionally log rate limit hit
|
||||||
if (!rateLimit.allowed) {
|
try {
|
||||||
// Optionally log rate limit hit
|
const payload = await getPayload({ config })
|
||||||
try {
|
const audit = await getAuditService()
|
||||||
const payload = await getPayload({ config })
|
await audit.logRateLimit?.(payload, '/api/users/login', undefined, undefined)
|
||||||
const audit = await getAuditService()
|
} catch {
|
||||||
await audit.logRateLimit?.(payload, '/api/users/login', undefined, undefined)
|
// Ignore audit logging errors
|
||||||
} 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 headers = security.rateLimitHeaders(rateLimit, 5)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ errors: [{ message: 'Too many login attempts. Please try again later.' }] },
|
||||||
|
{ status: 429, headers },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Login] Rate limiting check failed:', err)
|
console.error('[Login] Rate limiting check failed:', err)
|
||||||
// Continue - don't block login if check fails
|
return NextResponse.json(
|
||||||
|
{ errors: [{ message: 'Security check failed' }] },
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Parse Request Body
|
// 4. Parse Request Body
|
||||||
|
|
@ -274,14 +283,14 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
reason = errorMessage
|
reason = errorMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audit-Log für fehlgeschlagenen Login (optional, fail-safe)
|
// Audit-Log für fehlgeschlagenen Login (optional, fail-safe)
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config })
|
const payload = await getPayload({ config })
|
||||||
const audit = await getAuditService()
|
const audit = await getAuditService()
|
||||||
const clientInfo = getClientInfo(req)
|
const clientInfo = getClientInfo(req, clientIp)
|
||||||
await audit.logLoginFailed?.(payload, email, reason, clientInfo)
|
await audit.logLoginFailed?.(payload, email, reason, clientInfo)
|
||||||
console.log(`[Audit:Auth] Login failed for ${email}: ${reason} (IP: ${clientInfo.ipAddress})`)
|
console.log(`[Audit:Auth] Login failed for ${email}: ${reason} (IP: ${clientInfo.ipAddress})`)
|
||||||
} catch (auditErr) {
|
} catch (auditErr) {
|
||||||
console.warn('[Login] Audit logging failed:', auditErr)
|
console.warn('[Login] Audit logging failed:', auditErr)
|
||||||
// Continue - don't let audit failure affect response
|
// 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 { auditUserAfterChange, auditUserAfterDelete } from '../hooks/auditUserChanges'
|
||||||
import {
|
import {
|
||||||
auditAfterLogin,
|
auditAfterLogin,
|
||||||
|
|
@ -16,8 +16,12 @@ const canUpdateOwnAccount: Access = ({ req: { user }, id }) => {
|
||||||
if (user?.id && id && String(user.id) === String(id)) {
|
if (user?.id && id && String(user.id) === String(id)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Ansonsten Multi-Tenant Access Control
|
// Ansonsten kein Zugriff
|
||||||
return true
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const superAdminFieldAccess: FieldAccess = ({ req: { user } }) => {
|
||||||
|
return Boolean(user?.isSuperAdmin)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Users: CollectionConfig = {
|
export const Users: CollectionConfig = {
|
||||||
|
|
@ -54,6 +58,11 @@ export const Users: CollectionConfig = {
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
label: 'Super Admin',
|
label: 'Super Admin',
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
|
access: {
|
||||||
|
read: superAdminFieldAccess,
|
||||||
|
create: superAdminFieldAccess,
|
||||||
|
update: superAdminFieldAccess,
|
||||||
|
},
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Super Admins haben Zugriff auf alle Tenants und können neue Tenants erstellen.',
|
description: 'Super Admins haben Zugriff auf alle Tenants und können neue Tenants erstellen.',
|
||||||
position: 'sidebar',
|
position: 'sidebar',
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,23 @@ export async function register() {
|
||||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||||
const { getPayload } = await import('payload')
|
const { getPayload } = await import('payload')
|
||||||
const config = await import('./payload.config')
|
const config = await import('./payload.config')
|
||||||
const { initScheduledJobs } = await import('./jobs/scheduler')
|
|
||||||
|
|
||||||
// Payload initialisieren
|
// Payload initialisieren
|
||||||
const payload = await getPayload({
|
const payload = await getPayload({
|
||||||
config: config.default,
|
config: config.default,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Scheduled Jobs starten
|
const enableInProcessScheduler =
|
||||||
initScheduledJobs(payload)
|
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.')
|
console.log('[Instrumentation] Payload und Scheduled Jobs initialisiert.')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ interface UserWithRoles {
|
||||||
youtube_role?: 'none' | 'viewer' | 'editor' | 'producer' | 'creator' | 'manager'
|
youtube_role?: 'none' | 'viewer' | 'editor' | 'producer' | 'creator' | 'manager'
|
||||||
communityRole?: 'none' | 'viewer' | 'moderator' | 'manager'
|
communityRole?: 'none' | 'viewer' | 'moderator' | 'manager'
|
||||||
community_role?: 'none' | 'viewer' | 'moderator' | 'manager'
|
community_role?: 'none' | 'viewer' | 'moderator' | 'manager'
|
||||||
|
roles?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkIsSuperAdmin = (user: UserWithRoles | null): boolean => {
|
const checkIsSuperAdmin = (user: UserWithRoles | null): boolean => {
|
||||||
|
|
@ -27,16 +28,52 @@ const getYouTubeRole = (user: UserWithRoles | null): string | undefined => {
|
||||||
return user.youtubeRole || user.youtube_role
|
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
|
* Prüft ob User Community-Manager oder Super-Admin ist
|
||||||
*/
|
*/
|
||||||
export const isCommunityManager: Access = ({ req }) => {
|
export const isCommunityManager: Access = ({ req }) => {
|
||||||
const user = req.user as UserWithRoles | null
|
const user = req.user as UserWithRoles | null
|
||||||
if (!user) return false
|
return hasCommunityApiAccess(user, 'manager')
|
||||||
if (checkIsSuperAdmin(user)) return true
|
|
||||||
// YouTube-Manager haben auch Community-Zugriff
|
|
||||||
if (getYouTubeRole(user) === 'manager') return true
|
|
||||||
return getCommunityRole(user) === 'manager'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -44,10 +81,7 @@ export const isCommunityManager: Access = ({ req }) => {
|
||||||
*/
|
*/
|
||||||
export const isCommunityModeratorOrAbove: Access = ({ req }) => {
|
export const isCommunityModeratorOrAbove: Access = ({ req }) => {
|
||||||
const user = req.user as UserWithRoles | null
|
const user = req.user as UserWithRoles | null
|
||||||
if (!user) return false
|
return hasCommunityApiAccess(user, 'moderator')
|
||||||
if (checkIsSuperAdmin(user)) return true
|
|
||||||
if (['manager', 'creator'].includes(getYouTubeRole(user) || '')) return true
|
|
||||||
return ['moderator', 'manager'].includes(getCommunityRole(user) || '')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -55,12 +89,7 @@ export const isCommunityModeratorOrAbove: Access = ({ req }) => {
|
||||||
*/
|
*/
|
||||||
export const hasCommunityAccess: Access = ({ req }) => {
|
export const hasCommunityAccess: Access = ({ req }) => {
|
||||||
const user = req.user as UserWithRoles | null
|
const user = req.user as UserWithRoles | null
|
||||||
if (!user) return false
|
return hasCommunityApiAccess(user, 'viewer')
|
||||||
if (checkIsSuperAdmin(user)) return true
|
|
||||||
const ytRole = getYouTubeRole(user)
|
|
||||||
if (ytRole && ytRole !== 'none') return true
|
|
||||||
const commRole = getCommunityRole(user)
|
|
||||||
return commRole !== 'none' && commRole !== undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
|
|
||||||
// Token-Gültigkeitsdauer: 48 Stunden
|
// Token-Gültigkeitsdauer: 48 Stunden
|
||||||
const TOKEN_EXPIRY_HOURS = 48
|
const TOKEN_EXPIRY_HOURS = 48
|
||||||
|
const UNSUBSCRIBE_TOKEN_EXPIRY_DAYS = 365
|
||||||
|
|
||||||
export interface SubscribeResult {
|
export interface SubscribeResult {
|
||||||
success: boolean
|
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({
|
await this.payload.update({
|
||||||
collection: 'newsletter-subscribers',
|
collection: 'newsletter-subscribers',
|
||||||
id: subscriber.id,
|
id: subscriber.id,
|
||||||
data: {
|
data: {
|
||||||
status: 'confirmed',
|
status: 'confirmed',
|
||||||
confirmedAt: new Date().toISOString(),
|
confirmedAt: new Date().toISOString(),
|
||||||
confirmationToken: null, // Token löschen nach Verwendung
|
confirmationToken: unsubscribeToken,
|
||||||
},
|
},
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
})
|
})
|
||||||
|
|
@ -251,7 +253,10 @@ export class NewsletterService {
|
||||||
|
|
||||||
// Willkommens-E-Mail senden
|
// Willkommens-E-Mail senden
|
||||||
if (tenantId) {
|
if (tenantId) {
|
||||||
await this.sendWelcomeEmail(tenantId as number, subscriber)
|
await this.sendWelcomeEmail(
|
||||||
|
tenantId as number,
|
||||||
|
{ ...subscriber, confirmationToken: unsubscribeToken } as NewsletterSubscriber,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -273,18 +278,13 @@ export class NewsletterService {
|
||||||
*/
|
*/
|
||||||
async unsubscribe(token: string): Promise<UnsubscribeResult> {
|
async unsubscribe(token: string): Promise<UnsubscribeResult> {
|
||||||
try {
|
try {
|
||||||
// Subscriber mit Token oder ID finden
|
// Subscriber ausschließlich per zufälligem Token finden
|
||||||
// Token kann entweder confirmationToken sein oder als ID interpretiert werden
|
|
||||||
let subscriber: NewsletterSubscriber | null = null
|
let subscriber: NewsletterSubscriber | null = null
|
||||||
|
|
||||||
// Versuche zuerst nach Token zu suchen
|
|
||||||
const byToken = await this.payload.find({
|
const byToken = await this.payload.find({
|
||||||
collection: 'newsletter-subscribers',
|
collection: 'newsletter-subscribers',
|
||||||
where: {
|
where: {
|
||||||
or: [
|
confirmationToken: { equals: token },
|
||||||
{ confirmationToken: { equals: token } },
|
|
||||||
{ id: { equals: parseInt(token) || 0 } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
limit: 1,
|
limit: 1,
|
||||||
depth: 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
|
// Tenant-ID ermitteln
|
||||||
const tenantId = typeof subscriber.tenant === 'object' && subscriber.tenant
|
const tenantId = typeof subscriber.tenant === 'object' && subscriber.tenant
|
||||||
? subscriber.tenant.id
|
? subscriber.tenant.id
|
||||||
|
|
@ -321,6 +335,7 @@ export class NewsletterService {
|
||||||
data: {
|
data: {
|
||||||
status: 'unsubscribed',
|
status: 'unsubscribed',
|
||||||
unsubscribedAt: new Date().toISOString(),
|
unsubscribedAt: new Date().toISOString(),
|
||||||
|
confirmationToken: crypto.randomUUID(),
|
||||||
},
|
},
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
})
|
})
|
||||||
|
|
@ -365,17 +380,14 @@ export class NewsletterService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Willkommens-E-Mail nach erfolgreicher Bestätigung senden
|
* 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.
|
|
||||||
*/
|
*/
|
||||||
private async sendWelcomeEmail(
|
private async sendWelcomeEmail(
|
||||||
tenantId: number,
|
tenantId: number,
|
||||||
subscriber: NewsletterSubscriber,
|
subscriber: NewsletterSubscriber,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const tenant = await this.getTenant(tenantId)
|
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)
|
||||||
const templateData = this.buildTemplateData(tenant, subscriber, { useIdForUnsubscribe: true })
|
|
||||||
|
|
||||||
await sendTenantEmail(this.payload, tenantId, {
|
await sendTenantEmail(this.payload, tenantId, {
|
||||||
to: subscriber.email,
|
to: subscriber.email,
|
||||||
|
|
@ -391,16 +403,14 @@ export class NewsletterService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abmelde-Bestätigung senden
|
* Abmelde-Bestätigung senden.
|
||||||
* Verwendet die Subscriber-ID für Links, da kein Token mehr benötigt wird.
|
|
||||||
*/
|
*/
|
||||||
private async sendUnsubscribeEmail(
|
private async sendUnsubscribeEmail(
|
||||||
tenantId: number,
|
tenantId: number,
|
||||||
subscriber: NewsletterSubscriber,
|
subscriber: NewsletterSubscriber,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const tenant = await this.getTenant(tenantId)
|
const tenant = await this.getTenant(tenantId)
|
||||||
// ID für Links verwenden
|
const templateData = this.buildTemplateData(tenant, subscriber)
|
||||||
const templateData = this.buildTemplateData(tenant, subscriber, { useIdForUnsubscribe: true })
|
|
||||||
|
|
||||||
await sendTenantEmail(this.payload, tenantId, {
|
await sendTenantEmail(this.payload, tenantId, {
|
||||||
to: subscriber.email,
|
to: subscriber.email,
|
||||||
|
|
@ -430,14 +440,11 @@ export class NewsletterService {
|
||||||
/**
|
/**
|
||||||
* Template-Daten zusammenstellen
|
* Template-Daten zusammenstellen
|
||||||
*
|
*
|
||||||
* Für Confirmation-E-Mails wird der Token verwendet.
|
* Für Confirmation- und Unsubscribe-Links wird ein zufälliger Token verwendet.
|
||||||
* Für Willkommens- und andere E-Mails wird immer die ID verwendet,
|
|
||||||
* da der Token nach Bestätigung gelöscht wird.
|
|
||||||
*/
|
*/
|
||||||
private buildTemplateData(
|
private buildTemplateData(
|
||||||
tenant: Tenant,
|
tenant: Tenant,
|
||||||
subscriber: NewsletterSubscriber,
|
subscriber: NewsletterSubscriber,
|
||||||
options?: { useIdForUnsubscribe?: boolean },
|
|
||||||
): NewsletterTemplateData {
|
): NewsletterTemplateData {
|
||||||
// Tenant-Website URL ermitteln
|
// Tenant-Website URL ermitteln
|
||||||
const tenantWebsite = tenant.domains?.[0]?.domain
|
const tenantWebsite = tenant.domains?.[0]?.domain
|
||||||
|
|
@ -449,11 +456,7 @@ export class NewsletterService {
|
||||||
? `${tenantWebsite}/datenschutz`
|
? `${tenantWebsite}/datenschutz`
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
// Für Unsubscribe immer ID verwenden wenn kein Token vorhanden
|
const unsubscribeToken = subscriber.confirmationToken || ''
|
||||||
// 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
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
firstName: subscriber.firstName || undefined,
|
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.
|
* Wirft einen Fehler und beendet den Server-Start, wenn Variablen fehlen.
|
||||||
*/
|
*/
|
||||||
export function validateRequiredEnvVars(): RequiredEnvVars {
|
export function validateRequiredEnvVars(): RequiredEnvVars {
|
||||||
|
// Production-only security requirements
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
validateEnvVar('CRON_SECRET', process.env.CRON_SECRET)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
PAYLOAD_SECRET: validateEnvVar('PAYLOAD_SECRET', process.env.PAYLOAD_SECRET),
|
PAYLOAD_SECRET: validateEnvVar('PAYLOAD_SECRET', process.env.PAYLOAD_SECRET),
|
||||||
DATABASE_URI: validateEnvVar('DATABASE_URI', process.env.DATABASE_URI),
|
DATABASE_URI: validateEnvVar('DATABASE_URI', process.env.DATABASE_URI),
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,121 @@
|
||||||
import { chromium, Browser, Page } from 'playwright'
|
import { chromium, Browser, Page } from 'playwright'
|
||||||
import * as fs from 'fs/promises'
|
import * as fs from 'fs/promises'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
|
import { lookup } from 'dns/promises'
|
||||||
|
import { isIP } from 'net'
|
||||||
|
|
||||||
// Umgebungsvariablen
|
// Umgebungsvariablen
|
||||||
const PDF_OUTPUT_DIR = process.env.PDF_OUTPUT_DIR || '/tmp/payload-pdfs'
|
const PDF_OUTPUT_DIR = process.env.PDF_OUTPUT_DIR || '/tmp/payload-pdfs'
|
||||||
const PDF_GENERATION_DISABLED = process.env.PDF_GENERATION_DISABLED === 'true'
|
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)
|
// Browser-Instanz (wird wiederverwendet)
|
||||||
let browserInstance: Browser | null = null
|
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 {
|
export interface PdfOptions {
|
||||||
format?: 'A4' | 'A3' | 'Letter' | 'Legal'
|
format?: 'A4' | 'A3' | 'Letter' | 'Legal'
|
||||||
landscape?: boolean
|
landscape?: boolean
|
||||||
|
|
@ -206,6 +313,15 @@ export async function generatePdfFromUrl(
|
||||||
let page: Page | null = null
|
let page: Page | null = null
|
||||||
|
|
||||||
try {
|
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()
|
const browser = await getBrowser()
|
||||||
page = await browser.newPage()
|
page = await browser.newPage()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -445,7 +445,7 @@ export async function searchPosts(
|
||||||
{ id: { in: ftsResult.postIds } },
|
{ id: { in: ftsResult.postIds } },
|
||||||
]
|
]
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
whereConditions.push({ category: { equals: categoryId } })
|
whereConditions.push({ categories: { contains: categoryId } })
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await payload.find({
|
result = await payload.find({
|
||||||
|
|
@ -486,7 +486,7 @@ export async function searchPosts(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
whereConditions.push({ category: { equals: categoryId } })
|
whereConditions.push({ categories: { contains: categoryId } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: Where = whereConditions.length > 1 ? { and: whereConditions } : whereConditions[0]
|
const where: Where = whereConditions.length > 1 ? { and: whereConditions } : whereConditions[0]
|
||||||
|
|
@ -584,7 +584,7 @@ export async function getSearchSuggestions(
|
||||||
limit: 1,
|
limit: 1,
|
||||||
})
|
})
|
||||||
if (categoryResult.docs.length > 0) {
|
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,
|
limit: 1,
|
||||||
})
|
})
|
||||||
if (categoryResult.docs.length > 0) {
|
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,
|
createSafeLogger,
|
||||||
isSensitiveField,
|
isSensitiveField,
|
||||||
} from './data-masking'
|
} from './data-masking'
|
||||||
|
|
||||||
|
// Cron Endpoint Authentication
|
||||||
|
export { requireCronAuth } from './cron-auth'
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,11 @@ const allowlistConfigs: Record<string, AllowlistConfig> = {
|
||||||
description: 'Admin Panel',
|
description: 'Admin Panel',
|
||||||
allowAllIfEmpty: true,
|
allowAllIfEmpty: true,
|
||||||
},
|
},
|
||||||
|
generatePdf: {
|
||||||
|
envVar: 'GENERATE_PDF_ALLOWED_IPS',
|
||||||
|
description: '/api/generate-pdf',
|
||||||
|
allowAllIfEmpty: true,
|
||||||
|
},
|
||||||
webhooks: {
|
webhooks: {
|
||||||
envVar: 'WEBHOOK_ALLOWED_IPS',
|
envVar: 'WEBHOOK_ALLOWED_IPS',
|
||||||
description: 'Webhook Endpoints',
|
description: 'Webhook Endpoints',
|
||||||
|
|
@ -199,7 +204,7 @@ export function isIpAllowed(
|
||||||
|
|
||||||
const config = allowlistConfigs[endpoint]
|
const config = allowlistConfigs[endpoint]
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return { allowed: true } // Unbekannter Endpoint = erlaubt
|
return { allowed: false, reason: `Unknown allowlist endpoint: ${String(endpoint)}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedIps = parseIpList(process.env[config.envVar])
|
const allowedIps = parseIpList(process.env[config.envVar])
|
||||||
|
|
|
||||||
|
|
@ -270,7 +270,7 @@ describe('Search API Integration', () => {
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(testCategoryId ? { category: testCategoryId } : {}),
|
...(testCategoryId ? { categories: [testCategoryId] } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
testPostId = post.id
|
testPostId = post.id
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue