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:
AyrisAI
2026-08-24 00:01:06 +03:00
co-authored by Claude Sonnet 5
parent 390bd699a6
commit 1b8cfeda95
62 changed files with 2216 additions and 411 deletions
+119 -53
View File
@@ -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 }