Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion frontend/app/api/ai/explain/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
createExplainPrompt,
type ExplanationResponse,
} from '@/lib/ai/prompts';
import { getClientIp } from '@/lib/security/rate-limit';
import { getClientIp } from '@/lib/security/client-ip';

const rateLimiter = new Map<string, { count: number; resetAt: number }>();
const MAX_REQUESTS_PER_WINDOW = 10;
Expand Down Expand Up @@ -94,6 +94,9 @@ export async function POST(request: NextRequest) {
const apiKey = process.env.GROQ_API_KEY;
if (!apiKey) {
console.error('GROQ_API_KEY is not configured');
console.error('Available env vars starting with GROQ:',
Object.keys(process.env).filter(k => k.startsWith('GROQ'))
);
return NextResponse.json(
{ error: 'AI service not configured', code: 'SERVICE_UNAVAILABLE' },
{ status: 503 }
Expand Down
19 changes: 19 additions & 0 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,25 @@ export default function RootLayout({
}>) {
return (
<html lang="uk" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var theme = localStorage.getItem('theme') || 'system';
if (theme === 'system') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
} catch (e) {}
})();
`,
}}
/>
</head>
<body
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-gray-50 text-gray-900 dark:bg-neutral-950 dark:text-gray-100 transition-colors duration-300`}
Expand Down
56 changes: 36 additions & 20 deletions frontend/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { headers } from 'next/headers';
import { cookies } from 'next/headers';

import ukMessages from '@/messages/uk.json';
import enMessages from '@/messages/en.json';
Expand All @@ -15,41 +15,57 @@ const messages = {
};

export default async function NotFound() {
const headersList = await headers();
const xLocale = headersList.get('x-locale');
const cookieStore = await cookies();
const localeCookie = cookieStore.get('NEXT_LOCALE')?.value;
const locale: Locale =
xLocale && locales.includes(xLocale as Locale)
? (xLocale as Locale)
localeCookie && locales.includes(localeCookie as Locale)
? (localeCookie as Locale)
: 'en';
const t = messages[locale];

return (
<main className="relative min-h-screen flex items-center justify-center overflow-hidden bg-white dark:bg-slate-950">
<div
className="absolute inset-0 pointer-events-none -z-10"
aria-hidden="true"
>
<div className="absolute inset-0 bg-gradient-to-b from-sky-50 via-white to-rose-50 dark:from-slate-950 dark:via-slate-950 dark:to-black" />
<div className="absolute top-1/4 left-1/4 h-96 w-[36rem] -translate-x-1/2 rounded-full bg-sky-300/20 blur-3xl dark:bg-sky-500/10" />
<div className="absolute bottom-1/4 right-1/4 h-[26rem] w-[26rem] rounded-full bg-violet-300/30 blur-3xl dark:bg-violet-500/10" />
<main className="relative min-h-screen flex items-center justify-center overflow-hidden bg-background transition-colors duration-300">
<div className="pointer-events-none absolute inset-0 opacity-60">
<div className="absolute -top-32 left-1/2 h-96 w-[36rem] -translate-x-1/2 rounded-full bg-[var(--accent-primary)]/20 blur-3xl" />
<div className="absolute bottom-[-12rem] left-1/4 h-[22rem] w-[22rem] rounded-full bg-[var(--accent-hover)]/15 blur-3xl" />
<div className="absolute bottom-[-10rem] right-0 h-[26rem] w-[26rem] rounded-full bg-[var(--accent-primary)]/25 blur-3xl" />
</div>

<div className="pointer-events-none absolute inset-0 opacity-40 dark:opacity-60">
<span className="absolute left-[10%] top-[18%] h-1 w-1 rounded-full bg-[var(--accent-primary)]" />
<span className="absolute left-[35%] top-[8%] h-1 w-1 rounded-full bg-[var(--accent-hover)]" />
<span className="absolute left-[70%] top-[16%] h-1 w-1 rounded-full bg-[var(--accent-primary)]" />
<span className="absolute left-[80%] top-[40%] h-1 w-1 rounded-full bg-[var(--accent-hover)]" />
<span className="absolute left-[18%] top-[60%] h-1 w-1 rounded-full bg-[var(--accent-primary)]" />
</div>

<div className="relative z-10 text-center px-6 py-12">
<p className="text-8xl md:text-9xl font-black bg-gradient-to-r from-sky-400 via-violet-400 to-pink-400 dark:from-sky-400 dark:via-indigo-400 dark:to-fuchsia-500 bg-clip-text text-transparent">
404
</p>
<div className="relative inline-block mt-4">
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black tracking-tight">
<span className="relative inline-block bg-gradient-to-r from-[var(--accent-primary)]/70 via-[color-mix(in_srgb,var(--accent-primary)_70%,white)]/70 to-[var(--accent-hover)]/70 bg-clip-text text-transparent">
DevL
</span>
<span className="relative inline-block text-red-500 text-[1em] leading-none" style={{ verticalAlign: 'baseline' }}>
Ø
</span>
<span className="relative inline-block bg-gradient-to-r from-[var(--accent-primary)]/70 via-[color-mix(in_srgb,var(--accent-primary)_70%,white)]/70 to-[var(--accent-hover)]/70 bg-clip-text text-transparent">
vers
</span>

</h1>
</div>

<h1 className="mt-6 text-2xl md:text-3xl font-bold text-slate-900 dark:text-white">
<h2 className="mt-6 text-2xl md:text-3xl font-bold text-foreground">
{t.title}
</h1>
</h2>

<p className="mt-4 max-w-md mx-auto text-slate-600 dark:text-slate-400">
<p className="mt-4 max-w-md mx-auto text-muted-foreground">
{t.description}
</p>

<a
href={`/${locale}`}
className="mt-8 inline-flex items-center justify-center rounded-full bg-gradient-to-r from-sky-500 to-violet-500 px-6 py-3 text-sm font-medium text-white transition-all hover:from-sky-600 hover:to-violet-600 hover:shadow-lg hover:shadow-sky-500/25"
className="mt-8 inline-flex items-center justify-center rounded-full bg-[var(--accent-primary)] hover:bg-[var(--accent-hover)] px-6 py-3 text-sm font-medium text-white transition-colors"
>
{t.backHome}
</a>
Expand Down
48 changes: 48 additions & 0 deletions frontend/lib/security/client-ip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import 'server-only';

import type { NextRequest } from 'next/server';
import { isIP } from 'node:net';

function envBool(name: string, fallback: boolean): boolean {
const raw = (process.env[name] ?? '').trim().toLowerCase();
if (!raw) return fallback;

if (raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on')
return true;
if (raw === '0' || raw === 'false' || raw === 'no' || raw === 'off')
return false;

return fallback;
}

export function getClientIpFromHeaders(headers: Headers): string | null {
const trustForwarded = envBool(
'TRUST_FORWARDED_HEADERS',
process.env.NODE_ENV !== 'production'
);
const trustCf = envBool('TRUST_CF_CONNECTING_IP', false);

if (trustCf) {
const cf = (headers.get('cf-connecting-ip') ?? '').trim();
if (cf && isIP(cf)) return cf;
}

if (!trustForwarded) return null;

const xr = (headers.get('x-real-ip') ?? '').trim();
if (xr && isIP(xr)) return xr;

const xff = (headers.get('x-forwarded-for') ?? '').trim();
if (xff) {
for (const part of xff.split(',')) {
const candidate = part.trim();
if (candidate && isIP(candidate)) return candidate;
}
}

return null;
}

export function getClientIp(request: NextRequest): string | null {
return getClientIpFromHeaders(request.headers);
}
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@
"react-dom": "^19.2.1",
"sonner": "^2.0.7",
"stripe": "20.0.0",
"tailwind-merge": "^3.4.0"
"tailwind-merge": "^3.4.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@netlify/plugin-nextjs": "^5.15.1",
Expand Down
5 changes: 5 additions & 0 deletions frontend/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ export function proxy(req: NextRequest) {
intlResponse.headers.set('x-app-scope', scope);
intlResponse.headers.set('x-locale', locale);

intlResponse.cookies.set('NEXT_LOCALE', locale, {
path: '/',
sameSite: 'lax',
});

return intlResponse;
}

Expand Down