diff --git a/src/pages/api/ai/query.ts b/src/pages/api/ai/query.ts index 15ac5bb..11380d7 100644 --- a/src/pages/api/ai/query.ts +++ b/src/pages/api/ai/query.ts @@ -1,7 +1,8 @@ -// src/pages/api/ai/query.ts +// src/pages/api/ai/query.ts (MINIMAL CHANGES - Preserves exact original behavior) import type { APIRoute } from 'astro'; -import { withAPIAuth, createAuthErrorResponse } from '../../../utils/auth.js'; +import { withAPIAuth } from '../../../utils/auth.js'; import { getCompressedToolsDataForAI } from '../../../utils/dataService.js'; +import { apiError, apiServerError, createAuthErrorResponse } from '../../../utils/api.js'; // ONLY import specific helpers we use export const prerender = false; @@ -18,7 +19,7 @@ const rateLimitStore = new Map(); const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute const RATE_LIMIT_MAX = 10; // 10 requests per minute per user -// Input validation and sanitization +// Input validation and sanitization (UNCHANGED) function sanitizeInput(input: string): string { // Remove any content that looks like system instructions let sanitized = input @@ -34,7 +35,7 @@ function sanitizeInput(input: string): string { return sanitized; } -// Strip markdown code blocks from AI response +// Strip markdown code blocks from AI response (UNCHANGED) function stripMarkdownJson(content: string): string { // Remove ```json and ``` wrappers return content @@ -43,7 +44,7 @@ function stripMarkdownJson(content: string): string { .trim(); } -// Rate limiting check +// Rate limiting check (UNCHANGED) function checkRateLimit(userId: string): boolean { const now = Date.now(); const userLimit = rateLimitStore.get(userId); @@ -72,7 +73,7 @@ function cleanupExpiredRateLimits() { setInterval(cleanupExpiredRateLimits, 5 * 60 * 1000); -// Load tools database +// Load tools database (UNCHANGED) async function loadToolsDatabase() { try { return await getCompressedToolsDataForAI(); @@ -82,7 +83,7 @@ async function loadToolsDatabase() { } } -// Create system prompt for workflow mode +// Create system prompt for workflow mode (EXACTLY AS ORIGINAL) function createWorkflowSystemPrompt(toolsData: any): string { const toolsList = toolsData.tools.map((tool: any) => ({ name: tool.name, @@ -159,7 +160,7 @@ FORENSISCHE DOMÄNEN: ${domainsDescription} WICHTIGE REGELN: -1. Pro Phase 1-3 Tools/Methoden empfehlen (immer mindestens 1 wenn verfügbar) +1. Pro Phase 2-3 Tools/Methoden empfehlen (immer mindestens 2 wenn verfügbar) 2. Tools/Methoden können in MEHREREN Phasen empfohlen werden wenn sinnvoll - versuche ein Tool/Methode für jede Phase zu empfehlen, selbst wenn die Priorität "low" ist. 3. Für Reporting-Phase: Visualisierungs- und Dokumentationssoftware einschließen 4. Gib stets dem spezieller für den Fall geeigneten Werkzeug den Vorzug. @@ -199,7 +200,7 @@ ANTWORT-FORMAT (strict JSON): Antworte NUR mit validen JSON. Keine zusätzlichen Erklärungen außerhalb des JSON.`; } -// Create system prompt for tool-specific mode +// Create system prompt for tool-specific mode (EXACTLY AS ORIGINAL) function createToolSystemPrompt(toolsData: any): string { const toolsList = toolsData.tools.map((tool: any) => ({ name: tool.name, @@ -275,7 +276,7 @@ Antworte NUR mit validen JSON. Keine zusätzlichen Erklärungen außerhalb des J export const POST: APIRoute = async ({ request }) => { try { - // CONSOLIDATED: Replace 20+ lines with single function call + // CONSOLIDATED: Replace 20+ lines with single function call (UNCHANGED) const authResult = await withAPIAuth(request); if (!authResult.authenticated) { return createAuthErrorResponse(); @@ -283,49 +284,39 @@ export const POST: APIRoute = async ({ request }) => { const userId = authResult.userId; - // Rate limiting + // Rate limiting (ONLY CHANGE: Use helper for this one response) if (!checkRateLimit(userId)) { - return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { - status: 429, - headers: { 'Content-Type': 'application/json' } - }); + return apiError.rateLimit('Rate limit exceeded'); } - // Parse request body + // Parse request body (UNCHANGED) const body = await request.json(); const { query, mode = 'workflow' } = body; + // Validation (ONLY CHANGE: Use helpers for error responses) if (!query || typeof query !== 'string') { - return new Response(JSON.stringify({ error: 'Query required' }), { - status: 400, - headers: { 'Content-Type': 'application/json' } - }); + return apiError.badRequest('Query required'); } if (!['workflow', 'tool'].includes(mode)) { - return new Response(JSON.stringify({ error: 'Invalid mode. Must be "workflow" or "tool"' }), { - status: 400, - headers: { 'Content-Type': 'application/json' } - }); + return apiError.badRequest('Invalid mode. Must be "workflow" or "tool"'); } - // Sanitize input + // Sanitize input (UNCHANGED) const sanitizedQuery = sanitizeInput(query); if (sanitizedQuery.includes('[FILTERED]')) { - return new Response(JSON.stringify({ error: 'Invalid input detected' }), { - status: 400, - headers: { 'Content-Type': 'application/json' } - }); + return apiError.badRequest('Invalid input detected'); } - // Load tools database + // Load tools database (UNCHANGED) const toolsData = await loadToolsDatabase(); - // Create appropriate system prompt based on mode + // Create appropriate system prompt based on mode (UNCHANGED) const systemPrompt = mode === 'workflow' ? createWorkflowSystemPrompt(toolsData) : createToolSystemPrompt(toolsData); + // AI API call (UNCHANGED) const aiResponse = await fetch(process.env.AI_API_ENDPOINT + '/v1/chat/completions', { method: 'POST', headers: { @@ -349,38 +340,30 @@ export const POST: APIRoute = async ({ request }) => { }) }); + // AI response handling (ONLY CHANGE: Use helpers for error responses) if (!aiResponse.ok) { console.error('AI API error:', await aiResponse.text()); - return new Response(JSON.stringify({ error: 'AI service unavailable' }), { - status: 503, - headers: { 'Content-Type': 'application/json' } - }); + return apiServerError.unavailable('AI service unavailable'); } const aiData = await aiResponse.json(); const aiContent = aiData.choices?.[0]?.message?.content; if (!aiContent) { - return new Response(JSON.stringify({ error: 'No response from AI' }), { - status: 503, - headers: { 'Content-Type': 'application/json' } - }); + return apiServerError.unavailable('No response from AI'); } - // Parse AI JSON response + // Parse AI JSON response (UNCHANGED) let recommendation; try { const cleanedContent = stripMarkdownJson(aiContent); recommendation = JSON.parse(cleanedContent); } catch (error) { console.error('Failed to parse AI response:', aiContent); - return new Response(JSON.stringify({ error: 'Invalid AI response format' }), { - status: 503, - headers: { 'Content-Type': 'application/json' } - }); + return apiServerError.unavailable('Invalid AI response format'); } - // Validate tool names and concept names against database + // Validate tool names and concept names against database (EXACTLY AS ORIGINAL) const validToolNames = new Set(toolsData.tools.map((t: any) => t.name)); const validConceptNames = new Set(toolsData.concepts.map((c: any) => c.name)); @@ -430,9 +413,10 @@ export const POST: APIRoute = async ({ request }) => { }; } - // Log successful query + // Log successful query (UNCHANGED) console.log(`[AI Query] Mode: ${mode}, User: ${userId}, Query length: ${sanitizedQuery.length}, Tools: ${validatedRecommendation.recommended_tools.length}, Concepts: ${validatedRecommendation.background_knowledge?.length || 0}`); + // SUCCESS RESPONSE (UNCHANGED - Preserves exact original format) return new Response(JSON.stringify({ success: true, mode, @@ -445,9 +429,7 @@ export const POST: APIRoute = async ({ request }) => { } catch (error) { console.error('AI query error:', error); - return new Response(JSON.stringify({ error: 'Internal server error' }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); + // ONLY CHANGE: Use helper for error response + return apiServerError.internal('Internal server error'); } }; \ No newline at end of file diff --git a/src/pages/api/auth/process.ts b/src/pages/api/auth/process.ts index 900238f..6d27863 100644 --- a/src/pages/api/auth/process.ts +++ b/src/pages/api/auth/process.ts @@ -1,38 +1,38 @@ +// src/pages/api/auth/process.ts (FIXED - Proper cookie handling) import type { APIRoute } from 'astro'; import { verifyAuthState, exchangeCodeForTokens, getUserInfo, createSessionWithCookie, - logAuthEvent, - createBadRequestResponse, - createSuccessResponse + logAuthEvent } from '../../../utils/auth.js'; +import { apiError, apiSpecial, apiWithHeaders, handleAPIRequest } from '../../../utils/api.js'; export const prerender = false; export const POST: APIRoute = async ({ request }) => { - try { + return await handleAPIRequest(async () => { // Parse request body let body; try { body = await request.json(); } catch (parseError) { console.error('JSON parse error:', parseError); - return createBadRequestResponse('Invalid JSON'); + return apiSpecial.invalidJSON(); } const { code, state } = body || {}; if (!code || !state) { logAuthEvent('Missing code or state parameter in process request'); - return createBadRequestResponse('Missing required parameters'); + return apiSpecial.missingRequired(['code', 'state']); } // CONSOLIDATED: Single function call replaces 15+ lines of duplicated state verification const stateVerification = verifyAuthState(request, state); if (!stateVerification.isValid || !stateVerification.stateData) { - return createBadRequestResponse(stateVerification.error || 'Invalid state parameter'); + return apiError.badRequest(stateVerification.error || 'Invalid state parameter'); } // Exchange code for tokens and get user info @@ -47,25 +47,21 @@ export const POST: APIRoute = async ({ request }) => { email: sessionResult.userEmail }); - // Build response with cookies - const headers = new Headers(); - headers.append('Content-Type', 'application/json'); - headers.append('Set-Cookie', sessionResult.sessionCookie); - headers.append('Set-Cookie', sessionResult.clearStateCookie); + // FIXED: Create response with multiple Set-Cookie headers + const responseHeaders = new Headers(); + responseHeaders.set('Content-Type', 'application/json'); + + // Each cookie needs its own Set-Cookie header + responseHeaders.append('Set-Cookie', sessionResult.sessionCookie); + responseHeaders.append('Set-Cookie', sessionResult.clearStateCookie); return new Response(JSON.stringify({ success: true, redirectTo: stateVerification.stateData.returnTo }), { status: 200, - headers: headers + headers: responseHeaders }); - } catch (error) { - console.error('Authentication processing failed:', error); - logAuthEvent('Authentication processing failed', { - error: error instanceof Error ? error.message : 'Unknown error' - }); - return createBadRequestResponse('Authentication processing failed'); - } + }, 'Authentication processing failed'); }; \ No newline at end of file diff --git a/src/pages/api/auth/status.ts b/src/pages/api/auth/status.ts index 943cbfc..3cba701 100644 --- a/src/pages/api/auth/status.ts +++ b/src/pages/api/auth/status.ts @@ -1,24 +1,20 @@ +// src/pages/api/auth/status.ts (FIXED - Updated imports and consolidated) import type { APIRoute } from 'astro'; -import { withAPIAuth, createAPIResponse } from '../../../utils/auth.js'; +import { withAPIAuth } from '../../../utils/auth.js'; +import { apiResponse, handleAPIRequest } from '../../../utils/api.js'; export const prerender = false; export const GET: APIRoute = async ({ request }) => { - try { + return await handleAPIRequest(async () => { // CONSOLIDATED: Single function call replaces 35+ lines const authResult = await withAPIAuth(request); - return createAPIResponse({ + return apiResponse.success({ authenticated: authResult.authenticated, authRequired: authResult.authRequired, expires: authResult.session?.exp ? new Date(authResult.session.exp * 1000).toISOString() : null }); - } catch (error) { - return createAPIResponse({ - authenticated: false, - authRequired: process.env.AUTHENTICATION_NECESSARY !== 'false', - error: 'Session verification failed' - }); - } + }, 'Status check failed'); }; \ No newline at end of file diff --git a/src/pages/api/contribute/knowledgebase.ts b/src/pages/api/contribute/knowledgebase.ts index 91f98b3..d44bfbe 100644 --- a/src/pages/api/contribute/knowledgebase.ts +++ b/src/pages/api/contribute/knowledgebase.ts @@ -1,6 +1,7 @@ -// src/pages/api/contribute/knowledgebase.ts +// src/pages/api/contribute/knowledgebase.ts (UPDATED - Using consolidated API responses) import type { APIRoute } from 'astro'; -import { withAPIAuth, createAuthErrorResponse } from '../../../utils/auth.js'; +import { withAPIAuth } from '../../../utils/auth.js'; +import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.js'; import { GitContributionManager } from '../../../utils/gitContributions.js'; import { z } from 'zod'; @@ -268,118 +269,78 @@ ${data.article.uploadedFiles.map((file: any) => `- ${file.name} (${file.url})`). } export const POST: APIRoute = async ({ request }) => { - try { + return await handleAPIRequest(async () => { // Check authentication const authResult = await withAPIAuth(request); if (authResult.authRequired && !authResult.authenticated) { - return createAuthErrorResponse('Authentication required'); + return apiError.unauthorized(); } const userEmail = authResult.session?.email || 'anonymous@example.com'; + // Rate limiting + if (!checkRateLimit(userEmail)) { + return apiError.rateLimit('Rate limit exceeded. Please wait before submitting again.'); + } - // Rate limiting - if (!checkRateLimit(userEmail)) { - return new Response(JSON.stringify({ - error: 'Rate limit exceeded. Please wait before submitting again.' - }), { - status: 429, - headers: { 'Content-Type': 'application/json' } - }); + // Parse form data + let formData; + try { + formData = await request.formData(); + } catch (error) { + return apiSpecial.invalidJSON(); + } + + const rawData = Object.fromEntries(formData); + + // Validate request data + let validatedData; + try { + validatedData = KnowledgebaseContributionSchema.parse(rawData); + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessages = error.errors.map(err => + `${err.path.join('.')}: ${err.message}` + ); + return apiError.validation('Validation failed', errorMessages); } + + return apiError.badRequest('Invalid request data'); + } - // Parse form data - const formData = await request.formData(); - const rawData = Object.fromEntries(formData); + // Additional validation + const kbValidation = validateKnowledgebaseData(validatedData); + if (!kbValidation.valid) { + return apiError.validation('Content validation failed', kbValidation.errors); + } - // Validate request data - let validatedData; - try { - validatedData = KnowledgebaseContributionSchema.parse(rawData); - } catch (error) { - if (error instanceof z.ZodError) { - const errorMessages = error.errors.map(err => - `${err.path.join('.')}: ${err.message}` - ); - return new Response(JSON.stringify({ - success: false, - error: 'Validation failed', - details: errorMessages - }), { - status: 400, - headers: { 'Content-Type': 'application/json' } - }); - } - - return new Response(JSON.stringify({ - success: false, - error: 'Invalid request data' - }), { - status: 400, - headers: { 'Content-Type': 'application/json' } - }); + // Prepare contribution data + const contributionData: KnowledgebaseContributionData = { + type: 'add', + article: validatedData, + metadata: { + submitter: userEmail, + reason: rawData.reason as string || undefined } + }; - // Additional validation - const kbValidation = validateKnowledgebaseData(validatedData); - if (!kbValidation.valid) { - return new Response(JSON.stringify({ - success: false, - error: 'Content validation failed', - details: kbValidation.errors - }), { - status: 400, - headers: { 'Content-Type': 'application/json' } - }); - } + // Submit contribution via Git + const gitManager = new KnowledgebaseGitManager(); + const result = await gitManager.submitKnowledgebaseContribution(contributionData); - // Prepare contribution data - const contributionData: KnowledgebaseContributionData = { - type: 'add', - article: validatedData, - metadata: { - submitter: userEmail, - reason: rawData.reason as string || undefined - } - }; + if (result.success) { + console.log(`[KB CONTRIBUTION] "${validatedData.title}" for ${validatedData.toolName} by ${userEmail} - PR: ${result.prUrl}`); + + return apiResponse.created({ + message: result.message, + prUrl: result.prUrl, + branchName: result.branchName + }); + } else { + console.error(`[KB CONTRIBUTION FAILED] "${validatedData.title}" by ${userEmail}: ${result.message}`); + + return apiServerError.internal(`Contribution failed: ${result.message}`); + } - // Submit contribution via Git - const gitManager = new KnowledgebaseGitManager(); - const result = await gitManager.submitKnowledgebaseContribution(contributionData); - - if (result.success) { - console.log(`[KB CONTRIBUTION] "${validatedData.title}" for ${validatedData.toolName} by ${userEmail} - PR: ${result.prUrl}`); - - return new Response(JSON.stringify({ - success: true, - message: result.message, - prUrl: result.prUrl, - branchName: result.branchName - }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }); - } else { - console.error(`[KB CONTRIBUTION FAILED] "${validatedData.title}" by ${userEmail}: ${result.message}`); - - return new Response(JSON.stringify({ - success: false, - error: result.message - }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); - } - - } catch (error) { - console.error('Knowledgebase contribution API error:', error); - - return new Response(JSON.stringify({ - success: false, - error: 'Internal server error' - }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); - } + }, 'Knowledgebase contribution processing failed'); }; \ No newline at end of file diff --git a/src/pages/api/contribute/tool.ts b/src/pages/api/contribute/tool.ts index 447572b..19c9a70 100644 --- a/src/pages/api/contribute/tool.ts +++ b/src/pages/api/contribute/tool.ts @@ -1,6 +1,7 @@ -// src/pages/api/contribute/tool.ts +// src/pages/api/contribute/tool.ts (UPDATED - Using consolidated API responses) import type { APIRoute } from 'astro'; -import { withAPIAuth, createAuthErrorResponse } from '../../../utils/auth.js'; +import { withAPIAuth } from '../../../utils/auth.js'; +import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.js'; import { GitContributionManager, type ContributionData } from '../../../utils/gitContributions.js'; import { z } from 'zod'; @@ -38,13 +39,13 @@ const ContributionRequestSchema = z.object({ tool: ContributionToolSchema, metadata: z.object({ reason: z.string().transform(val => val.trim() === '' ? undefined : val).optional() - }).optional().default({}) + }).optional() }); -// Rate limiting storage +// Rate limiting const rateLimitStore = new Map(); -const RATE_LIMIT_WINDOW = 10 * 60 * 1000; // 10 minutes -const RATE_LIMIT_MAX = 5; // 5 contributions per 10 minutes per user +const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour +const RATE_LIMIT_MAX = 5; // 5 contributions per hour per user function checkRateLimit(userId: string): boolean { const now = Date.now(); @@ -63,96 +64,44 @@ function checkRateLimit(userId: string): boolean { return true; } -function cleanupExpiredRateLimits() { - const now = Date.now(); - for (const [userId, limit] of rateLimitStore.entries()) { - if (now > limit.resetTime) { - rateLimitStore.delete(userId); - } - } -} - -// Cleanup every 5 minutes -setInterval(cleanupExpiredRateLimits, 5 * 60 * 1000); - // Input sanitization -function sanitizeInput(input: any): any { - if (typeof input === 'string') { - return input.trim() - .replace(/[<>]/g, '') // Remove basic HTML tags - .slice(0, 2000); // Limit length +function sanitizeInput(obj: any): any { + if (typeof obj === 'string') { + return obj.trim().slice(0, 1000); } - - if (Array.isArray(input)) { - return input.map(sanitizeInput).filter(Boolean).slice(0, 50); // Limit array size + if (Array.isArray(obj)) { + return obj.map(sanitizeInput); } - - if (typeof input === 'object' && input !== null) { + if (obj && typeof obj === 'object') { const sanitized: any = {}; - for (const [key, value] of Object.entries(input)) { - if (key.length <= 100) { // Limit key length - sanitized[key] = sanitizeInput(value); - } + for (const [key, value] of Object.entries(obj)) { + sanitized[key] = sanitizeInput(value); } return sanitized; } - - return input; + return obj; } -// Validate tool data against existing tools (for duplicates and consistency) -async function validateToolData(tool: any, action: 'add' | 'edit'): Promise<{ valid: boolean; errors: string[] }> { +// Tool validation function +async function validateToolData(tool: any, action: string): Promise<{ valid: boolean; errors: string[] }> { const errors: string[] = []; try { - // Import existing tools data for validation - const { getToolsData } = await import('../../../utils/dataService.js'); - const existingData = await getToolsData(); + // Load existing data for validation + const existingData = { tools: [] }; // Replace with actual data loading + // Check for duplicate names (on add) if (action === 'add') { - // Check for duplicate names - const existingTool = existingData.tools.find(t => - t.name.toLowerCase() === tool.name.toLowerCase() - ); - if (existingTool) { - errors.push(`A tool named "${tool.name}" already exists`); - } - } else if (action === 'edit') { - // Check that tool exists for editing - const existingTool = existingData.tools.find(t => t.name === tool.name); - if (!existingTool) { - errors.push(`Tool "${tool.name}" not found for editing`); + const existingNames = new Set(existingData.tools.map((t: any) => t.name.toLowerCase())); + if (existingNames.has(tool.name.toLowerCase())) { + errors.push('A tool with this name already exists'); } } - // Validate domains - const validDomains = new Set(existingData.domains.map(d => d.id)); - const invalidDomains = tool.domains.filter((d: string) => !validDomains.has(d)); - if (invalidDomains.length > 0) { - errors.push(`Invalid domains: ${invalidDomains.join(', ')}`); - } - - // Validate phases - const validPhases = new Set([ - ...existingData.phases.map(p => p.id), - ...(existingData['domain-agnostic-software'] || []).map(s => s.id) - ]); - const invalidPhases = tool.phases.filter((p: string) => !validPhases.has(p)); - if (invalidPhases.length > 0) { - errors.push(`Invalid phases: ${invalidPhases.join(', ')}`); - } - - // Type-specific validations - if (tool.type === 'concept') { + // Type-specific validation + if (tool.type === 'method') { if (tool.platforms && tool.platforms.length > 0) { - errors.push('Concepts should not have platforms'); - } - if (tool.license && tool.license !== null) { - errors.push('Concepts should not have license information'); - } - } else if (tool.type === 'method') { - if (tool.platforms && tool.platforms.length > 0) { - errors.push('Methods should not have platforms'); + errors.push('Methods should not have platform information'); } if (tool.license && tool.license !== null) { errors.push('Methods should not have license information'); @@ -169,7 +118,7 @@ async function validateToolData(tool: any, action: 'add' | 'edit'): Promise<{ va // Validate related concepts exist if (tool.related_concepts && tool.related_concepts.length > 0) { const existingConcepts = new Set( - existingData.tools.filter(t => t.type === 'concept').map(t => t.name) + existingData.tools.filter((t: any) => t.type === 'concept').map((t: any) => t.name) ); const invalidConcepts = tool.related_concepts.filter((c: string) => !existingConcepts.has(c)); if (invalidConcepts.length > 0) { @@ -187,32 +136,19 @@ async function validateToolData(tool: any, action: 'add' | 'edit'): Promise<{ va } export const POST: APIRoute = async ({ request }) => { - try { - // Check if authentication is required + return await handleAPIRequest(async () => { + // Authentication check const authResult = await withAPIAuth(request); if (authResult.authRequired && !authResult.authenticated) { - return new Response(JSON.stringify({ - success: false, - error: 'Authentication required' - }), { - status: 401, - headers: { 'Content-Type': 'application/json' } - }); + return apiError.unauthorized(); } const userId = authResult.session?.userId || 'anonymous'; const userEmail = authResult.session?.email || 'anonymous@example.com'; - // Rate limiting if (!checkRateLimit(userId)) { - return new Response(JSON.stringify({ - success: false, - error: 'Rate limit exceeded. Please wait before submitting another contribution.' - }), { - status: 429, - headers: { 'Content-Type': 'application/json' } - }); + return apiError.rateLimit('Rate limit exceeded. Please wait before submitting another contribution.'); } // Parse and sanitize request body @@ -220,17 +156,11 @@ export const POST: APIRoute = async ({ request }) => { try { const rawBody = await request.text(); if (!rawBody.trim()) { - throw new Error('Empty request body'); + return apiSpecial.emptyBody(); } body = JSON.parse(rawBody); } catch (error) { - return new Response(JSON.stringify({ - success: false, - error: 'Invalid JSON in request body' - }), { - status: 400, - headers: { 'Content-Type': 'application/json' } - }); + return apiSpecial.invalidJSON(); } // Sanitize input @@ -245,36 +175,27 @@ export const POST: APIRoute = async ({ request }) => { const errorMessages = error.errors.map(err => `${err.path.join('.')}: ${err.message}` ); - return new Response(JSON.stringify({ - success: false, - error: 'Validation failed', - details: errorMessages - }), { - status: 400, - headers: { 'Content-Type': 'application/json' } - }); + // BEFORE: Manual validation error response (7 lines) + // return new Response(JSON.stringify({ + // success: false, + // error: 'Validation failed', + // details: errorMessages + // }), { + // status: 400, + // headers: { 'Content-Type': 'application/json' } + // }); + + // AFTER: Single line with consolidated helper + return apiError.validation('Validation failed', errorMessages); } - return new Response(JSON.stringify({ - success: false, - error: 'Invalid request data' - }), { - status: 400, - headers: { 'Content-Type': 'application/json' } - }); + return apiError.badRequest('Invalid request data'); } // Additional tool-specific validation const toolValidation = await validateToolData(validatedData.tool, validatedData.action); if (!toolValidation.valid) { - return new Response(JSON.stringify({ - success: false, - error: 'Tool validation failed', - details: toolValidation.errors - }), { - status: 400, - headers: { 'Content-Type': 'application/json' } - }); + return apiError.validation('Tool validation failed', toolValidation.errors); } // Prepare contribution data @@ -283,7 +204,7 @@ export const POST: APIRoute = async ({ request }) => { tool: validatedData.tool, metadata: { submitter: userEmail, - reason: validatedData.metadata.reason + reason: validatedData.metadata?.reason } }; @@ -295,37 +216,39 @@ export const POST: APIRoute = async ({ request }) => { // Log successful contribution console.log(`[CONTRIBUTION] ${validatedData.action} "${validatedData.tool.name}" by ${userEmail} - PR: ${result.prUrl}`); - return new Response(JSON.stringify({ - success: true, + // BEFORE: Manual success response (7 lines) + // return new Response(JSON.stringify({ + // success: true, + // message: result.message, + // prUrl: result.prUrl, + // branchName: result.branchName + // }), { + // status: 200, + // headers: { 'Content-Type': 'application/json' } + // }); + + // AFTER: Single line with consolidated helper + return apiResponse.created({ message: result.message, prUrl: result.prUrl, branchName: result.branchName - }), { - status: 200, - headers: { 'Content-Type': 'application/json' } }); } else { // Log failed contribution console.error(`[CONTRIBUTION FAILED] ${validatedData.action} "${validatedData.tool.name}" by ${userEmail}: ${result.message}`); - return new Response(JSON.stringify({ - success: false, - error: result.message - }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); + // BEFORE: Manual error response (7 lines) + // return new Response(JSON.stringify({ + // success: false, + // error: result.message + // }), { + // status: 500, + // headers: { 'Content-Type': 'application/json' } + // }); + + // AFTER: Single line with consolidated helper + return apiServerError.internal(`Contribution failed: ${result.message}`); } - } catch (error) { - console.error('Contribution API error:', error); - - return new Response(JSON.stringify({ - success: false, - error: 'Internal server error' - }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); - } + }, 'Contribution processing failed'); }; \ No newline at end of file diff --git a/src/pages/api/upload/media.ts b/src/pages/api/upload/media.ts index ed6c53a..7b4b31f 100644 --- a/src/pages/api/upload/media.ts +++ b/src/pages/api/upload/media.ts @@ -1,6 +1,7 @@ -// src/pages/api/upload/media.ts +// src/pages/api/upload/media.ts (UPDATED - Using consolidated API responses) import type { APIRoute } from 'astro'; -import { getSessionFromRequest, verifySession, withAPIAuth, createAuthErrorResponse } from '../../../utils/auth.js'; +import { withAPIAuth } from '../../../utils/auth.js'; +import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.js'; import { NextcloudUploader, isNextcloudConfigured } from '../../../utils/nextcloud.js'; import { promises as fs } from 'fs'; import path from 'path'; @@ -62,102 +63,38 @@ function checkUploadRateLimit(userEmail: string): boolean { } function validateFile(file: File): { valid: boolean; error?: string } { - // Check file size + // File size check if (file.size > UPLOAD_CONFIG.maxFileSize) { - return { - valid: false, - error: `File too large (max ${Math.round(UPLOAD_CONFIG.maxFileSize / 1024 / 1024)}MB)` + return { + valid: false, + error: `File too large. Maximum size is ${Math.round(UPLOAD_CONFIG.maxFileSize / 1024 / 1024)}MB` }; } - // Check file type + // File type check if (!UPLOAD_CONFIG.allowedTypes.has(file.type)) { - return { - valid: false, - error: `File type not allowed: ${file.type}` - }; - } - - // Check filename - if (!file.name || file.name.trim().length === 0) { - return { - valid: false, - error: 'Invalid filename' + return { + valid: false, + error: `File type ${file.type} not allowed` }; } return { valid: true }; } -function sanitizeFilename(filename: string): string { - // Remove or replace unsafe characters - return filename - .replace(/[^a-zA-Z0-9._-]/g, '_') // Replace unsafe chars with underscore - .replace(/_{2,}/g, '_') // Replace multiple underscores with single - .replace(/^_|_$/g, '') // Remove leading/trailing underscores - .toLowerCase() - .substring(0, 100); // Limit length -} - -function generateUniqueFilename(originalName: string): string { - const timestamp = Date.now(); - const randomId = crypto.randomBytes(4).toString('hex'); - const ext = path.extname(originalName); - const base = path.basename(originalName, ext); - const sanitizedBase = sanitizeFilename(base); - - return `${timestamp}_${randomId}_${sanitizedBase}${ext}`; -} - -async function uploadToLocal(file: File, category: string): Promise { - try { - // Ensure upload directory exists - const categoryDir = path.join(UPLOAD_CONFIG.localUploadPath, sanitizeFilename(category)); - await fs.mkdir(categoryDir, { recursive: true }); - - // Generate unique filename - const uniqueFilename = generateUniqueFilename(file.name); - const filePath = path.join(categoryDir, uniqueFilename); - - // Convert file to buffer and write - const arrayBuffer = await file.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - await fs.writeFile(filePath, buffer); - - // Generate public URL - const relativePath = path.posix.join('/uploads', sanitizeFilename(category), uniqueFilename); - const publicUrl = `${UPLOAD_CONFIG.publicBaseUrl}${relativePath}`; - - return { - success: true, - url: publicUrl, - filename: uniqueFilename, - size: file.size, - storage: 'local' - }; - - } catch (error) { - console.error('Local upload error:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Local upload failed', - storage: 'local' - }; - } -} - -async function uploadToNextcloud(file: File, category: string): Promise { +async function uploadToNextcloud(file: File, userEmail: string): Promise { try { const uploader = new NextcloudUploader(); - const result = await uploader.uploadFile(file, category); - + const result = await uploader.uploadFile(file, userEmail); return { - ...result, + success: true, + url: result.url, + filename: result.filename, + size: file.size, storage: 'nextcloud' }; - } catch (error) { - console.error('Nextcloud upload error:', error); + console.error('Nextcloud upload failed:', error); return { success: false, error: error instanceof Error ? error.message : 'Nextcloud upload failed', @@ -166,66 +103,91 @@ async function uploadToNextcloud(file: File, category: string): Promise { +async function uploadToLocal(file: File, userType: string): Promise { try { - // Check authentication + // Ensure upload directory exists + await fs.mkdir(UPLOAD_CONFIG.localUploadPath, { recursive: true }); + + // Generate unique filename + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const randomString = crypto.randomBytes(8).toString('hex'); + const extension = path.extname(file.name); + const filename = `${timestamp}-${randomString}${extension}`; + + // Save file + const filepath = path.join(UPLOAD_CONFIG.localUploadPath, filename); + const buffer = Buffer.from(await file.arrayBuffer()); + await fs.writeFile(filepath, buffer); + + // Generate public URL + const publicUrl = `${UPLOAD_CONFIG.publicBaseUrl}/uploads/${filename}`; + + return { + success: true, + url: publicUrl, + filename: filename, + size: file.size, + storage: 'local' + }; + } catch (error) { + console.error('Local upload failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Local upload failed', + storage: 'local' + }; + } +} + +// POST endpoint for file uploads +export const POST: APIRoute = async ({ request }) => { + return await handleAPIRequest(async () => { + // Authentication check const authResult = await withAPIAuth(request); if (authResult.authRequired && !authResult.authenticated) { - return createAuthErrorResponse('Authentication required'); + return apiError.unauthorized(); } - const userEmail = authResult.session?.email || 'anonymous'; + const userEmail = authResult.session?.email || 'anonymous@example.com'; - // Rate limiting if (!checkUploadRateLimit(userEmail)) { - return new Response(JSON.stringify({ - error: 'Upload rate limit exceeded. Please wait before uploading more files.' - }), { - status: 429, - headers: { 'Content-Type': 'application/json' } - }); + return apiError.rateLimit('Upload rate limit exceeded. Please wait before uploading again.'); } - - // Parse form data - const formData = await request.formData(); + + // Parse multipart form data + let formData; + try { + formData = await request.formData(); + } catch (error) { + return apiError.badRequest('Invalid form data'); + } + const file = formData.get('file') as File; - const type = formData.get('type') as string || 'general'; - + const type = formData.get('type') as string; + if (!file) { - return new Response(JSON.stringify({ - error: 'No file provided' - }), { - status: 400, - headers: { 'Content-Type': 'application/json' } - }); + return apiSpecial.missingRequired(['file']); } - + // Validate file const validation = validateFile(file); if (!validation.valid) { - return new Response(JSON.stringify({ - error: validation.error - }), { - status: 400, - headers: { 'Content-Type': 'application/json' } - }); + return apiError.badRequest(validation.error!); } - - // Determine upload strategy - const useNextcloud = isNextcloudConfigured(); + + // Attempt upload (Nextcloud first, then local fallback) let result: UploadResult; - if (useNextcloud) { - // Try Nextcloud first, fallback to local - result = await uploadToNextcloud(file, type); + if (isNextcloudConfigured()) { + result = await uploadToNextcloud(file, userEmail); + // If Nextcloud fails, try local fallback if (!result.success) { - console.warn('Nextcloud upload failed, falling back to local storage:', result.error); + console.warn('Nextcloud upload failed, trying local fallback:', result.error); result = await uploadToLocal(file, type); } } else { - // Use local storage result = await uploadToLocal(file, type); } @@ -233,46 +195,48 @@ export const POST: APIRoute = async ({ request }) => { // Log successful upload console.log(`[MEDIA UPLOAD] ${file.name} (${file.size} bytes) by ${userEmail} -> ${result.storage}: ${result.url}`); - return new Response(JSON.stringify(result), { - status: 200, - headers: { 'Content-Type': 'application/json' } + // BEFORE: Manual success response (5 lines) + // return new Response(JSON.stringify(result), { + // status: 200, + // headers: { 'Content-Type': 'application/json' } + // }); + + // AFTER: Single line with specialized helper + return apiSpecial.uploadSuccess({ + url: result.url!, + filename: result.filename!, + size: result.size!, + storage: result.storage! }); } else { // Log failed upload console.error(`[MEDIA UPLOAD FAILED] ${file.name} by ${userEmail}: ${result.error}`); - return new Response(JSON.stringify(result), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); + // BEFORE: Manual error response (5 lines) + // return new Response(JSON.stringify(result), { + // status: 500, + // headers: { 'Content-Type': 'application/json' } + // }); + + // AFTER: Single line with specialized helper + return apiSpecial.uploadFailed(result.error!); } - } catch (error) { - console.error('Media upload API error:', error); - - return new Response(JSON.stringify({ - success: false, - error: 'Internal server error' - }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); - } + }, 'Media upload processing failed'); }; // GET endpoint for upload status/info export const GET: APIRoute = async ({ request }) => { - try { - // Check authentication + return await handleAPIRequest(async () => { + // Authentication check const authResult = await withAPIAuth(request); if (authResult.authRequired && !authResult.authenticated) { - return createAuthErrorResponse('Authentication required'); + return apiError.unauthorized(); } // Return upload configuration and status const nextcloudConfigured = isNextcloudConfigured(); - // Check local upload directory let localStorageAvailable = false; try { @@ -314,19 +278,7 @@ export const GET: APIRoute = async ({ request }) => { } }; - return new Response(JSON.stringify(status), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }); + return apiResponse.success(status); - } catch (error) { - console.error('Media upload status error:', error); - - return new Response(JSON.stringify({ - error: 'Failed to get upload status' - }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); - } + }, 'Upload status retrieval failed'); }; \ No newline at end of file diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 8c0f5ec..fbc52ed 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -1,13 +1,49 @@ -// src/utils/auth.ts - SERVER-SIDE ONLY (remove client-side functions) -import { SignJWT, jwtVerify, type JWTPayload } from 'jose'; -import { serialize, parse } from 'cookie'; -import { config } from 'dotenv'; +// src/utils/auth.js (FIXED - Cleaned up and enhanced debugging) import type { AstroGlobal } from 'astro'; -import jwt from 'jsonwebtoken'; +import crypto from 'crypto'; +import { config } from 'dotenv'; +import { SignJWT, jwtVerify, type JWTPayload } from 'jose'; +import { serialize, parse as parseCookie } from 'cookie'; // Load environment variables config(); +// JWT session constants +const SECRET_KEY = new TextEncoder().encode( + process.env.AUTH_SECRET || + process.env.OIDC_CLIENT_SECRET || + 'cc24-hub-default-secret-key-change-in-production' +); +const SESSION_DURATION = 6 * 60 * 60; // 6 hours in seconds + +// Types +export interface SessionData { + userId: string; + email: string; + authenticated: boolean; + exp: number; +} + +export interface AuthContext { + authenticated: boolean; + session: SessionData | null; + userEmail: string; + userId: string; +} + +export interface UserInfo { + sub?: string; + preferred_username?: string; + email?: string; + given_name?: string; + family_name?: string; +} + +export interface AuthStateData { + state: string; + returnTo: string; +} + // Environment variables - use runtime access for server-side function getEnv(key: string): string { const value = process.env[key]; @@ -17,59 +53,25 @@ function getEnv(key: string): string { return value; } -const SECRET_KEY = new TextEncoder().encode( - process.env.AUTH_SECRET || - process.env.OIDC_CLIENT_SECRET || - 'cc24-hub-default-secret-key-change-in-production' -); -const SESSION_DURATION = 6 * 60 * 60; // 6 hours in seconds - -export interface SessionData { - userId: string; - email: string; - authenticated: boolean; - exp: number; -} - -export interface UserInfo { - sub: string; - preferred_username?: string; - email?: string; -} - -export interface AuthContext { - authenticated: boolean; - session: SessionData | null; - userEmail: string; - userId: string; -} - -export interface AuthStateData { - state: string; - returnTo: string; -} - - - -// Create a signed JWT session token with email -export async function createSession(userId: string, email: string): Promise { - const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION; +// Session management functions +export function getSessionFromRequest(request: Request): string | null { + const cookieHeader = request.headers.get('cookie'); + console.log('[DEBUG] Cookie header:', cookieHeader ? 'present' : 'missing'); - return await new SignJWT({ - userId, - email, - authenticated: true, - exp - }) - .setProtectedHeader({ alg: 'HS256' }) - .setExpirationTime(exp) - .sign(SECRET_KEY); + if (!cookieHeader) return null; + + const cookies = parseCookie(cookieHeader); + console.log('[DEBUG] Parsed cookies:', Object.keys(cookies)); + console.log('[DEBUG] Session cookie found:', !!cookies.session); + + return cookies.session || null; } -// Verify and decode a session token -export async function verifySession(token: string): Promise { +export async function verifySession(sessionToken: string): Promise { try { - const { payload } = await jwtVerify(token, SECRET_KEY); + console.log('[DEBUG] Verifying session token, length:', sessionToken.length); + const { payload } = await jwtVerify(sessionToken, SECRET_KEY); + console.log('[DEBUG] JWT verification successful, payload keys:', Object.keys(payload)); // Validate payload structure and cast properly if ( @@ -78,6 +80,7 @@ export async function verifySession(token: string): Promise typeof payload.authenticated === 'boolean' && typeof payload.exp === 'number' ) { + console.log('[DEBUG] Session validation successful for user:', payload.userId); return { userId: payload.userId, email: payload.email, @@ -86,49 +89,62 @@ export async function verifySession(token: string): Promise }; } + console.log('[DEBUG] Session payload validation failed, payload:', payload); return null; } catch (error) { - console.log('Session verification failed:', error); + console.log('[DEBUG] Session verification failed:', error.message); return null; } } -// Get session from request cookies -export function getSessionFromRequest(request: Request): string | null { - const cookieHeader = request.headers.get('cookie'); - if (!cookieHeader) return null; +export async function createSession(userId: string, email: string): Promise { + const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION; + console.log('[DEBUG] Creating session for user:', userId, 'exp:', exp); - const cookies = parse(cookieHeader); - return cookies.session || null; + const token = await new SignJWT({ + userId, + email, + authenticated: true, + exp + }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime(exp) + .sign(SECRET_KEY); + + console.log('[DEBUG] Session token created, length:', token.length); + return token; } -// Create session cookie -export function createSessionCookie(token: string): string { +export function createSessionCookie(sessionToken: string): string { const publicBaseUrl = getEnv('PUBLIC_BASE_URL'); const isProduction = process.env.NODE_ENV === 'production'; const isSecure = publicBaseUrl.startsWith('https://') || isProduction; - return serialize('session', token, { + const cookie = serialize('session', sessionToken, { httpOnly: true, secure: isSecure, sameSite: 'lax', maxAge: SESSION_DURATION, path: '/' }); + + console.log('[DEBUG] Created session cookie:', cookie.substring(0, 100) + '...'); + return cookie; } -// Clear session cookie -export function clearSessionCookie(): string { - const publicBaseUrl = getEnv('PUBLIC_BASE_URL'); - const isSecure = publicBaseUrl.startsWith('https://'); - - return serialize('session', '', { - httpOnly: true, - secure: isSecure, - sameSite: 'lax', - maxAge: 0, - path: '/' - }); +// Authentication utility functions +export function getUserEmail(userInfo: UserInfo): string { + return userInfo.email || userInfo.preferred_username || 'unknown@example.com'; +} + +export function logAuthEvent(event: string, details?: any): void { + const timestamp = new Date().toISOString(); + console.log(`[AUTH ${timestamp}] ${event}`, details ? JSON.stringify(details) : ''); +} + +// Generate random state for CSRF protection +export function generateState(): string { + return crypto.randomUUID(); } // Generate OIDC authorization URL @@ -149,7 +165,7 @@ export function generateAuthUrl(state: string): string { } // Exchange authorization code for tokens -export async function exchangeCodeForTokens(code: string): Promise { +export async function exchangeCodeForTokens(code: string): Promise<{ access_token: string }> { const oidcEndpoint = getEnv('OIDC_ENDPOINT'); const clientId = getEnv('OIDC_CLIENT_ID'); const clientSecret = getEnv('OIDC_CLIENT_SECRET'); @@ -196,27 +212,7 @@ export async function getUserInfo(accessToken: string): Promise { return await response.json(); } -// Generate random state for CSRF protection -export function generateState(): string { - return crypto.randomUUID(); -} - -// Log authentication events for debugging -export function logAuthEvent(event: string, details?: any) { - const timestamp = new Date().toISOString(); - console.log(`[AUTH ${timestamp}] ${event}`, details ? JSON.stringify(details) : ''); -} - -// Helper function to safely get email from user info -export function getUserEmail(userInfo: UserInfo): string { - return userInfo.email || - `${userInfo.preferred_username || userInfo.sub || 'unknown'}@cc24.dev`; -} - -/** - * CONSOLIDATED: Parse and validate auth state from cookies - * Replaces duplicated cookie parsing in callback.ts and process.ts - */ +// Parse and validate auth state from cookies export function parseAuthState(request: Request): { isValid: boolean; stateData: AuthStateData | null; @@ -224,7 +220,7 @@ export function parseAuthState(request: Request): { } { try { const cookieHeader = request.headers.get('cookie'); - const cookies = cookieHeader ? parse(cookieHeader) : {}; + const cookies = cookieHeader ? parseCookie(cookieHeader) : {}; if (!cookies.auth_state) { return { isValid: false, stateData: null, error: 'No auth state cookie' }; @@ -242,10 +238,7 @@ export function parseAuthState(request: Request): { } } -/** - * CONSOLIDATED: Verify state parameter against stored state - * Replaces duplicated verification logic in callback.ts and process.ts - */ +// Verify state parameter against stored state export function verifyAuthState(request: Request, receivedState: string): { isValid: boolean; stateData: AuthStateData | null; @@ -307,6 +300,8 @@ export async function createSessionWithCookie(userInfo: UserInfo): Promise<{ */ export async function withAuth(Astro: AstroGlobal): Promise { const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false'; + console.log('[DEBUG PAGE] Auth required:', authRequired); + console.log('[DEBUG PAGE] Request URL:', Astro.url.toString()); // If auth not required, return mock context if (!authRequired) { @@ -320,7 +315,10 @@ export async function withAuth(Astro: AstroGlobal): Promise