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
+29
View File
@@ -0,0 +1,29 @@
import type { NextAuthConfig } from "next-auth"
/**
* Edge-safe NextAuth config (no providers, no Prisma import) — used by
* proxy.ts to read the session JWT in the Edge middleware runtime, where
* @prisma/client cannot run. The Prisma-backed CredentialsProvider lives in
* lib/auth.ts and only executes in the Node.js runtime (route handler,
* server actions, server components).
*/
export const authConfig = {
pages: {
signIn: '/login'
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = (user as any).role
}
return token
},
async session({ session, token }) {
if (session.user && token.role) {
(session.user as any).role = token.role
}
return session
}
},
providers: [],
} satisfies NextAuthConfig
+30 -31
View File
@@ -1,7 +1,12 @@
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
import bcrypt from "bcryptjs"
import { authConfig } from "./auth.config"
import { db } from "./db"
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
CredentialsProvider({
name: "Credentials",
@@ -10,38 +15,32 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
// Boilerplate mock logic
// TODO: In production, lookup user in Prisma and verify password using bcrypt
// const user = await db.user.findUnique({ where: { email: credentials.email } })
if (credentials?.email === "admin@ayris.tech" && credentials?.password === "admin") {
return {
id: "1",
name: "Admin User",
email: "admin@ayris.tech",
role: "ADMIN"
}
const email = credentials?.email as string | undefined
const password = credentials?.password as string | undefined
if (!email || !password) return null
const user = await db.user.findUnique({ where: { email } })
if (!user?.password || user.role !== "ADMIN") return null
const isValid = await bcrypt.compare(password, user.password)
if (!isValid) return null
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role
}
return null
}
})
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = (user as any).role
}
return token
},
async session({ session, token }) {
if (session.user && token.role) {
(session.user as any).role = token.role
}
return session
}
},
pages: {
signIn: '/login'
}
]
})
/** Server actions / route handlers should call this before any admin-only mutation. */
export async function requireAdmin() {
const session = await auth()
if (!session || (session.user as any)?.role !== "ADMIN") {
throw new Error("Yetkisiz erişim: Bu işlem için admin girişi gerekli.")
}
return session
}
+123
View File
@@ -0,0 +1,123 @@
const DEEPSEEK_API_URL = 'https://api.deepseek.com/chat/completions'
export interface ItineraryCandidate {
id: string
name: string
description: string
category: string
categorySlug: string
neighborhood: string
address: string
priceRange: number
rating: number | null
slug: string
isFeatured: boolean
}
interface GenerateItineraryParams {
days: number
style: string
locale: string
candidates: ItineraryCandidate[]
}
const LANGUAGE_NAMES: Record<string, string> = {
tr: 'Turkish',
en: 'English',
ru: 'Russian',
}
const STYLE_LABELS: Record<string, string> = {
gastronomy: 'gastronomy and food-focused',
relaxation: 'relaxation and slow-paced',
adventure: 'adventure and outdoor-focused',
}
/**
* Grounded itinerary generation: the model may ONLY recommend businesses from
* `candidates` — it never invents a place. This is the core anti-hallucination
* rule from the product spec (docs/prd-3.md §3.3).
*/
export async function generateItineraryContent(params: GenerateItineraryParams): Promise<string> {
const apiKey = process.env.DEEPSEEK_API_KEY
if (!apiKey) {
throw new Error('DEEPSEEK_API_KEY tanımlı değil')
}
const languageName = LANGUAGE_NAMES[params.locale] || LANGUAGE_NAMES.tr
const styleLabel = STYLE_LABELS[params.style] || params.style
const candidateList = params.candidates
.map((c) => {
const price = '₺'.repeat(c.priceRange)
const featured = c.isFeatured ? ' [featured]' : ''
return `- id:${c.id}${featured} | ${c.name} | ${c.category} | ${c.neighborhood} | ${price} | rating:${c.rating ?? 'n/a'} | link:/${c.categorySlug}/${c.slug}\n ${c.description}`
})
.join('\n')
const systemPrompt = `You are the local guide writer for "Marmaris Local", a curated directory of Marmaris, Turkey. You write personalized multi-day itineraries.
STRICT RULE: you may only recommend businesses from the CANDIDATE LIST below. Never invent, assume, or mention any place that is not in this list. If the list has fewer suitable places than needed, reuse the best candidates rather than inventing new ones.
For every place you recommend, include its markdown link exactly as given in the candidate list (e.g. [Place Name](/category-slug/place-slug)).
Write in ${languageName}. Output valid Markdown: use "## gün N" / "## day N" style headings per day (translate "day" to ${languageName}), short warm paragraphs, and occasional > blockquote for a "local tip". Avoid generic travel-blog clichés — be specific and grounded in the actual candidate descriptions. Places marked [featured] may be given slight preference when they fit, but relevance always comes first.`
const userPrompt = `Number of days: ${params.days}
Travel style: ${styleLabel}
CANDIDATE LIST (choose only from these):
${candidateList}
Write the full ${params.days}-day itinerary now, in Markdown, in ${languageName}.`
const res = await fetch(DEEPSEEK_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.7,
}),
})
if (!res.ok) {
const errText = await res.text()
throw new Error(`DeepSeek API hatası (${res.status}): ${errText}`)
}
const json = await res.json()
const content = json?.choices?.[0]?.message?.content
if (!content) {
throw new Error('DeepSeek yanıtı boş döndü')
}
return linkifyItineraryContent(content, params.candidates)
}
/**
* The model reliably mentions candidate names but doesn't always wrap them in
* the requested markdown link syntax. This deterministically links the first
* mention of each candidate as a safety net, so every generated plan actually
* drives traffic to listing pages regardless of how well the model complied.
*/
function linkifyItineraryContent(content: string, candidates: ItineraryCandidate[]): string {
let result = content
for (const c of candidates) {
const href = `/${c.categorySlug}/${c.slug}`
if (result.includes(`](${href})`)) continue // model already linked it correctly
const escapedName = c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`\\*{0,2}${escapedName}\\*{0,2}`)
if (pattern.test(result)) {
result = result.replace(pattern, `**[${c.name}](${href})**`)
}
}
return result
}
+44
View File
@@ -0,0 +1,44 @@
// Lightweight safe Markdown to HTML parsing function — shared by blog posts and AI itinerary results.
export function renderMarkdownToHtml(md: string): string {
if (!md) return ''
let html = md
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Bold
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/__(.*?)__/g, '<strong>$1</strong>')
// Italic
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>')
html = html.replace(/_(.*?)_/g, '<em>$1</em>')
// Headings
html = html.replace(/^### (.*?)$/gm, '<h4 class="text-base font-heading font-bold text-pine mt-6 mb-2 lowercase">$1</h4>')
html = html.replace(/^## (.*?)$/gm, '<h3 class="text-lg font-heading font-extrabold text-pine mt-8 mb-3 lowercase">$1</h3>')
html = html.replace(/^# (.*?)$/gm, '<h2 class="text-xl font-heading font-extrabold text-pine mt-10 mb-4 lowercase">$1</h2>')
// Bullet Lists
html = html.replace(/^\* (.*?)$/gm, '<li class="ml-4 list-disc text-sm text-ink/80 leading-relaxed">$1</li>')
html = html.replace(/^- (.*?)$/gm, '<li class="ml-4 list-disc text-sm text-ink/80 leading-relaxed">$1</li>')
// Blockquotes
html = html.replace(/^&gt; (.*?)$/gm, '<blockquote class="border-l-2 border-turquoise/40 pl-4 italic text-sm text-shutter my-3">$1</blockquote>')
// Links
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" class="text-turquoise hover:underline" target="_blank" rel="noopener">$1</a>')
// Paragraphs
const blocks = html.split(/\n\n+/)
html = blocks.map(block => {
const trimmed = block.trim()
if (!trimmed) return ''
if (trimmed.startsWith('<h') || trimmed.startsWith('<li') || trimmed.startsWith('<ul') || trimmed.startsWith('<ol') || trimmed.startsWith('<blockquote')) {
return trimmed
}
return `<p class="leading-relaxed mb-4 text-sm sm:text-base text-ink/80 font-medium">${trimmed.replace(/\n/g, '<br/>')}</p>`
}).join('\n')
return html
}
+7
View File
@@ -1589,5 +1589,12 @@ export const mockDb = {
return newItin
}
return db.generatedItinerary.create({ data })
},
async getItineraries() {
if (this.isMock()) {
return [...globalForMockDb.generatedItineraries]
}
return db.generatedItinerary.findMany()
}
}
+61
View File
@@ -0,0 +1,61 @@
// Shared JSX used by app/icon.tsx, app/apple-icon.tsx and the per-locale
// opengraph-image.tsx / twitter-image.tsx — keeps the brand badge (the same
// circular "ML" mark used in Navbar) defined in exactly one place.
export function BrandBadge({ size, ringWidth = 3 }: { size: number; ringWidth?: number }) {
return (
<div
style={{
width: size,
height: size,
borderRadius: '50%',
background: '#FBFAF6',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
width: size * 0.8,
height: size * 0.8,
borderRadius: '50%',
border: `${ringWidth}px dashed #2E9C9A`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span style={{ fontSize: size * 0.36, fontWeight: 800, color: '#123238', letterSpacing: '-1px' }}>ML</span>
</div>
</div>
)
}
export function OgImageContent({ tagline }: { tagline: string }) {
return (
<div
style={{
width: '100%',
height: '100%',
background: '#123238',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: 80,
}}
>
<div style={{ display: 'flex', marginBottom: 40 }}>
<BrandBadge size={140} />
</div>
<div style={{ display: 'flex', fontSize: 64, fontWeight: 800, color: '#FBFAF6' }}>
<span>marmaris&nbsp;</span>
<span style={{ color: '#2E9C9A' }}>local</span>
</div>
<div style={{ display: 'flex', fontSize: 28, color: '#EDEEE3', marginTop: 20, textAlign: 'center' }}>
{tagline}
</div>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
// In-memory sliding-window rate limiter. Good enough for a single-instance
// Docker deployment (Coolify) — resets on redeploy and doesn't share state
// across replicas, but that's an acceptable MVP tradeoff for abuse-throttling
// a public, unauthenticated endpoint (no external dependency like Redis needed).
const hits = new Map<string, number[]>()
export function checkRateLimit(key: string, limit: number, windowMs: number): boolean {
const now = Date.now()
const timestamps = (hits.get(key) || []).filter((t) => now - t < windowMs)
if (timestamps.length >= limit) {
hits.set(key, timestamps)
return false
}
timestamps.push(now)
hits.set(key, timestamps)
// Opportunistic cleanup so the map doesn't grow unbounded.
if (hits.size > 5000) {
for (const [k, v] of hits) {
if (v.every((t) => now - t > windowMs)) hits.delete(k)
}
}
return true
}
+11 -3
View File
@@ -34,12 +34,20 @@ export function ogLocale(locale: string): string {
* Metadata for pages that only need a localized title/description, but still
* want their own Open Graph/Twitter tags instead of inheriting the layout's
* generic sitewide fallback (which would otherwise show on every such page).
*
* Next.js doesn't deep-merge `openGraph`/`twitter` objects across the segment
* tree — a page that returns its own `openGraph` here replaces (not extends)
* the root layout's, which would silently drop the shared opengraph-image.tsx
* fallback. So the share image is added back explicitly.
*/
export function basicMetadata(title: string, description: string): Metadata {
export function basicMetadata(title: string, description: string, locale: string, pathSuffix: string = ''): Metadata {
const image = `${SITE_URL}/${locale}/opengraph-image`
const pathname = `/${locale}${pathSuffix.startsWith('/') ? pathSuffix : pathSuffix ? `/${pathSuffix}` : ''}`
return {
title,
description,
openGraph: { title, description, type: 'website' },
twitter: { card: 'summary_large_image', title, description },
alternates: buildAlternates(pathname),
openGraph: { title, description, url: `${SITE_URL}${pathname}`, locale: ogLocale(locale), type: 'website', images: [image] },
twitter: { card: 'summary_large_image', title, description, images: [image] },
}
}
+91
View File
@@ -0,0 +1,91 @@
// Best-effort Telegram notification — a failed send never blocks the form
// submission it's attached to (the message is already saved in the DB by
// the time this runs; Telegram is a convenience notification, not the
// source of truth).
export async function sendTelegramMessage(text: string): Promise<void> {
const token = process.env.TELEGRAM_BOT_TOKEN
const chatId = process.env.TELEGRAM_CHAT_ID
if (!token || !chatId) return
try {
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: chatId, text, disable_web_page_preview: true }),
})
if (!res.ok) {
console.error('Telegram notification failed:', res.status, await res.text())
}
} catch (e) {
console.error('Telegram notification error:', e)
}
}
function formatIstanbulDate(date: Date): string {
return new Intl.DateTimeFormat('tr-TR', {
timeZone: 'Europe/Istanbul',
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(date)
}
export function formatContactMessageNotification(params: {
name: string
email: string
subject: string
message: string
ip: string
}): string {
return [
'⚡ [Marmaris Local] Yeni İletişim Talebi',
'',
'📍 Kaynak: marmarislocal.com / İletişim Formu',
`👤 Ad Soyad: ${params.name}`,
`📧 E-posta: ${params.email}`,
`📋 Konu: ${params.subject}`,
`🌐 IP Adresi: ${params.ip}`,
`⏰ Tarih: ${formatIstanbulDate(new Date())}`,
'',
'📝 Mesaj:',
params.message,
].join('\n')
}
export function formatBusinessSubmissionNotification(params: {
businessName: string
categoryName: string
neighborhoodName: string
address: string
phone?: string | null
whatsapp?: string | null
description: string
contactName: string
contactEmail: string
ip: string
}): string {
return [
'🏢 [Marmaris Local] Yeni İşletme Başvurusu',
'',
'📍 Kaynak: marmarislocal.com / İşletme Ekle Formu',
`🏷️ İşletme Adı: ${params.businessName}`,
`📂 Kategori: ${params.categoryName}`,
`📌 Mahalle: ${params.neighborhoodName}`,
`🏠 Adres: ${params.address}`,
...(params.phone ? [`☎️ Telefon: ${params.phone}`] : []),
...(params.whatsapp ? [`💬 WhatsApp: ${params.whatsapp}`] : []),
`👤 Başvuran: ${params.contactName}`,
`📧 E-posta: ${params.contactEmail}`,
`🌐 IP Adresi: ${params.ip}`,
`⏰ Tarih: ${formatIstanbulDate(new Date())}`,
'',
'📝 Açıklama:',
params.description,
'',
'Onaylamak için admin panel → Başvurular.',
].join('\n')
}