diff --git a/README.md b/README.md index 8f87564..a173ead 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,17 @@
-# ColdByDefault Portfolio · V5.3.18 +# ColdByDefault Portfolio · V6.0.1 Modern, secure, high‑performance developer portfolio built with Next.js 16, TypeScript, a strongly hardened edge-first architecture & multi‑locale SEO‑optimized delivery. Screenshot 2025-08-31 111906 -**Live:** https://www.coldbydefault.com • **Docs:** https://docs.coldbydefault.com/ • **Stack:** Next.js 16 · React 19.2.3 · TypeScript 5.x · Tailwind 4.1.12 · shadcn/ui · Embla Carousel · Framer Motion 12.x · next-intl 4.6 · Prisma ORM 7 · Neon PostgreSQL · Zod 4.x · ESLint 9.x · Vercel +- **Live:** https://www.coldbydefault.com +- **Docs:** https://docs.coldbydefault.com/ +- **Stack:** + - Next.js 16 · React 19.2.3 · TypeScript 5.x · Tailwind 4.1.12 · shadcn/ui + - Embla Carousel · Framer Motion 12.x · next-intl 4.6 · Prisma ORM 7 + - Neon PostgreSQL · Zod 4.x · ESLint 9.x · Vercel
@@ -217,13 +222,11 @@ Last internal assessment: 2025‑09 (latest iteration) — no known unresolved c Implemented Layers (expanded in 4.11.15): -1. Transport & Headers: HSTS, CSP, X-Content-Type-Options, X-Frame-Options (deny), Referrer-Policy, Permissions-Policy. -2. Application: Sanitized inputs, explicit error redaction, avoidance of `eval` / dangerous DOM sinks, reinforced type gates (locale / SEO literal unions) reducing unchecked paths. -3. Operational: Secrets confined to environment variables; repository free of credentials. -4. Abuse Mitigation: IP‑scoped rate limiting on sensitive endpoints with enhanced Zod validation. -5. Dependency Hygiene: Routine audit (npm audit) — zero known CVEs at last scan; periodic verification of transitive packages relevant to security headers & i18n. -6. Automated Security: CodeQL Advanced Security Scanning for JavaScript, TypeScript, and Python with multi-language matrix analysis. -7. Dependency Security: Automated dependency review workflows blocking vulnerable dependencies in pull requests. +1. Transport & Headers: HSTS, CSP, X-Content-Type-Options, X-Frame-Options (deny), Referrer-Policy, Permissions-Policy.. +2. Abuse Mitigation.. +3. Dependency Hygiene: Routine audit (npm audit) — zero known CVEs at last scan; periodic verification of transitive packages relevant to security headers & i18n. +4. Automated Security: CodeQL Advanced Security Scanning for JavaScript, TypeScript, and Python with multi-language matrix analysis. +5. Dependency Security: Automated dependency review workflows blocking vulnerable dependencies in pull requests. Security Posture Snapshot: @@ -295,40 +298,9 @@ pnpm dev ``` -Open http://localhost:3000 - - -**Prisma ORM 7 Notes:** - -Prisma 7 introduces a new client generation structure. The generated client exports are now in `client.ts`: - -```typescript -// ✅ Correct import for Prisma 7 -import { PrismaClient } from "@/lib/generated/prisma/client"; -import type { Prisma } from "@/lib/generated/prisma/client"; - -// ❌ Old import (Prisma 6 and below) -import { PrismaClient } from "@/lib/generated/prisma"; - -``` - --- -## 13. Roadmap - -**Planned Enhancements:** - -* Expand localization (additional languages beyond 5; automated missing key detection) -* Further edge caching tuning & RUM instrumentation (privacy‑preserving) -* Enhanced visual regression / accessibility automation -* Add selective metrics dashboard (anonymized) -* Structured data expansion (Projects, Certifications) -* Advanced chatbot capabilities with memory and context awareness -* Enhanced performance monitoring and optimization tools - ---- - -## 15. License & Intellectual Property +## 13. License & Intellectual Property Copyright © 2026 ColdByDefault. All rights reserved. @@ -344,15 +316,17 @@ Refer to `LICENSE` & `COPYRIGHT` files for formal wording. --- -## 16. Contact +## 14. Contact Portfolio: https://www.coldbydefault.com + Documentation: https://docs.coldbydefault.com/ + For professional or security inquiries, reach out via the official channels listed above. _P.S. If you find any bugs, they're not bugs - they're undocumented features!_ --- -## 17. Special Thanks +## 15. Special Thanks
diff --git a/app/(legals)/impressum/page.tsx b/app/(legals)/impressum/page.tsx index 445dde8..7871048 100644 --- a/app/(legals)/impressum/page.tsx +++ b/app/(legals)/impressum/page.tsx @@ -99,7 +99,7 @@ export default async function Impressum() {

{t("vat.description")}

-

{t("vat.value")}

+

{t("vat.value")} Will be provided soon

diff --git a/app/admin/blocked/page.tsx b/app/admin/blocked/page.tsx new file mode 100644 index 0000000..ba870a8 --- /dev/null +++ b/app/admin/blocked/page.tsx @@ -0,0 +1,29 @@ +/** + * @author ColdByDefault + * @copyright 2026 ColdByDefault. All Rights Reserved. + */ + +import { Shield } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function AdminBlockedPage() { + return ( +
+ + +
+ +
+ + Access Blocked + +
+ +

+ Nuh you ain't support to be doing this... NOW BANNNN!. +

+
+
+
+ ); +} diff --git a/app/admin/chatbot/page.tsx b/app/admin/chatbot/page.tsx new file mode 100644 index 0000000..286ad4b --- /dev/null +++ b/app/admin/chatbot/page.tsx @@ -0,0 +1,417 @@ +/** + * Admin ChatBot Logs Page + * @author ColdByDefault + * @copyright 2026 ColdByDefault. All Rights Reserved. + */ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Authentication } from "@/components/blog/dashboard"; + +import { + MessageSquare, + Globe, + Clock, + User, + Bot, + Search, + Filter, + Trash2, + CheckCircle2, + XCircle, + MapPin, +} from "lucide-react"; +import type { ChatSessionLog } from "@/types/configs/chatbot"; + +export default function AdminChatLogsPage() { + // Authentication state + const [token, setToken] = useState(""); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [authLoading, setAuthLoading] = useState(false); + const [authMessage, setAuthMessage] = useState(""); + + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [countryFilter, setCountryFilter] = useState(""); + const [consentFilter, setConsentFilter] = useState<"all" | "yes" | "no">( + "all", + ); + const [selectedSession, setSelectedSession] = useState(null); + + // Authentication + const authenticate = useCallback(async (): Promise => { + if (!token.trim()) { + setAuthMessage("Please enter a valid token"); + return; + } + + setAuthLoading(true); + try { + const response = await fetch("/api/admin/chatbot/logs?limit=1&offset=0", { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (response.ok) { + setIsAuthenticated(true); + setAuthMessage("Authenticated successfully!"); + } else { + setAuthMessage("Authentication failed"); + } + } catch { + setAuthMessage("Authentication failed"); + } finally { + setAuthLoading(false); + } + }, [token]); + + const handleKeyPress = useCallback( + (event: React.KeyboardEvent): void => { + if (event.key === "Enter" && !authLoading) { + void authenticate(); + } + }, + [authenticate, authLoading], + ); + + // Fetch chat logs + const fetchLogs = async () => { + try { + setLoading(true); + setError(null); + + const params = new URLSearchParams({ + limit: "50", + offset: "0", + }); + + if (countryFilter) { + params.append("country", countryFilter); + } + + if (consentFilter !== "all") { + params.append("hasConsent", consentFilter === "yes" ? "true" : "false"); + } + + const response = await fetch(`/api/admin/chatbot/logs?${params}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error("Failed to fetch chat logs"); + } + + const data = (await response.json()) as { + sessions: ChatSessionLog[]; + total: number; + hasMore: boolean; + }; + + // Apply search filter client-side + let filtered = data.sessions; + if (searchQuery) { + filtered = filtered.filter((session) => + session.messages?.some((msg) => + msg.content.toLowerCase().includes(searchQuery.toLowerCase()), + ), + ); + } + + setSessions(filtered); + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + setLoading(false); + } + }; + + // Delete session + const deleteSession = async (sessionId: string) => { + if (!confirm("Are you sure you want to delete this chat session?")) { + return; + } + + try { + const response = await fetch( + `/api/admin/chatbot/logs?sessionId=${sessionId}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }, + ); + + if (!response.ok) { + throw new Error("Failed to delete session"); + } + + // Refresh logs + await fetchLogs(); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to delete session"); + } + }; + + useEffect(() => { + if (isAuthenticated) { + void fetchLogs(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [countryFilter, consentFilter, isAuthenticated]); + + if (!isAuthenticated) { + return ( + void authenticate()} + onKeyPress={handleKeyPress} + loading={authLoading} + message={authMessage} + /> + ); + } + + return ( +
+
+ {/* Header */} +
+
+

+ + ChatBot Logs +

+

+ View and manage chat conversations +

+
+ +
+ + {/* Filters */} + + + + + Filters + + + +
+
+ +
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void fetchLogs(); + }} + className="pl-9" + /> +
+
+ +
+ + setCountryFilter(e.target.value)} + /> +
+ +
+ + +
+
+ + +
+
+ + {/* Error State */} + {error && ( + + +

{error}

+
+
+ )} + + {/* Loading State */} + {loading && ( + + +

+ Loading chat logs... +

+
+
+ )} + + {/* Sessions List */} + {!loading && !error && ( +
+
+

+ {sessions.length} Session{sessions.length !== 1 ? "s" : ""}{" "} + Found +

+
+ + {sessions.length === 0 && ( + + + No chat sessions found. Try adjusting your filters. + + + )} + + {sessions.map((session) => ( + + +
+
+
+ + {session.id} + + {session.consentGiven ? ( + + + Consented + + ) : ( + + + No Consent + + )} + {session.isActive && ( + Active + )} +
+ +
+
+ + + {session.ipCountry || "Unknown"}{" "} + {session.ipCity && `• ${session.ipCity}`} + +
+
+ + + {session.ipAddress || "N/A"} + +
+
+ + + {new Date(session.startedAt).toLocaleString()} + +
+
+ + {session.totalMessages} messages +
+
+
+ +
+ + +
+
+
+ + {/* Messages */} + {selectedSession === session.id && session.messages && ( + +
+ {session.messages.map((msg) => ( +
+
+ {msg.role === "user" ? ( + + ) : ( + + )} +
+
+
+ + {msg.role === "user" ? "User" : "Assistant"} + + + + {new Date(msg.timestamp).toLocaleTimeString()} + +
+

+ {msg.content} +

+
+
+ ))} +
+
+ )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/app/api/admin/blog/route.ts b/app/api/admin/blog/route.ts index 50d3a50..5ce5fde 100644 --- a/app/api/admin/blog/route.ts +++ b/app/api/admin/blog/route.ts @@ -29,6 +29,7 @@ import { getAdminTags, type AdminContext, } from "@/lib/blog-admin/blog-admin"; +import { createAdminSession, invalidateAdminSession } from "@/proxy"; // Enhanced authentication const ADMIN_TOKEN = process.env.ADMIN_TOKEN; @@ -59,7 +60,6 @@ function getClientIP(request: NextRequest): string { function isAuthorized(request: NextRequest): boolean { if (!ADMIN_TOKEN) { - // Security: Don't log sensitive information about environment configuration return false; } @@ -72,7 +72,6 @@ function isAuthorized(request: NextRequest): boolean { ? authHeader.substring(7) : authHeader; - // Use constant-time comparison to prevent timing attacks if (token.length !== ADMIN_TOKEN.length) { return false; } @@ -87,9 +86,6 @@ function isAuthorized(request: NextRequest): boolean { return isEqual; } -/** - * Create admin context for blog operations - */ function createAdminContext(request: NextRequest): AdminContext { return { clientIP: getClientIP(request), @@ -98,6 +94,31 @@ function createAdminContext(request: NextRequest): AdminContext { }; } + +function setAdminSessionCookie(response: NextResponse, clientIP: string): void { + // Create cryptographically secure session + const sessionId = createAdminSession(clientIP); + + response.cookies.set("PORTFOLIO_ADMIN_SESSION", sessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 60 * 60 * 8, + path: "/", + }); +} + +function clearAdminSessionCookie( + response: NextResponse, + request: NextRequest, +): void { + const sessionCookie = request.cookies.get("PORTFOLIO_ADMIN_SESSION"); + if (sessionCookie?.value) { + invalidateAdminSession(sessionCookie.value); + } + response.cookies.delete("PORTFOLIO_ADMIN_SESSION"); +} + // Validation schemas const createBlogSchema = z.object({ title: z.string().min(3).max(200), @@ -211,7 +232,14 @@ export async function GET( switch (action) { case "stats": { const stats = await getBlogAdminStats(context); - return NextResponse.json({ success: true, data: stats }); + const response = NextResponse.json({ success: true, data: stats }); + + // Set session cookie on successful authentication + if (!request.cookies.get("PORTFOLIO_ADMIN_SESSION")) { + setAdminSessionCookie(response, clientIP); + } + + return response; } case "list": { @@ -487,6 +515,15 @@ export async function POST( }); } + case "logout": { + const response = NextResponse.json({ + success: true, + message: "Logged out successfully", + }); + clearAdminSessionCookie(response, request); + return response; + } + default: return NextResponse.json({ error: "Invalid action" }, { status: 400 }); } diff --git a/app/api/admin/chatbot/logs/route.ts b/app/api/admin/chatbot/logs/route.ts new file mode 100644 index 0000000..d6e03ac --- /dev/null +++ b/app/api/admin/chatbot/logs/route.ts @@ -0,0 +1,255 @@ +/** + * Admin API - ChatBot Logs Endpoint + * @author ColdByDefault + * @copyright 2026 ColdByDefault. All Rights Reserved. + */ + +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import type { ChatLogsResponse, ChatSessionLog } from "@/types/configs/chatbot"; +import { prisma } from "@/lib/configs/prisma"; + +const ADMIN_TOKEN = process.env.ADMIN_TOKEN; + +function isAuthorized(request: NextRequest): boolean { + if (!ADMIN_TOKEN) return false; + + const authHeader = request.headers.get("authorization"); + if (!authHeader) return false; + + const token = authHeader.startsWith("Bearer ") + ? authHeader.substring(7) + : authHeader; + + if (token.length !== ADMIN_TOKEN.length) return false; + + let isEqual = true; + for (let i = 0; i < token.length; i++) { + if (token[i] !== ADMIN_TOKEN[i]) isEqual = false; + } + + return isEqual; +} + +// Validation schema for query params +const chatLogsFilterSchema = z.object({ + startDate: z.string().datetime().optional(), + endDate: z.string().datetime().optional(), + country: z.string().max(50).optional(), + searchQuery: z.string().max(200).optional(), + minMessages: z.coerce.number().min(0).max(1000).optional(), + hasConsent: z + .string() + .transform((val) => val === "true") + .optional(), + isActive: z + .string() + .transform((val) => val === "true") + .optional(), + limit: z.coerce.number().min(1).max(100).default(20), + offset: z.coerce.number().min(0).default(0), +}); + +export async function GET( + request: NextRequest, +): Promise> { + if (!isAuthorized(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { searchParams } = new URL(request.url); + + // Validate query parameters + const filters = chatLogsFilterSchema.parse({ + startDate: searchParams.get("startDate") || undefined, + endDate: searchParams.get("endDate") || undefined, + country: searchParams.get("country") || undefined, + searchQuery: searchParams.get("searchQuery") || undefined, + minMessages: searchParams.get("minMessages") || undefined, + hasConsent: searchParams.get("hasConsent") || undefined, + isActive: searchParams.get("isActive") || undefined, + limit: searchParams.get("limit") || "20", + offset: searchParams.get("offset") || "0", + }); + + // Build where clause + const where: Record = {}; + + if (filters.startDate) { + where.startedAt = { + ...((where.startedAt as Record) || {}), + gte: new Date(filters.startDate), + }; + } + + if (filters.endDate) { + where.startedAt = { + ...((where.startedAt as Record) || {}), + lte: new Date(filters.endDate), + }; + } + + if (filters.country) { + where.ipCountry = filters.country; + } + + if (filters.minMessages !== undefined) { + where.totalMessages = { + gte: filters.minMessages, + }; + } + + if (filters.hasConsent !== undefined) { + where.consentGiven = filters.hasConsent; + } + + if (filters.isActive !== undefined) { + where.isActive = filters.isActive; + } + + // Get total count + const total = await prisma.chatSession.count({ where }); + + // Fetch sessions with messages + const sessions = await prisma.chatSession.findMany({ + where, + include: { + messages: { + orderBy: { + timestamp: "asc", + }, + }, + }, + orderBy: { + startedAt: "desc", + }, + take: filters.limit, + skip: filters.offset, + }); + + // Transform to response format + const sessionsData: ChatSessionLog[] = sessions.map( + (session: { + id: string; + ipAddress: string | null; + ipCountry: string | null; + ipCity: string | null; + userAgent: string | null; + language: string | null; + startedAt: Date; + lastActivityAt: Date; + endedAt: Date | null; + isActive: boolean; + consentGiven: boolean; + consentTimestamp: Date | null; + totalMessages: number; + messages: Array<{ + id: string; + sessionId: string; + role: string; + content: string; + timestamp: Date; + status: string | null; + pageContext: string | null; + errorDetails: string | null; + }>; + }) => ({ + id: session.id, + ipAddress: session.ipAddress, + ipCountry: session.ipCountry, + ipCity: session.ipCity, + userAgent: session.userAgent, + language: session.language, + startedAt: session.startedAt, + lastActivityAt: session.lastActivityAt, + endedAt: session.endedAt, + isActive: session.isActive, + consentGiven: session.consentGiven, + consentTimestamp: session.consentTimestamp, + totalMessages: session.totalMessages, + messages: session.messages.map((msg) => ({ + id: msg.id, + sessionId: msg.sessionId, + role: msg.role as "user" | "assistant", + content: msg.content, + timestamp: msg.timestamp, + status: msg.status, + pageContext: msg.pageContext, + errorDetails: msg.errorDetails, + })), + }), + ); + + // Apply search filter on content (if provided) + const filteredSessions = filters.searchQuery + ? sessionsData.filter((session) => + session.messages?.some((msg) => + msg.content + .toLowerCase() + .includes(filters.searchQuery!.toLowerCase()), + ), + ) + : sessionsData; + + return NextResponse.json({ + sessions: filteredSessions, + total, + hasMore: filters.offset + filters.limit < total, + }); + } catch (error) { + console.error("Error fetching chat logs:", error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { + error: `Validation error: ${error.issues.map((e) => e.message).join(", ")}`, + }, + { status: 400 }, + ); + } + + return NextResponse.json( + { error: "Failed to fetch chat logs" }, + { status: 500 }, + ); + } +} + +/** + * DELETE /api/admin/chatbot/logs/:sessionId + * Delete a specific chat session and its messages + */ +export async function DELETE( + request: NextRequest, +): Promise> { + if (!isAuthorized(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { searchParams } = new URL(request.url); + const sessionId = searchParams.get("sessionId"); + + if (!sessionId) { + return NextResponse.json( + { error: "Session ID is required" }, + { status: 400 }, + ); + } + + // Delete session (messages will be cascade deleted) + await prisma.chatSession.delete({ + where: { id: sessionId }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting chat session:", error); + return NextResponse.json( + { error: "Failed to delete chat session" }, + { status: 500 }, + ); + } +} diff --git a/app/api/chatbot/route.ts b/app/api/chatbot/route.ts index 529c571..9da91b8 100644 --- a/app/api/chatbot/route.ts +++ b/app/api/chatbot/route.ts @@ -21,6 +21,13 @@ import { REEM_SYSTEM_PROMPT, REEM_CONFIG, } from "@/data/main/chatbot-system-prompt"; +import { + getGeoIPInfo, + anonymizeIP, + isChatLoggingEnabled, + shouldAnonymizeIP, +} from "@/lib/chatbot-logging"; +import { prisma } from "@/lib/configs/prisma"; // Environment configuration with validation const GROQ_API_KEY = process.env.GROQ_API_KEY; @@ -68,9 +75,11 @@ const chatRequestSchema = z.object({ page: z.string().max(200).optional(), userAgent: z.string().max(500).optional(), timestamp: z.number().optional(), + language: z.string().max(10).optional(), }) .optional(), csrfToken: z.string().min(32).max(64).optional(), + consentGiven: z.boolean().optional(), }); // In-memory rate limiting and session storage @@ -234,6 +243,99 @@ async function callGroqAPI( return data.choices[0].message.content; } +/** + * Log chat session and messages to database (if logging is enabled AND user consented) + */ +async function logChatToDB( + sessionId: string, + clientIP: string, + userMessage: ChatMessage, + assistantMessage: ChatMessage, + requestBody: ChatBotRequest, +): Promise { + if (!isChatLoggingEnabled()) { + return; // Logging disabled + } + + const hasConsent = requestBody.consentGiven || false; + + // GDPR Compliance: If user declined consent, don't save ANY data + if (!hasConsent) { + return; // No logging without consent - ephemeral chat only + } + + try { + // User consented - collect data with privacy protections + const ipToStore = shouldAnonymizeIP() ? anonymizeIP(clientIP) : clientIP; + const geoInfo = await getGeoIPInfo(clientIP); + + // Check if session exists + const existingSession = await prisma.chatSession.findUnique({ + where: { id: sessionId }, + }); + + if (!existingSession) { + // Create new session + await prisma.chatSession.create({ + data: { + id: sessionId, + ipAddress: ipToStore, + ipCountry: geoInfo.country || null, + ipCity: geoInfo.city || null, + userAgent: requestBody.context?.userAgent || null, + language: requestBody.context?.language || null, + consentGiven: true, + consentTimestamp: new Date(), + totalMessages: 2, // User + assistant message + }, + }); + } else { + // Update existing session + await prisma.chatSession.update({ + where: { id: sessionId }, + data: { + lastActivityAt: new Date(), + totalMessages: existingSession.totalMessages + 2, + // Update consent if not already recorded + ...(!existingSession.consentGiven && { + consentGiven: true, + consentTimestamp: new Date(), + }), + }, + }); + } + + // Log both messages with full context + await prisma.chatMessage.createMany({ + data: [ + { + id: userMessage.id, + sessionId, + role: userMessage.role, + content: userMessage.content, + timestamp: userMessage.timestamp, + status: userMessage.status || null, + pageContext: requestBody.context?.page || null, + errorDetails: null, + }, + { + id: assistantMessage.id, + sessionId, + role: assistantMessage.role, + content: assistantMessage.content, + timestamp: assistantMessage.timestamp, + status: assistantMessage.status || null, + pageContext: requestBody.context?.page || null, + errorDetails: null, + }, + ], + }); + } catch (error) { + console.error("Failed to log chat to database:", error); + // Don't throw - logging failure shouldn't break the chat functionality + } +} + // API Routes export async function POST( request: NextRequest, @@ -354,6 +456,17 @@ export async function POST( // Update session storage sessions.set(sessionId, sessionMessages); + // Log to database (async, non-blocking) + logChatToDB( + sessionId, + clientIP, + userMessage, + assistantMessage, + requestBody, + ).catch((err) => { + console.error("Chat logging failed:", err); + }); + // Cleanup old sessions and rate limits periodically during requests if (Math.random() < 0.1) { cleanupSessions(); diff --git a/components/chatbot/ChatBot.tsx b/components/chatbot/ChatBot.tsx index 3a96273..f99f34c 100644 --- a/components/chatbot/ChatBot.tsx +++ b/components/chatbot/ChatBot.tsx @@ -11,7 +11,7 @@ import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; -import { Bot, CircleAlert, Sparkles } from "lucide-react"; +import { Bot, CircleAlert, Shield, Sparkles } from "lucide-react"; import { useChatBot } from "@/components/chatbot"; import type { ChatBotUIProps, ChatMessage } from "@/types/configs/chatbot"; import { @@ -40,7 +40,20 @@ export function ChatBot({ const chatInputRef = useRef(null); const t = useTranslations("ChatBot"); - const { messages, isLoading, error, sendMessage, clearError } = useChatBot(); + const { + messages, + isLoading, + error, + consentGiven, + sendMessage, + clearError, + setConsent, + } = useChatBot(); + const [consentDismissed, setConsentDismissed] = useState(false); + + // Derive consent banner visibility from state (no effect needed) + const showConsentBanner = + isOpen && !consentGiven && !consentDismissed && messages.length === 0; // Show ChatBot button after configured delay useEffect(() => { @@ -115,6 +128,16 @@ export function ChatBot({ }, 100); }; + const handleAcceptConsent = () => { + setConsent(true); + setConsentDismissed(true); + }; + + const handleDeclineConsent = () => { + setConsent(false); + setConsentDismissed(true); + }; + const handleSendMessage = async (message: string) => { try { await sendMessage(message); @@ -162,7 +185,48 @@ export function ChatBot({ > - + {/* Consent Banner */} + {showConsentBanner && ( +
+
+
+
+
+
+

+ {t("consent.title")} +

+

+ {t("consent.description")} +

+
+
+ + +
+
+
+
+ )} + + + {showConsentBanner && ( +
+ )}
{t(CHATBOT_TRANSLATION_KEYS.PRONUNCIATION)} - - v1.0.0 + + v1.3.6
-
+
{/* Powered By Section */}
@@ -98,6 +99,16 @@ export default function Footer() {
+ + {/* Version Section */} +
+ +
+ + {/* Copyright Section */}
diff --git a/components/nav/navbarItems.tsx b/components/nav/navbarItems.tsx index 525eb29..469e468 100644 --- a/components/nav/navbarItems.tsx +++ b/components/nav/navbarItems.tsx @@ -1,7 +1,7 @@ /** * @author ColdByDefault * @copyright 2026 ColdByDefault. All Rights Reserved. -*/ + */ "use client"; @@ -293,7 +293,9 @@ export function BrandLogo() { width={120} height={40} priority - className="h-8 w-auto dark:hidden" + unoptimized + className="h-8 w-auto dark:hidden object-contain" + style={{ mixBlendMode: "normal" }} />
diff --git a/hooks/use-chatbot.ts b/hooks/use-chatbot.ts index 888ede5..43cd174 100644 --- a/hooks/use-chatbot.ts +++ b/hooks/use-chatbot.ts @@ -19,9 +19,11 @@ interface UseChatBotReturn { isLoading: boolean; isConnected: boolean; error: string | null; + consentGiven: boolean; sendMessage: (content: string) => Promise; clearError: () => void; clearMessages: () => void; + setConsent: (consent: boolean) => void; } export function useChatBot(): UseChatBotReturn { @@ -29,11 +31,13 @@ export function useChatBot(): UseChatBotReturn { const [isLoading, setIsLoading] = useState(false); const [isConnected, setIsConnected] = useState(true); const [error, setError] = useState(null); + const [consentGiven, setConsentGiven] = useState(false); const sessionIdRef = useRef(null); // Storage keys for persistence const STORAGE_KEY_MESSAGES = "chatbot_messages"; const STORAGE_KEY_SESSION = "chatbot_session"; + const STORAGE_KEY_CONSENT = "chatbot_consent"; // Load messages from localStorage on component mount useEffect(() => { @@ -41,6 +45,7 @@ export function useChatBot(): UseChatBotReturn { try { const savedMessages = localStorage.getItem(STORAGE_KEY_MESSAGES); const savedSession = localStorage.getItem(STORAGE_KEY_SESSION); + const savedConsent = localStorage.getItem(STORAGE_KEY_CONSENT); if (savedMessages) { const parsedMessages = JSON.parse(savedMessages) as ChatMessage[]; @@ -57,10 +62,15 @@ export function useChatBot(): UseChatBotReturn { if (savedSession) { sessionIdRef.current = savedSession; } + + if (savedConsent === "true") { + setConsentGiven(true); + } } catch { // Clear corrupted data silently localStorage.removeItem(STORAGE_KEY_MESSAGES); localStorage.removeItem(STORAGE_KEY_SESSION); + localStorage.removeItem(STORAGE_KEY_CONSENT); } } }, []); @@ -122,6 +132,19 @@ export function useChatBot(): UseChatBotReturn { if (typeof window !== "undefined") { localStorage.removeItem(STORAGE_KEY_MESSAGES); localStorage.removeItem(STORAGE_KEY_SESSION); + localStorage.removeItem(STORAGE_KEY_CONSENT); + } + }, []); + + const setConsent = useCallback((consent: boolean) => { + setConsentGiven(consent); + // Save consent to localStorage + if (typeof window !== "undefined") { + try { + localStorage.setItem(STORAGE_KEY_CONSENT, String(consent)); + } catch { + // Storage quota exceeded or disabled - fail silently + } } }, []); @@ -156,7 +179,12 @@ export function useChatBot(): UseChatBotReturn { : undefined, userAgent: typeof window !== "undefined" ? navigator.userAgent : undefined, + language: + typeof window !== "undefined" + ? navigator.language || undefined + : undefined, }, + consentGiven, // Include consent status }; const response = await fetch("/api/chatbot", { @@ -268,7 +296,7 @@ export function useChatBot(): UseChatBotReturn { setIsLoading(false); } }, - [isLoading], + [isLoading, consentGiven], ); return { @@ -276,8 +304,10 @@ export function useChatBot(): UseChatBotReturn { isLoading, isConnected, error, + consentGiven, sendMessage, clearError, clearMessages, + setConsent, }; } diff --git a/lib/chatbot-logging.ts b/lib/chatbot-logging.ts new file mode 100644 index 0000000..4939b5e --- /dev/null +++ b/lib/chatbot-logging.ts @@ -0,0 +1,106 @@ +/** + * ChatBot Logging Utilities + * @author ColdByDefault + * @copyright 2026 ColdByDefault. All Rights Reserved. + */ + +import type { GeoIPInfo } from "@/types/configs/chatbot"; + +/** + * Get geolocation info from IP address using a free API + * Uses ip-api.com (free, no API key required, 45 requests/minute limit) + * @param ip - IP address to lookup + * @returns GeoIP information (country, city, timezone) + */ +export async function getGeoIPInfo( + ip: string, +): Promise> { + // Don't lookup localhost or private IPs + if ( + ip === "127.0.0.1" || + ip === "::1" || + ip.startsWith("192.168.") || + ip.startsWith("10.") || + ip.startsWith("172.") + ) { + return { + country: "LOCAL", + city: "Local", + timezone: "UTC", + }; + } + + try { + // Using ip-api.com - free tier allows 45 requests/minute + const response = await fetch( + `http://ip-api.com/json/${ip}?fields=country,city,timezone,status,message`, + { + method: "GET", + headers: { + "User-Agent": "Portfolio-Chatbot/1.0", + }, + // Cache for 1 hour to reduce API calls + next: { revalidate: 3600 }, + }, + ); + + if (!response.ok) { + console.warn(`GeoIP API returned status: ${response.status}`); + return {}; + } + + const data = (await response.json()) as { + status?: string; + country?: string; + city?: string; + timezone?: string; + message?: string; + }; + + if (data.status === "fail") { + console.warn(`GeoIP lookup failed: ${data.message || "Unknown error"}`); + return {}; + } + + const result: GeoIPInfo = {}; + if (data.country) result.country = data.country; + if (data.city) result.city = data.city; + if (data.timezone) result.timezone = data.timezone; + return result; + } catch (error) { + console.error("Error fetching GeoIP info:", error); + return {}; + } +} + +/** + * Anonymize IP address by removing last octet (IPv4) or last 4 groups (IPv6) + * This helps with GDPR compliance by not storing full IP addresses + * @param ip - IP address to anonymize + * @returns Anonymized IP address + */ +export function anonymizeIP(ip: string): string { + if (ip.includes(":")) { + // IPv6 - remove last 4 groups + const parts = ip.split(":"); + return parts.slice(0, -4).join(":") + "::0"; + } else { + // IPv4 - remove last octet + const parts = ip.split("."); + return parts.slice(0, -1).join(".") + ".0"; + } +} + +/** + * Check if chat logging is enabled via environment variable + */ +export function isChatLoggingEnabled(): boolean { + return process.env.CHATBOT_LOGGING_ENABLED === "true"; +} + +/** + * Check if IP should be anonymized (GDPR compliance) + */ +export function shouldAnonymizeIP(): boolean { + return process.env.CHATBOT_ANONYMIZE_IP !== "false"; // Default to true +} diff --git a/messages/de.json b/messages/de.json index 94d2143..c684fe2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -228,8 +228,8 @@ "description": "Diese Website enthält einen KI-Chatbot für interaktive Unterstützung und Informationen.", "apiTitle": "Groq API Integration", "apiDescription": "Der Chatbot wird von Groq betrieben. Durch die Nutzung des Chatbots akzeptieren Sie die Nutzungsbedingungen und Datenschutzrichtlinien von Groq. Chat-Anfragen werden über die Groq API verarbeitet.", - "temporaryTitle": "Keine Datenspeicherung", - "temporaryDescription": "Ich speichere keine Chat-Gespräche, Verläufe oder persönlichen Informationen. Alle Chat-Daten werden ausschließlich in Ihrem Browser (Web-Storage) gespeichert und verbleiben vollständig unter Ihrer Kontrolle." + "temporaryTitle": "Optionale Datenspeicherung (mit Einwilligung)", + "temporaryDescription": "Mit Ihrer ausdrücklichen Einwilligung können Chat-Gespräche zur Qualitätsverbesserung und Analyse gespeichert werden. Dies umfasst: Chat-Nachrichten, Zeitstempel, anonymisierte IP-Adresse und allgemeinen Standort (Land/Stadt). Sie können Ihre Einwilligung jederzeit widerrufen. Ohne Einwilligung verbleiben alle Chat-Daten ausschließlich in Ihrem Browser." }, "booking": { "title": "Buchungsservice", @@ -375,6 +375,12 @@ }, "accessibility": { "sendMessage": "Nachricht senden" + }, + "consent": { + "title": "Datenschutz & Datenerfassung", + "description": "Ich speichere Chat-Gespräche zu Verbesserungszwecken. Alle DSGVO-Regeln für EU- und EWR-Nutzer werden vollständig angewendet. Sie können ablehnen und privat chatten.", + "accept": "Akzeptieren & Fortfahren", + "decline": "Ablehnen" } }, "Services": { diff --git a/messages/en.json b/messages/en.json index 796e33a..71cb7b9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -228,8 +228,8 @@ "description": "This website includes an AI chatbot for interactive assistance and information.", "apiTitle": "Groq API Integration", "apiDescription": "The chatbot is powered by Groq. By using the chatbot, you accept Groq's terms of service and privacy policy. Chat requests are processed via the Groq API.", - "temporaryTitle": "No Data Storage", - "temporaryDescription": "I do not save any chat conversations, history, or personal information. All chat data is stored exclusively in your browser (web storage) and remains entirely under your control." + "temporaryTitle": "Optional Data Storage (With Consent)", + "temporaryDescription": "With your explicit consent, I may store chat conversations for quality improvement and analytics purposes. This includes: chat messages, timestamps, anonymized IP address, and general location (country/city). You can withdraw consent at any time. Without consent, all chat data remains in your browser only." }, "booking": { "title": "Booking Service", @@ -375,6 +375,12 @@ }, "accessibility": { "sendMessage": "Send message" + }, + "consent": { + "title": "Privacy & Data Collection", + "description": "I save chat conversations for improvement purposes. All GDPR rules for EU and EEA users are fully applied. You can decline and chat privately.", + "accept": "Accept & Continue", + "decline": "Decline" } }, "Services": { diff --git a/messages/es.json b/messages/es.json index 2be00ba..7aab07f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -228,8 +228,8 @@ "description": "Este sitio web incluye un chatbot de IA para asistencia interactiva e información.", "apiTitle": "Integración de Groq API", "apiDescription": "El chatbot funciona con Groq. Al usar el chatbot, aceptas los términos de servicio y la política de privacidad de Groq. Las solicitudes del chat se procesan a través de la API de Groq.", - "temporaryTitle": "Sin Almacenamiento de Datos", - "temporaryDescription": "No guardo ninguna conversación, historial o información personal. Todos los datos del chat se almacenan exclusivamente en tu navegador (almacenamiento web) y permanecen completamente bajo tu control." + "temporaryTitle": "Almacenamiento Opcional de Datos (Con Consentimiento)", + "temporaryDescription": "Con tu consentimiento explícito, podemos almacenar las conversaciones del chat para mejorar la calidad y con fines analíticos. Esto incluye: mensajes del chat, marcas de tiempo, dirección IP anonimizada y ubicación general (país/ciudad). Puedes retirar tu consentimiento en cualquier momento. Sin consentimiento, todos los datos del chat permanecen solo en tu navegador." }, "booking": { "title": "Servicio de Reservas", @@ -375,6 +375,12 @@ }, "accessibility": { "sendMessage": "Enviar mensaje" + }, + "consent": { + "title": "Privacidad y Recopilación de Datos", + "description": "Guardo las conversaciones del chat con fines de mejora. Se aplican todas las normas del RGPD para usuarios de la UE y el EEE. Puedes rechazar y chatear de forma privada.", + "accept": "Aceptar y Continuar", + "decline": "Rechazar" } }, "Services": { diff --git a/messages/fr.json b/messages/fr.json index 986d03b..514fd6f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -228,8 +228,8 @@ "description": "Ce site web inclut un chatbot IA pour l'assistance interactive et l'information.", "apiTitle": "Intégration de l'API Groq", "apiDescription": "Le chatbot est alimenté par Groq. En utilisant le chatbot, vous acceptez les conditions d'utilisation et la politique de confidentialité de Groq. Les requêtes de chat sont traitées via l'API Groq.", - "temporaryTitle": "Aucun Stockage de Données", - "temporaryDescription": "Je ne sauvegarde aucune conversation, historique ou information personnelle. Toutes les données de chat sont stockées exclusivement dans votre navigateur (stockage web) et restent entièrement sous votre contrôle." + "temporaryTitle": "Stockage Optionnel des Données (Avec Consentement)", + "temporaryDescription": "Avec votre consentement explicite, les conversations du chat peuvent être enregistrées à des fins d'amélioration de la qualité et d'analyse. Cela inclut : les messages du chat, les horodatages, l'adresse IP anonymisée et la localisation générale (pays/ville). Vous pouvez retirer votre consentement à tout moment. Sans consentement, toutes les données du chat restent uniquement dans votre navigateur." }, "booking": { "title": "Service de Réservation", @@ -375,6 +375,12 @@ }, "accessibility": { "sendMessage": "Envoyer le message" + }, + "consent": { + "title": "Confidentialité et Collecte de Données", + "description": "J'enregistre les conversations de chat à des fins d'amélioration. Toutes les règles RGPD pour les utilisateurs de l'UE et de l'EEE sont pleinement appliquées. Vous pouvez refuser et discuter en privé.", + "accept": "Accepter et Continuer", + "decline": "Refuser" } }, "Services": { diff --git a/messages/sv.json b/messages/sv.json index 1aeb572..40138f7 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -228,8 +228,8 @@ "description": "Denna webbplats inkluderar en AI-chatbot för interaktiv assistans och information.", "apiTitle": "Groq API Integration", "apiDescription": "Chatboten drivs av Groq. Genom att använda chatboten accepterar du Groqs användarvillkor och integritetspolicy. Chattförfrågningar bearbetas via Groq API.", - "temporaryTitle": "Ingen Datalagring", - "temporaryDescription": "Jag sparar inga chattkonversationer, historik eller personlig information. All chattdata lagras exklusivt i din webbläsare (webblagring) och förblir helt under din kontroll." + "temporaryTitle": "Valfri Datalagring (Med Samtycke)", + "temporaryDescription": "Med ditt uttryckliga samtycke kan chattkonversationer sparas för kvalitetsförbättring och analysändamål. Detta inkluderar: chattmeddelanden, tidsstämplar, anonymiserad IP-adress och allmän plats (land/stad). Du kan återkalla ditt samtycke när som helst. Utan samtycke förblir all chattdata enbart i din webbläsare." }, "booking": { "title": "Bokningstjänst", @@ -375,6 +375,12 @@ }, "accessibility": { "sendMessage": "Skicka meddelande" + }, + "consent": { + "title": "Integritet & Datainsamling", + "description": "Jag sparar chattkonversationer i förbättringssyfte. Alla GDPR-regler för EU- och EES-användare tillämpas fullt ut. Du kan avböja och chatta privat.", + "accept": "Acceptera & Fortsätt", + "decline": "Avböj" } }, "Services": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bd1d723..55a0c56 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -123,3 +123,60 @@ model BlogTagRelation { @@unique([blogId, tagId]) @@map("blog_tag_relations") } + +// Chatbot Models +model ChatSession { + id String @id // session_timestamp_randomhex format + ipAddress String? // Client IP (anonymized if needed) + ipCountry String? // Country from IP (e.g., "US", "DE") + ipCity String? // City from IP (optional, for analytics) + userAgent String? // Browser/device info + language String? // User's language preference + + // Session tracking + startedAt DateTime @default(now()) + lastActivityAt DateTime @updatedAt + endedAt DateTime? // When session explicitly ended + isActive Boolean @default(true) + + // User consent & privacy + consentGiven Boolean @default(false) // GDPR/privacy consent + consentTimestamp DateTime? // When consent was given + + // Session metadata + totalMessages Int @default(0) + + // Relations + messages ChatMessage[] + + @@map("chat_sessions") + @@index([ipAddress]) + @@index([startedAt]) + @@index([ipCountry]) + @@index([isActive]) +} + +model ChatMessage { + id String @id // msg_timestamp_randomhex format + sessionId String + + // Message content + role String // "user" or "assistant" + content String // The actual message (use @db.Text for large content) + + // Message metadata + timestamp DateTime @default(now()) + status String? // "sending", "sent", "error" + + // Context (optional) + pageContext String? // What page user was on + errorDetails String? // If status is error + + // Relations + session ChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + + @@map("chat_messages") + @@index([sessionId]) + @@index([timestamp]) + @@index([role]) +} diff --git a/proxy.ts b/proxy.ts index 8d119fd..fa421f7 100644 --- a/proxy.ts +++ b/proxy.ts @@ -10,6 +10,118 @@ import type { NextRequest } from "next/server"; // Supported locales const supportedLocales = ["en", "de", "es", "fr", "sv"]; +const failedAttempts = new Map(); +const blockedIPs = new Map(); +const BLOCK_DURATION = 15 * 60 * 1000; +const MAX_ATTEMPTS = 2; + +// Session validation: Store valid sessions with metadata +interface SessionData { + createdAt: number; + expiresAt: number; + ip: string; + signature: string; +} + +const validSessions = new Map(); +const SESSION_DURATION = 8 * 60 * 60 * 1000; // 8 hours + +function getClientIP(request: NextRequest): string { + const forwarded = request.headers.get("x-forwarded-for"); + if (forwarded) return forwarded.split(",")[0]?.trim() || "unknown"; + const realIP = request.headers.get("x-real-ip"); + if (realIP) return realIP; + return "unknown"; +} + +function isIPBlocked(ip: string): boolean { + const blockExpiry = blockedIPs.get(ip); + if (!blockExpiry) return false; + + if (Date.now() < blockExpiry) return true; + + // Expired, clean up + blockedIPs.delete(ip); + failedAttempts.delete(ip); + return false; +} + +function recordFailedAttempt(ip: string): void { + const attempts = (failedAttempts.get(ip) || 0) + 1; + failedAttempts.set(ip, attempts); + + if (attempts >= MAX_ATTEMPTS) { + blockedIPs.set(ip, Date.now() + BLOCK_DURATION); + } +} + +function hasValidAdminSession(request: NextRequest): boolean { + const sessionCookie = request.cookies.get("PORTFOLIO_ADMIN_SESSION"); + if (!sessionCookie?.value) return false; + + const sessionId = sessionCookie.value; + const sessionData = validSessions.get(sessionId); + + if (!sessionData) return false; + + const now = Date.now(); + + // Check expiration + if (now > sessionData.expiresAt) { + validSessions.delete(sessionId); + return false; + } + + const currentIP = getClientIP(request); + if (sessionData.ip !== currentIP) { + validSessions.delete(sessionId); + return false; + } + + return true; +} + +export function createAdminSession(ip: string): string { + const randomBytes = new Uint8Array(32); + crypto.getRandomValues(randomBytes); + const sessionId = Array.from(randomBytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + const now = Date.now(); + const expiresAt = now + SESSION_DURATION; + + const signature = `${sessionId}:${ip}:${expiresAt}`; + + const sessionData: SessionData = { + createdAt: now, + expiresAt, + ip, + signature, + }; + + validSessions.set(sessionId, sessionData); + + if (validSessions.size % 10 === 0) { + cleanupExpiredSessions(); + } + + return sessionId; +} + +export function invalidateAdminSession(sessionId: string): void { + validSessions.delete(sessionId); +} + +function cleanupExpiredSessions(): void { + const now = Date.now(); + for (const [sessionId, data] of validSessions.entries()) { + if (now > data.expiresAt) { + validSessions.delete(sessionId); + } + } +} + /** * Handle automatic locale detection based on browser language * Sets PORTFOLIOVERSIONLATEST_LOCALE for i18n and PORTFOLIOVERSIONLATEST_BROWSER_LANG for UI hints @@ -20,7 +132,7 @@ const supportedLocales = ["en", "de", "es", "fr", "sv"]; function handleLocaleDetection(request: NextRequest): NextResponse | null { // Check if locale cookie already exists - this is the only required check const existingLocale = request.cookies.get( - "PORTFOLIOVERSIONLATEST_LOCALE" + "PORTFOLIOVERSIONLATEST_LOCALE", )?.value; // If user already has a valid locale preference, respect it @@ -98,6 +210,45 @@ function handleLocaleDetection(request: NextRequest): NextResponse | null { export function proxy(request: NextRequest) { const pathname = request.nextUrl.pathname; + const isDev = process.env.NODE_ENV === "development"; + + // Admin route protection - Minimal server-side security (disabled in dev) + if ( + pathname.startsWith("/admin") && + !pathname.startsWith("/admin/blocked") && + !isDev + ) { + const clientIP = getClientIP(request); + + // Check if IP is blocked + if (isIPBlocked(clientIP)) { + return NextResponse.redirect(new URL("/admin/blocked", request.url)); + } + + // Check for valid admin session + if (!hasValidAdminSession(request)) { + // Record failed page access attempt + recordFailedAttempt(clientIP); + + const attempts = failedAttempts.get(clientIP) || 0; + + // If just exceeded limit, redirect to blocked page + if (attempts >= MAX_ATTEMPTS) { + return NextResponse.redirect(new URL("/admin/blocked", request.url)); + } + + // Allow access but add warning header + const response = NextResponse.next(); + response.headers.set( + "X-Remaining-Attempts", + String(MAX_ATTEMPTS - attempts), + ); + return response; + } + + // Valid session - clear attempts and allow access + failedAttempts.delete(clientIP); + } // Enhanced security for ChatBot API if (pathname.startsWith("/api/chatbot")) { @@ -159,7 +310,7 @@ export function proxy(request: NextRequest) { return NextResponse.redirect( new URL(redirectPath, request.url), - { status: 301 } // Permanent redirect for SEO + { status: 301 }, // Permanent redirect for SEO ); } diff --git a/types/configs/chatbot.ts b/types/configs/chatbot.ts index eb7b140..f66a3a9 100644 --- a/types/configs/chatbot.ts +++ b/types/configs/chatbot.ts @@ -2,7 +2,7 @@ * ChatBot Interface Types * @author ColdByDefault * @copyright 2026 ColdByDefault. All Rights Reserved. -*/ + */ export interface ChatMessage { id: string; @@ -37,9 +37,11 @@ export interface ChatBotRequest { page?: string | undefined; userAgent?: string | undefined; timestamp?: number | undefined; + language?: string | undefined; } | undefined; csrfToken?: string | undefined; + consentGiven?: boolean | undefined; // User consent for data logging } export interface ChatBotResponse { @@ -131,3 +133,56 @@ export interface ChatBotAnalytics { errorRate: number; lastResetTime: number; } + +// Chat Logging Types +export interface ChatSessionLog { + id: string; + ipAddress: string | null; + ipCountry: string | null; + ipCity: string | null; + userAgent: string | null; + language: string | null; + startedAt: Date; + lastActivityAt: Date; + endedAt: Date | null; + isActive: boolean; + consentGiven: boolean; + consentTimestamp: Date | null; + totalMessages: number; + messages?: ChatMessageLog[]; +} + +export interface ChatMessageLog { + id: string; + sessionId: string; + role: "user" | "assistant"; + content: string; + timestamp: Date; + status: string | null; + pageContext: string | null; + errorDetails: string | null; +} + +export interface ChatLogsFilter { + startDate?: Date; + endDate?: Date; + country?: string; + searchQuery?: string; + minMessages?: number; + hasConsent?: boolean; + isActive?: boolean; + limit?: number; + offset?: number; +} + +export interface ChatLogsResponse { + sessions: ChatSessionLog[]; + total: number; + hasMore: boolean; +} + +export interface GeoIPInfo { + country?: string; + city?: string; + timezone?: string; +} diff --git a/types/main/admin.ts b/types/main/admin.ts index 14c25dc..3f64e59 100644 --- a/types/main/admin.ts +++ b/types/main/admin.ts @@ -2,7 +2,7 @@ * Admin Interface Types * @author ColdByDefault * @copyright 2026 ColdByDefault. All Rights Reserved. -*/ + */ export interface AdminStats { totalSubmissions: number; @@ -89,11 +89,12 @@ export interface BlogAdminAction { | "publish" | "unpublish" | "feature" - | "unfeature"; + | "unfeature" + | "logout"; blogId?: string; data?: unknown; } export interface BlogAdminResponse extends ApiResponse { data?: unknown; -} \ No newline at end of file +}