feat: harden admin security, add AI trip planner, map view, and SEO/notification improvements
Security: - requireAdmin() session check added to every admin-only server action (previously relied only on middleware path matching, which Next.js Server Actions don't reliably respect) - Real Prisma + bcrypt admin auth, replacing hardcoded credentials; split into an Edge-safe auth.config.ts (used by proxy.ts) and the full Prisma-backed auth.ts (route handler, server actions, server components) - Removed hardcoded fallback secret on the Instagram sync cron endpoint - Honeypot field + per-IP rate limiting on contact/business-submission forms and the analytics events endpoint Features: - AI trip planner (/plan-olustur, /plan/[id]) backed by DeepSeek, grounded to only recommend isLocalApproved listings, with a deterministic link-injection fallback for anything the model doesn't format as markdown - Interactive Leaflet/OpenStreetMap view on category listing pages - Telegram notifications for new contact messages and business submissions SEO: - Brand-consistent favicon/apple-icon/PWA icons and default Open Graph/ Twitter share images, generated via next/og (replacing default Next.js placeholders) - BreadcrumbList structured data on category and listing detail pages - Fixed two remaining raw <img> tags to use next/image Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
390bd699a6
commit
1b8cfeda95
+119
-53
@@ -4,9 +4,29 @@ import { redirect } from "next/navigation"
|
||||
|
||||
import { mockDb } from '@/lib/mockDb'
|
||||
import { uploadToOpeninary } from '@/lib/openinary'
|
||||
import { requireAdmin } from '@/lib/auth'
|
||||
import { HONEYPOT_FIELD_NAME } from '@/components/HoneypotField'
|
||||
import { checkRateLimit } from '@/lib/rateLimit'
|
||||
import { sendTelegramMessage, formatContactMessageNotification, formatBusinessSubmissionNotification } from '@/lib/telegram'
|
||||
import { headers } from 'next/headers'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
async function getClientIp() {
|
||||
const h = await headers()
|
||||
return h.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
||||
}
|
||||
|
||||
export async function submitBusinessAction(formData: FormData) {
|
||||
if (formData.get(HONEYPOT_FIELD_NAME)) {
|
||||
// Bot trap tripped — pretend success so it doesn't learn to skip this field.
|
||||
return { success: true, error: undefined }
|
||||
}
|
||||
|
||||
const ip = await getClientIp()
|
||||
if (!checkRateLimit(`submit-business:${ip}`, 5, 10 * 60_000)) {
|
||||
return { error: 'Çok fazla başvuru gönderildi. Lütfen daha sonra tekrar deneyin.' }
|
||||
}
|
||||
|
||||
const businessName = formData.get('businessName') as string
|
||||
const categoryId = formData.get('categoryId') as string
|
||||
const neighborhoodId = formData.get('neighborhoodId') as string
|
||||
@@ -45,11 +65,36 @@ export async function submitBusinessAction(formData: FormData) {
|
||||
imageUrl
|
||||
})
|
||||
|
||||
const [categories, neighborhoods] = await Promise.all([mockDb.getCategories(), mockDb.getNeighborhoods()])
|
||||
await sendTelegramMessage(
|
||||
formatBusinessSubmissionNotification({
|
||||
businessName,
|
||||
categoryName: categories.find(c => c.id === categoryId)?.nameTr || categoryId,
|
||||
neighborhoodName: neighborhoods.find(n => n.id === neighborhoodId)?.nameTr || neighborhoodId,
|
||||
address,
|
||||
phone,
|
||||
whatsapp,
|
||||
description,
|
||||
contactName,
|
||||
contactEmail,
|
||||
ip,
|
||||
})
|
||||
)
|
||||
|
||||
revalidatePath('/admin/submissions')
|
||||
return { success: true, error: undefined }
|
||||
}
|
||||
|
||||
export async function submitContactMessageAction(formData: FormData) {
|
||||
if (formData.get(HONEYPOT_FIELD_NAME)) {
|
||||
return { success: true, error: undefined }
|
||||
}
|
||||
|
||||
const ip = await getClientIp()
|
||||
if (!checkRateLimit(`submit-contact:${ip}`, 5, 10 * 60_000)) {
|
||||
return { error: 'Çok fazla mesaj gönderildi. Lütfen daha sonra tekrar deneyin.' }
|
||||
}
|
||||
|
||||
const name = formData.get('name') as string
|
||||
const email = formData.get('email') as string
|
||||
const subject = formData.get('subject') as string
|
||||
@@ -66,11 +111,14 @@ export async function submitContactMessageAction(formData: FormData) {
|
||||
message
|
||||
})
|
||||
|
||||
await sendTelegramMessage(formatContactMessageNotification({ name, email, subject, message, ip }))
|
||||
|
||||
revalidatePath('/admin/messages')
|
||||
return { success: true, error: undefined }
|
||||
}
|
||||
|
||||
export async function approveSubmissionAction(id: string) {
|
||||
await requireAdmin()
|
||||
const submission = await mockDb.getSubmissionById(id)
|
||||
if (!submission) return { error: 'Başvuru bulunamadı' }
|
||||
|
||||
@@ -110,12 +158,14 @@ export async function approveSubmissionAction(id: string) {
|
||||
}
|
||||
|
||||
export async function rejectSubmissionAction(id: string) {
|
||||
await requireAdmin()
|
||||
await mockDb.updateSubmissionStatus(id, 'REJECTED')
|
||||
revalidatePath('/admin/submissions')
|
||||
return { success: true, error: undefined }
|
||||
}
|
||||
|
||||
export async function deleteListingAction(id: string) {
|
||||
await requireAdmin()
|
||||
await mockDb.deleteListing(id)
|
||||
revalidatePath('/admin/listings')
|
||||
revalidatePath('/restaurants')
|
||||
@@ -125,6 +175,7 @@ export async function deleteListingAction(id: string) {
|
||||
}
|
||||
|
||||
export async function restoreListingAction(id: string) {
|
||||
await requireAdmin()
|
||||
await mockDb.restoreListing(id)
|
||||
revalidatePath('/admin/trash')
|
||||
revalidatePath('/admin/listings')
|
||||
@@ -132,6 +183,7 @@ export async function restoreListingAction(id: string) {
|
||||
}
|
||||
|
||||
export async function hardDeleteListingAction(id: string) {
|
||||
await requireAdmin()
|
||||
try {
|
||||
await mockDb.hardDeleteListing(id)
|
||||
revalidatePath('/admin/trash')
|
||||
@@ -145,12 +197,14 @@ export async function hardDeleteListingAction(id: string) {
|
||||
}
|
||||
|
||||
export async function markMessageReadAction(id: string) {
|
||||
await requireAdmin()
|
||||
await mockDb.markMessageAsRead(id)
|
||||
revalidatePath('/admin/messages')
|
||||
return { success: true, error: undefined }
|
||||
}
|
||||
|
||||
export async function createOrUpdateListingAction(formData: FormData) {
|
||||
await requireAdmin()
|
||||
const id = formData.get('id') as string | null
|
||||
const slug = formData.get('slug') as string
|
||||
const categoryId = formData.get('categoryId') as string
|
||||
@@ -264,6 +318,7 @@ export async function createOrUpdateListingAction(formData: FormData) {
|
||||
|
||||
// Blog Actions
|
||||
export async function deleteBlogPostAction(id: string) {
|
||||
await requireAdmin()
|
||||
await mockDb.deleteBlogPost(id)
|
||||
revalidatePath('/admin/blog')
|
||||
revalidatePath('/blog')
|
||||
@@ -271,6 +326,7 @@ export async function deleteBlogPostAction(id: string) {
|
||||
}
|
||||
|
||||
export async function createOrUpdateBlogPostAction(formData: FormData) {
|
||||
await requireAdmin()
|
||||
const id = formData.get('id') as string | null
|
||||
const slug = formData.get('slug') as string
|
||||
const titleTr = formData.get('titleTr') as string
|
||||
@@ -337,6 +393,7 @@ export async function createOrUpdateBlogPostAction(formData: FormData) {
|
||||
|
||||
// Collections Actions
|
||||
export async function deleteCollectionAction(id: string) {
|
||||
await requireAdmin()
|
||||
await mockDb.deleteCollection(id)
|
||||
revalidatePath('/admin/collections')
|
||||
revalidatePath('/collections')
|
||||
@@ -344,6 +401,7 @@ export async function deleteCollectionAction(id: string) {
|
||||
}
|
||||
|
||||
export async function createOrUpdateCollectionAction(formData: FormData) {
|
||||
await requireAdmin()
|
||||
const id = formData.get('id') as string | null
|
||||
const slug = formData.get('slug') as string
|
||||
const titleTr = formData.get('titleTr') as string
|
||||
@@ -402,6 +460,7 @@ export async function createOrUpdateCollectionAction(formData: FormData) {
|
||||
|
||||
// Events Actions (Phase 3)
|
||||
export async function deleteEventAction(id: string) {
|
||||
await requireAdmin()
|
||||
await mockDb.deleteEvent(id)
|
||||
revalidatePath('/admin/events')
|
||||
revalidatePath('/events')
|
||||
@@ -409,6 +468,7 @@ export async function deleteEventAction(id: string) {
|
||||
}
|
||||
|
||||
export async function createOrUpdateEventAction(formData: FormData) {
|
||||
await requireAdmin()
|
||||
const id = formData.get('id') as string | null
|
||||
const slug = formData.get('slug') as string
|
||||
const listingId = formData.get('listingId') as string | null || null
|
||||
@@ -475,6 +535,7 @@ export async function createOrUpdateEventAction(formData: FormData) {
|
||||
}
|
||||
// Neighborhood Actions
|
||||
export async function deleteNeighborhoodAction(id: string) {
|
||||
await requireAdmin()
|
||||
try {
|
||||
const listings = await mockDb.getListings({ neighborhoodId: id })
|
||||
if (listings && listings.length > 0) {
|
||||
@@ -493,6 +554,7 @@ export async function deleteNeighborhoodAction(id: string) {
|
||||
}
|
||||
|
||||
export async function createOrUpdateNeighborhoodAction(formData: FormData) {
|
||||
await requireAdmin()
|
||||
const id = formData.get('id') as string | null
|
||||
const slug = formData.get('slug') as string
|
||||
const nameTr = formData.get('nameTr') as string
|
||||
@@ -525,72 +587,70 @@ export async function createOrUpdateNeighborhoodAction(formData: FormData) {
|
||||
|
||||
// AI Itinerary Planner Actions (Phase 3)
|
||||
import crypto from 'crypto'
|
||||
import { generateItineraryContent, type ItineraryCandidate } from '@/lib/deepseek'
|
||||
|
||||
async function generateMockItinerary(days: number, style: string, neighborhoodSlugs: string[]) {
|
||||
const allListings = await mockDb.getListings()
|
||||
const matchingListings = allListings.filter(l =>
|
||||
!l.deletedAt && l.isLocalApproved &&
|
||||
(neighborhoodSlugs.length === 0 ||
|
||||
(l.neighborhood && neighborhoodSlugs.includes(l.neighborhood.slug)))
|
||||
)
|
||||
|
||||
const pool = matchingListings.length > 0 ? matchingListings : allListings.filter(l => !l.deletedAt && l.isLocalApproved)
|
||||
const MAX_ITINERARY_CANDIDATES = 30
|
||||
|
||||
let md = `# marmaris local kişisel gezi rotası 🌴\n\n`
|
||||
md += `**gün sayısı:** ${days} gün | **gezi tarzı:** ${style === 'gastronomy' ? 'gurme' : style === 'relaxation' ? 'dinlenme' : 'macera'} | **keşif bölgeleri:** ${neighborhoodSlugs.join(', ')}\n\n`
|
||||
md += `bu rota, marmaris local topluluğu tarafından onaylanmış yerel işletmeler temel alınarak oluşturulmuştur.\n\n---\n\n`
|
||||
|
||||
for (let day = 1; day <= days; day++) {
|
||||
md += `## 🗓️ gün ${day}\n\n`
|
||||
|
||||
const dayListings = [...pool].sort(() => 0.5 - Math.random()).slice(0, 3)
|
||||
|
||||
if (dayListings.length >= 1) {
|
||||
md += `### 🌅 sabah: kahvaltı ve başlangıç\n`
|
||||
md += `güne yerel onaylı **[${dayListings[0].nameTr}](/${dayListings[0].category?.slug || 'isletme'}/${dayListings[0].slug})** işletmesinde başlayın. \n`
|
||||
md += `> **yerel ipucu:** ${dayListings[0].descriptionTr}\n\n`
|
||||
}
|
||||
|
||||
if (dayListings.length >= 2) {
|
||||
md += `### ☀️ öğle: keşif zamanı\n`
|
||||
md += `öğleden sonra **[${dayListings[1].nameTr}](/${dayListings[1].category?.slug || 'isletme'}/${dayListings[1].slug})** mekanına uğrayın ve çevreyi keşfedin.\n`
|
||||
md += `> **editör notu:** ${dayListings[1].address}\n\n`
|
||||
}
|
||||
|
||||
if (dayListings.length >= 3) {
|
||||
md += `### 🌌 akşam: gün batımı ve akşam yemeği\n`
|
||||
md += `akşamı şık bir akşam yemeğiyle taçlandırın: **[${dayListings[2].nameTr}](/${dayListings[2].category?.slug || 'isletme'}/${dayListings[2].slug})**.\n`
|
||||
md += `> **özel detaylar:** rating: ★${dayListings[2].rating} • tel: ${dayListings[2].phone || 'belirtilmemiş'}\n\n`
|
||||
}
|
||||
|
||||
md += `---\n\n`
|
||||
}
|
||||
|
||||
md += `*not: seyahatiniz boyunca yerel rehberimizdeki işletmeleri ziyaret etmeyi ve favorilerinize eklemeyi unutmayın!*`
|
||||
|
||||
return {
|
||||
content: md,
|
||||
listingIds: pool.map(l => l.id)
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateItineraryAction(days: number, style: string, neighborhoodSlugs: string[]) {
|
||||
export async function generateItineraryAction(days: number, style: string, neighborhoodSlugs: string[], locale: string) {
|
||||
const sorted = [...neighborhoodSlugs].sort()
|
||||
const paramsHash = crypto.createHash('md5').update(JSON.stringify({ days, style, neighborhoodSlugs: sorted })).digest('hex')
|
||||
const paramsHash = crypto.createHash('md5').update(JSON.stringify({ days, style, neighborhoodSlugs: sorted, locale })).digest('hex')
|
||||
|
||||
const existing = await mockDb.getItineraryByHash(paramsHash)
|
||||
if (existing) {
|
||||
return { success: true, id: existing.id }
|
||||
}
|
||||
|
||||
// Generate new program
|
||||
const { content, listingIds } = await generateMockItinerary(days, style, sorted)
|
||||
// Cache hit above is free; only a genuinely new combination reaches the
|
||||
// paid DeepSeek call, so rate-limit per IP to bound abuse cost.
|
||||
const ip = await getClientIp()
|
||||
if (!checkRateLimit(`itinerary:${ip}`, 10, 60 * 60_000)) {
|
||||
return { success: false, error: 'Çok fazla plan oluşturuldu. Lütfen bir süre sonra tekrar deneyin.' }
|
||||
}
|
||||
|
||||
const allListings = await mockDb.getListings()
|
||||
const matchingListings = allListings.filter(l =>
|
||||
!l.deletedAt && l.isLocalApproved &&
|
||||
(sorted.length === 0 ||
|
||||
(l.neighborhood && sorted.includes(l.neighborhood.slug)))
|
||||
)
|
||||
const pool = (matchingListings.length > 0 ? matchingListings : allListings.filter(l => !l.deletedAt && l.isLocalApproved))
|
||||
.sort((a, b) => Number(b.isFeatured) - Number(a.isFeatured))
|
||||
.slice(0, MAX_ITINERARY_CANDIDATES)
|
||||
|
||||
if (pool.length === 0) {
|
||||
return { success: false, error: 'Seçtiğiniz bölgelerde yerel onaylı mekan bulunamadı.' }
|
||||
}
|
||||
|
||||
const nameKey = locale === 'en' ? 'nameEn' : locale === 'ru' ? 'nameRu' : 'nameTr'
|
||||
const descKey = locale === 'en' ? 'descriptionEn' : locale === 'ru' ? 'descriptionRu' : 'descriptionTr'
|
||||
|
||||
const candidates: ItineraryCandidate[] = pool.map(l => ({
|
||||
id: l.id,
|
||||
name: (l as any)[nameKey] || l.nameTr,
|
||||
description: (l as any)[descKey] || l.descriptionTr,
|
||||
category: l.category?.nameTr || '',
|
||||
categorySlug: l.category?.slug || 'isletme',
|
||||
neighborhood: l.neighborhood?.nameTr || '',
|
||||
address: l.address,
|
||||
priceRange: l.priceRange,
|
||||
rating: l.rating ?? null,
|
||||
slug: l.slug,
|
||||
isFeatured: l.isFeatured,
|
||||
}))
|
||||
|
||||
let content: string
|
||||
try {
|
||||
content = await generateItineraryContent({ days, style, locale, candidates })
|
||||
} catch (e: any) {
|
||||
console.error('DeepSeek itinerary generation error:', e)
|
||||
return { success: false, error: 'Planınız oluşturulamadı, lütfen birazdan tekrar deneyin.' }
|
||||
}
|
||||
|
||||
const newItin = await mockDb.createItinerary({
|
||||
paramsHash,
|
||||
params: { days, style, neighborhoodSlugs: sorted },
|
||||
params: { days, style, neighborhoodSlugs: sorted, locale },
|
||||
content,
|
||||
listingIds
|
||||
listingIds: pool.map(l => l.id)
|
||||
})
|
||||
|
||||
return { success: true, id: newItin.id }
|
||||
@@ -599,6 +659,7 @@ export async function generateItineraryAction(days: number, style: string, neigh
|
||||
|
||||
|
||||
export async function createOrUpdateCategoryAction(formData: FormData) {
|
||||
await requireAdmin()
|
||||
const id = formData.get('id') as string
|
||||
const slug = formData.get('slug') as string
|
||||
const nameTr = formData.get('nameTr') as string
|
||||
@@ -620,6 +681,7 @@ export async function createOrUpdateCategoryAction(formData: FormData) {
|
||||
}
|
||||
|
||||
export async function deleteCategoryAction(formData: FormData) {
|
||||
await requireAdmin()
|
||||
const id = formData.get('id') as string
|
||||
if (!id) return
|
||||
await mockDb.deleteCategory(id)
|
||||
@@ -627,12 +689,14 @@ export async function deleteCategoryAction(formData: FormData) {
|
||||
}
|
||||
|
||||
export async function deleteSubmissionAction(id: string) {
|
||||
await requireAdmin()
|
||||
await mockDb.deleteSubmission(id)
|
||||
revalidatePath('/admin/submissions')
|
||||
return { success: true, error: undefined }
|
||||
}
|
||||
|
||||
export async function deleteMessageAction(id: string) {
|
||||
await requireAdmin()
|
||||
await mockDb.deleteMessage(id)
|
||||
revalidatePath('/admin/messages')
|
||||
return { success: true, error: undefined }
|
||||
@@ -664,6 +728,7 @@ export async function searchListingsAction(query: string) {
|
||||
|
||||
|
||||
export async function saveWidgetPartnerAction(formData: FormData) {
|
||||
await requireAdmin()
|
||||
const id = formData.get('id') as string | null
|
||||
const name = formData.get('name') as string
|
||||
const url = formData.get('url') as string
|
||||
@@ -697,6 +762,7 @@ export async function saveWidgetPartnerAction(formData: FormData) {
|
||||
}
|
||||
|
||||
export async function deleteWidgetPartnerAction(id: string) {
|
||||
await requireAdmin()
|
||||
await mockDb.deleteWidgetPartner(id)
|
||||
revalidatePath('/admin/widget-partners')
|
||||
return { success: true, error: undefined }
|
||||
|
||||
Reference in New Issue
Block a user