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
4 changes: 1 addition & 3 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */
};
const nextConfig: NextConfig = {};

export default nextConfig;
173 changes: 173 additions & 0 deletions src/app/api/script-explain/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
154 changes: 147 additions & 7 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Loading
Loading