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.

-**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
+}