- Fix memory leak: revoke object URLs in TimelineUploadSection - Fix broken timeline photo URLs in admin panel (/data/... → /api/files/...) - Remove duplicate bad-word list in AI moderation function - Add input validation for type/status params in media and contributions API - Add bulk-approve button in admin for pending contributions - Add PATCH endpoint for bulk-approving all pending contributions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+18
-2
@@ -1138,7 +1138,7 @@ export default function AdminPage() {
|
|||||||
{entry.media_filenames.split(',').slice(0, 3).map((filename, idx) => (
|
{entry.media_filenames.split(',').slice(0, 3).map((filename, idx) => (
|
||||||
<img
|
<img
|
||||||
key={idx}
|
key={idx}
|
||||||
src={`/data/uploads/photos/${filename.trim()}`}
|
src={`/api/files/${filename.trim()}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-10 h-10 object-cover rounded border border-warm-border"
|
className="w-10 h-10 object-cover rounded border border-warm-border"
|
||||||
/>
|
/>
|
||||||
@@ -1608,7 +1608,23 @@ export default function AdminPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Status Filter Tabs */}
|
{/* Status Filter Tabs */}
|
||||||
<div className="flex gap-2 mb-6">
|
<div className="flex flex-wrap gap-2 mb-6">
|
||||||
|
{timelineContributions.filter(c => c.status === 'pending').length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm(`Alle ${timelineContributions.filter(c => c.status === 'pending').length} ausstehenden Beiträge freigeben?`)) return
|
||||||
|
await fetch('/api/contributions', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'approve-all-pending' }),
|
||||||
|
})
|
||||||
|
loadData()
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-lora bg-green-600 hover:bg-green-700 text-white transition-colors"
|
||||||
|
>
|
||||||
|
✓ Alle ausstehenden freigeben ({timelineContributions.filter(c => c.status === 'pending').length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setContributionFilter('review')}
|
onClick={() => setContributionFilter('review')}
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-lora transition-colors ${
|
className={`px-4 py-2 rounded-lg text-sm font-lora transition-colors ${
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getDb } from '@/lib/db'
|
|||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const candles = db
|
const candles = db
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { getDb } from '@/lib/db'
|
import { getDb } from '@/lib/db'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
async function isAdmin() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const token = cookieStore.get('admin_auth')?.value
|
||||||
|
const expected = createHash('sha256')
|
||||||
|
.update(process.env.ADMIN_PASSWORD || 'change-me')
|
||||||
|
.digest('hex')
|
||||||
|
return token === expected
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_CONTRIBUTION_TYPES = ['memory', 'timeline', 'media', 'recipe']
|
||||||
|
|
||||||
// Simple bad word check
|
// Simple bad word check
|
||||||
function hasBadWords(text: string): { flag: boolean; reason?: string } {
|
function hasBadWords(text: string): { flag: boolean; reason?: string } {
|
||||||
const lower = text.toLowerCase()
|
const lower = text.toLowerCase()
|
||||||
@@ -29,26 +42,10 @@ function hasBadWords(text: string): { flag: boolean; reason?: string } {
|
|||||||
// Background AI moderation with Ollama
|
// Background AI moderation with Ollama
|
||||||
async function moderateWithAI(contributionId: number, content: string) {
|
async function moderateWithAI(contributionId: number, content: string) {
|
||||||
console.log(`[AI-Mod] Starting for ${contributionId}`)
|
console.log(`[AI-Mod] Starting for ${contributionId}`)
|
||||||
|
|
||||||
// Step 1: Instant bad word check
|
// AI check for subtle issues (irrelevant content, hidden insults etc.)
|
||||||
const lowerCheck = content.toLowerCase()
|
// Note: bad words are already checked before this function is called
|
||||||
const badWords = ['hurensohn', 'arschloch', 'wichser', 'fotze']
|
console.log(`[AI-Mod] Asking AI...`)
|
||||||
const foundBadWord = badWords.find(word => lowerCheck.includes(word))
|
|
||||||
|
|
||||||
if (foundBadWord) {
|
|
||||||
console.log(`[AI-Mod] ⚠️ INSTANT FLAG: "${foundBadWord}" detected!`)
|
|
||||||
const db = getDb()
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE contributions
|
|
||||||
SET status = 'flagged', moderation_reason = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`).run(`Unangemessene Sprache: "${foundBadWord}"`, contributionId)
|
|
||||||
console.log(`[AI-Mod] ✅ FLAGGED ${contributionId} instantly`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: AI check for subtle issues (irrelevant content, hidden insults etc.)
|
|
||||||
console.log(`[AI-Mod] No bad words, asking AI...`)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const prompt = `Du prüfst Beiträge für eine Gedenkseite einer verstorbenen Großmutter (Maria Malejka).
|
const prompt = `Du prüfst Beiträge für eine Gedenkseite einer verstorbenen Großmutter (Maria Malejka).
|
||||||
@@ -151,6 +148,13 @@ export async function POST(request: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!VALID_CONTRIBUTION_TYPES.includes(type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid type' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Require content for memory type, title for timeline
|
// Require content for memory type, title for timeline
|
||||||
if (type === 'memory' && !content) {
|
if (type === 'memory' && !content) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -216,6 +220,28 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
if (!await isAdmin()) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { action } = await request.json()
|
||||||
|
if (action !== 'approve-all-pending') {
|
||||||
|
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare(`UPDATE contributions SET status = 'approved' WHERE status = 'pending'`)
|
||||||
|
.run()
|
||||||
|
return NextResponse.json({ updated: result.changes })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] Bulk approve error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
|
|||||||
@@ -3,9 +3,20 @@ import { getDb } from '@/lib/db'
|
|||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
const VALID_TYPES = ['photo', 'video', 'music']
|
||||||
|
const VALID_STATUSES = ['approved', 'pending', 'rejected', 'flagged']
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const type = req.nextUrl.searchParams.get('type')
|
const type = req.nextUrl.searchParams.get('type')
|
||||||
const status = req.nextUrl.searchParams.get('status')
|
const status = req.nextUrl.searchParams.get('status')
|
||||||
|
|
||||||
|
if (type && !VALID_TYPES.includes(type)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid type' }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (status && !VALID_STATUSES.includes(status)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
|
|
||||||
let query = 'SELECT * FROM media WHERE 1=1'
|
let query = 'SELECT * FROM media WHERE 1=1'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { Calendar, MapPin, CheckCircle2, Loader2 } from 'lucide-react'
|
import { Calendar, MapPin, CheckCircle2, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
@@ -16,9 +16,16 @@ export default function TimelineUploadSection() {
|
|||||||
location: '',
|
location: '',
|
||||||
})
|
})
|
||||||
const [files, setFiles] = useState<File[]>([])
|
const [files, setFiles] = useState<File[]>([])
|
||||||
|
const [objectUrls, setObjectUrls] = useState<string[]>([])
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [submitSuccess, setSubmitSuccess] = useState(false)
|
const [submitSuccess, setSubmitSuccess] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urls = files.map(file => URL.createObjectURL(file))
|
||||||
|
setObjectUrls(urls)
|
||||||
|
return () => urls.forEach(url => URL.revokeObjectURL(url))
|
||||||
|
}, [files])
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files) {
|
if (e.target.files) {
|
||||||
setFiles(Array.from(e.target.files))
|
setFiles(Array.from(e.target.files))
|
||||||
@@ -219,7 +226,7 @@ export default function TimelineUploadSection() {
|
|||||||
{files.map((file, i) => (
|
{files.map((file, i) => (
|
||||||
<div key={i} className="aspect-square rounded-lg overflow-hidden bg-warm-brown/5">
|
<div key={i} className="aspect-square rounded-lg overflow-hidden bg-warm-brown/5">
|
||||||
<img
|
<img
|
||||||
src={URL.createObjectURL(file)}
|
src={objectUrls[i] ?? ''}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user