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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// 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(/^> (.*?)$/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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 </span>
|
||||
<span style={{ color: '#2E9C9A' }}>local</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 28, color: '#EDEEE3', marginTop: 20, textAlign: 'center' }}>
|
||||
{tagline}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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] },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user