api unification

This commit is contained in:
overcuriousity 2025-07-24 20:13:11 +02:00
parent f76999ed2e
commit 209f173d7a
7 changed files with 408 additions and 617 deletions

View File

@ -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<string, { count: number; resetTime: number }>();
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');
}
};

View File

@ -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');
};

View File

@ -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');
};

View File

@ -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');
};

View File

@ -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<string, { count: number; resetTime: number }>();
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');
};

View File

@ -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<UploadResult> {
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<UploadResult> {
async function uploadToNextcloud(file: File, userEmail: string): Promise<UploadResult> {
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<UploadRe
}
}
export const POST: APIRoute = async ({ request }) => {
async function uploadToLocal(file: File, userType: string): Promise<UploadResult> {
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');
};

View File

@ -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<string> {
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<SessionData | null> {
export async function verifySession(sessionToken: string): Promise<SessionData | null> {
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<SessionData | null>
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<SessionData | null>
};
}
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<string> {
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<any> {
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<UserInfo> {
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<AuthContext | Response> {
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<AuthContext | Respon
// Check session
const sessionToken = getSessionFromRequest(Astro.request);
console.log('[DEBUG PAGE] Session token found:', !!sessionToken);
if (!sessionToken) {
console.log('[DEBUG PAGE] No session token, redirecting to login');
const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(Astro.url.toString())}`;
return new Response(null, {
status: 302,
@ -329,7 +327,10 @@ export async function withAuth(Astro: AstroGlobal): Promise<AuthContext | Respon
}
const session = await verifySession(sessionToken);
console.log('[DEBUG PAGE] Session verification result:', !!session);
if (!session) {
console.log('[DEBUG PAGE] Session verification failed, redirecting to login');
const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(Astro.url.toString())}`;
return new Response(null, {
status: 302,
@ -337,6 +338,7 @@ export async function withAuth(Astro: AstroGlobal): Promise<AuthContext | Respon
});
}
console.log('[DEBUG PAGE] Page authentication successful for user:', session.userId);
return {
authenticated: true,
session,
@ -366,7 +368,10 @@ export async function withAPIAuth(request: Request): Promise<{
}
const sessionToken = getSessionFromRequest(request);
console.log('[DEBUG] Session token found:', !!sessionToken);
if (!sessionToken) {
console.log('[DEBUG] No session token found');
return {
authenticated: false,
userId: '',
@ -375,7 +380,10 @@ export async function withAPIAuth(request: Request): Promise<{
}
const session = await verifySession(sessionToken);
console.log('[DEBUG] Session verification result:', !!session);
if (!session) {
console.log('[DEBUG] Session verification failed');
return {
authenticated: false,
userId: '',
@ -383,38 +391,11 @@ export async function withAPIAuth(request: Request): Promise<{
};
}
console.log('[DEBUG] Authentication successful for user:', session.userId);
return {
authenticated: true,
userId: session.userId,
session,
authRequired: true
};
}
/**
* Helper to create consistent API auth error responses
*/
export function createAuthErrorResponse(message: string = 'Authentication required'): Response {
return new Response(JSON.stringify({ error: message }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
/**
* CONSOLIDATED: Create consistent API responses
*/
export function createAPIResponse(data: any, status: number = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' }
});
}
export function createBadRequestResponse(message: string = 'Bad request'): Response {
return createAPIResponse({ error: message }, 400);
}
export function createSuccessResponse(data: any = { success: true }): Response {
return createAPIResponse(data, 200);
}