diff --git a/next.config.ts b/next.config.ts index e9ffa30..cb651cd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,5 @@ import type { NextConfig } from "next"; -const nextConfig: NextConfig = { - /* config options here */ -}; +const nextConfig: NextConfig = {}; export default nextConfig; diff --git a/src/app/api/script-explain/route.ts b/src/app/api/script-explain/route.ts new file mode 100644 index 0000000..8273308 --- /dev/null +++ b/src/app/api/script-explain/route.ts @@ -0,0 +1,173 @@ +import { NextRequest } from 'next/server'; +import Groq from 'groq-sdk'; +import { checkRateLimit, isValidGroqApiKey } from '@/lib/security'; + +let groq: Groq | null = null; + +// Rate limit: 10 requests per minute per IP +const RATE_LIMIT_MAX = 10; +const RATE_LIMIT_WINDOW_MS = 60 * 1000; + +function getGroqClient() { + if (!groq) { + const apiKey = process.env.GROQ_API_KEY; + if (!apiKey) { + throw new Error('GROQ_API_KEY is not defined'); + } + // SECURITY: Validate API key format to catch misconfigurations early + if (!isValidGroqApiKey(apiKey)) { + console.error('[Security] Invalid GROQ_API_KEY format detected'); + throw new Error('Invalid GROQ_API_KEY format'); + } + groq = new Groq({ apiKey }); + } + return groq; +} + +function getClientIP(request: NextRequest): string { + // Get IP from various headers, fallback to 'unknown' + const forwarded = request.headers.get('x-forwarded-for'); + const realIP = request.headers.get('x-real-ip'); + return forwarded?.split(',')[0]?.trim() || realIP || 'unknown'; +} + +export async function POST(request: NextRequest) { + try { + // SECURITY: Rate limiting check + const clientIP = getClientIP(request); + const rateLimit = checkRateLimit(`script-explain:${clientIP}`, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS); + + if (!rateLimit.allowed) { + return new Response( + JSON.stringify({ + error: 'Rate limit exceeded. Please try again later.', + retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) + }), + { + status: 429, + headers: { + 'X-RateLimit-Limit': String(RATE_LIMIT_MAX), + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), + } + } + ); + } + + const { script, packages, os, shell } = await request.json(); + + if (!script || typeof script !== 'string') { + return new Response(JSON.stringify({ error: 'Invalid script' }), { status: 400 }); + } + + if (!packages || !Array.isArray(packages)) { + return new Response(JSON.stringify({ error: 'Invalid packages' }), { status: 400 }); + } + + // Build the AI prompt + const packageList = packages.map((p: { name: string; description: string; category: string; version: string }) => + `- ${p.name} (${p.category}): ${p.description}${p.version ? ` [v${p.version}]` : ''}` + ).join('\n'); + + const systemPrompt = { + role: 'system' as const, + content: `You are "Root", an expert DevOps assistant for the SudoStart application. + +Your task is to explain installation scripts in plain, friendly English. Help users understand what will be installed and why. + +Guidelines: +- Be concise but informative (2-4 paragraphs) +- Use markdown formatting for readability +- Highlight the purpose of the setup +- Mention any notable tools or configurations +- Keep a helpful, professional tone +- Focus on what the user will get after running the script`, + }; + + const userPrompt = { + role: 'user' as const, + content: `Explain this installation script in plain English: + +**Target System:** +- OS: ${os} +- Shell: ${shell} + +**Tools to Install (${packages.length} total):** +${packageList} + +**Script Preview (first 50 lines):** +\`\`\`bash +${script.split('\n').slice(0, 50).join('\n')} +\`\`\` + +Provide a brief, helpful summary of what this script does and what the user will have after running it.`, + }; + + const client = getGroqClient(); + const stream = await client.chat.completions.create({ + messages: [systemPrompt, userPrompt], + model: 'llama-3.3-70b-versatile', + temperature: 0.7, + max_tokens: 1024, + stream: true, + }); + + // Stream the response as Server-Sent Events + const encoder = new TextEncoder(); + const readable = new ReadableStream({ + async start(controller) { + try { + let fullContent = ''; + for await (const chunk of stream) { + const delta = chunk.choices[0]?.delta?.content ?? ''; + if (delta) { + fullContent += delta; + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ delta, done: false })} + +`) + ); + } + } + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ delta: '', done: true, full: fullContent })} + +`) + ); + controller.close(); + } catch (err) { + controller.error(err); + } + }, + }); + + return new Response(readable, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-RateLimit-Limit': String(RATE_LIMIT_MAX), + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), + }, + }); + } catch (error) { + // SECURITY: Never leak the API key in error messages + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const safeErrorMessage = errorMessage.includes('gsk_') + ? 'Internal server error' + : errorMessage; + + // Log with masked key if present + if (errorMessage.includes('gsk_')) { + console.error('Script explain API error: [REDACTED - API key detected in error]'); + } else { + console.error('Script explain API error:', error); + } + + return new Response( + JSON.stringify({ error: safeErrorMessage }), + { status: 500 } + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index ef00625..f4748cf 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -195,17 +195,17 @@ } [data-theme="dark"] .terminal-text { - text-shadow: 0 0 5px var(--terminal-green); + text-shadow: 0 0 3px color-mix(in oklch, var(--terminal-green) 50%, transparent); } .terminal-glow { - box-shadow: 0 0 10px color-mix(in oklch, var(--primary) 40%, transparent), - 0 0 20px color-mix(in oklch, var(--primary) 20%, transparent); + box-shadow: 0 0 8px color-mix(in oklch, var(--primary) 25%, transparent), + 0 0 16px color-mix(in oklch, var(--primary) 12%, transparent); } [data-theme="light"] .terminal-glow { box-shadow: 0 0 0 1px var(--primary), - 0 2px 8px color-mix(in oklch, var(--primary) 25%, transparent); + 0 2px 6px color-mix(in oklch, var(--primary) 15%, transparent); } .cursor-blink { @@ -239,14 +239,14 @@ .terminal-card { background: var(--terminal-bg); border: 1px solid var(--border); - box-shadow: 0 0 20px color-mix(in oklch, var(--primary) 12%, transparent), - inset 0 0 20px color-mix(in oklch, var(--primary) 4%, transparent); + box-shadow: 0 0 12px color-mix(in oklch, var(--primary) 8%, transparent), + inset 0 0 12px color-mix(in oklch, var(--primary) 3%, transparent); } [data-theme="light"] .terminal-card { background: var(--card); border: 1px solid var(--border); - box-shadow: 0 1px 4px color-mix(in oklch, var(--primary) 10%, transparent); + box-shadow: 0 1px 3px color-mix(in oklch, var(--primary) 8%, transparent); } /* Theme transition for all interactive elements */ @@ -293,6 +293,104 @@ margin-bottom: 0; } +/* ── Focus Visible Styles ─────────────────────────────── */ +@layer base { + /* Default focus-visible styles for all interactive elements */ + button:focus-visible, + a:focus-visible, + input:focus-visible, + select:focus-visible, + textarea:focus-visible, + [tabindex]:not([tabindex="-1"]):focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + ring: 2px; + ring-color: var(--ring); + } + + /* Remove default focus styles and use focus-visible instead */ + *:focus { + outline: none; + } + + /* Ensure focus is visible on keyboard navigation */ + *:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + } +} + +/* Skip link for accessibility */ +.skip-link { + position: absolute; + top: -100%; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + padding: 0.75rem 1.5rem; + background: var(--primary); + color: var(--primary-foreground); + border-radius: 0.375rem; + font-weight: 500; + transition: top 0.2s ease; +} + +.skip-link:focus { + top: 1rem; + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +/* High contrast focus indicators */ +.focus-indicator { + position: relative; +} + +.focus-indicator::after { + content: ''; + position: absolute; + inset: -2px; + border: 2px solid transparent; + border-radius: inherit; + pointer-events: none; + transition: border-color 0.2s ease; +} + +.focus-indicator:focus-visible::after { + border-color: var(--ring); +} + +/* Keyboard-only focus styles */ +.keyboard-focus:focus { + outline: none; +} + +.keyboard-focus:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + box-shadow: 0 0 0 4px color-mix(in oklch, var(--ring) 20%, transparent); +} + +/* Focus trap indicator for modals */ +.focus-trap-active { + position: relative; +} + +.focus-trap-active::before { + content: ''; + position: absolute; + inset: 0; + border: 2px solid var(--ring); + border-radius: inherit; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; +} + +.focus-trap-active:focus-within::before { + opacity: 1; +} + /* ── Version Note Styles ─────────────────────────────── */ .version-note-input { background: var(--note-bg); @@ -343,4 +441,46 @@ .delay-300 { animation-delay: 300ms; +} + +/* ── Empty State Animations ─────────────────────────────── */ +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes bounce-slow { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-5px); + } +} + +@keyframes cursor-blink { + 0%, 50% { + opacity: 1; + } + 51%, 100% { + opacity: 0.3; + } +} + +.animate-fade-in { + animation: fade-in 0.4s ease-out forwards; +} + +.animate-bounce-slow { + animation: bounce-slow 2s ease-in-out infinite; +} + +.animate-cursor-blink { + animation: cursor-blink 1.5s ease-in-out infinite; } \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c3d1998..9677546 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "@/lib/theme-context"; +import { ToastProvider } from "@/lib/toast-context"; +import { Toaster } from "@/components/toaster"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -28,8 +30,20 @@ export default function RootLayout({
+ {/* Skip to main content link for accessibility */} + + Skip to main content +