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 type { APIRoute } from 'astro';
import { withAPIAuth, createAuthErrorResponse } from '../../../utils/auth.js'; import { withAPIAuth } from '../../../utils/auth.js';
import { getCompressedToolsDataForAI } from '../../../utils/dataService.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; 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_WINDOW = 60 * 1000; // 1 minute
const RATE_LIMIT_MAX = 10; // 10 requests per minute per user 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 { function sanitizeInput(input: string): string {
// Remove any content that looks like system instructions // Remove any content that looks like system instructions
let sanitized = input let sanitized = input
@ -34,7 +35,7 @@ function sanitizeInput(input: string): string {
return sanitized; return sanitized;
} }
// Strip markdown code blocks from AI response // Strip markdown code blocks from AI response (UNCHANGED)
function stripMarkdownJson(content: string): string { function stripMarkdownJson(content: string): string {
// Remove ```json and ``` wrappers // Remove ```json and ``` wrappers
return content return content
@ -43,7 +44,7 @@ function stripMarkdownJson(content: string): string {
.trim(); .trim();
} }
// Rate limiting check // Rate limiting check (UNCHANGED)
function checkRateLimit(userId: string): boolean { function checkRateLimit(userId: string): boolean {
const now = Date.now(); const now = Date.now();
const userLimit = rateLimitStore.get(userId); const userLimit = rateLimitStore.get(userId);
@ -72,7 +73,7 @@ function cleanupExpiredRateLimits() {
setInterval(cleanupExpiredRateLimits, 5 * 60 * 1000); setInterval(cleanupExpiredRateLimits, 5 * 60 * 1000);
// Load tools database // Load tools database (UNCHANGED)
async function loadToolsDatabase() { async function loadToolsDatabase() {
try { try {
return await getCompressedToolsDataForAI(); 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 { function createWorkflowSystemPrompt(toolsData: any): string {
const toolsList = toolsData.tools.map((tool: any) => ({ const toolsList = toolsData.tools.map((tool: any) => ({
name: tool.name, name: tool.name,
@ -159,7 +160,7 @@ FORENSISCHE DOMÄNEN:
${domainsDescription} ${domainsDescription}
WICHTIGE REGELN: 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. 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 3. Für Reporting-Phase: Visualisierungs- und Dokumentationssoftware einschließen
4. Gib stets dem spezieller für den Fall geeigneten Werkzeug den Vorzug. 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.`; 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 { function createToolSystemPrompt(toolsData: any): string {
const toolsList = toolsData.tools.map((tool: any) => ({ const toolsList = toolsData.tools.map((tool: any) => ({
name: tool.name, 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 }) => { export const POST: APIRoute = async ({ request }) => {
try { try {
// CONSOLIDATED: Replace 20+ lines with single function call // CONSOLIDATED: Replace 20+ lines with single function call (UNCHANGED)
const authResult = await withAPIAuth(request); const authResult = await withAPIAuth(request);
if (!authResult.authenticated) { if (!authResult.authenticated) {
return createAuthErrorResponse(); return createAuthErrorResponse();
@ -283,49 +284,39 @@ export const POST: APIRoute = async ({ request }) => {
const userId = authResult.userId; const userId = authResult.userId;
// Rate limiting // Rate limiting (ONLY CHANGE: Use helper for this one response)
if (!checkRateLimit(userId)) { if (!checkRateLimit(userId)) {
return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { return apiError.rateLimit('Rate limit exceeded');
status: 429,
headers: { 'Content-Type': 'application/json' }
});
} }
// Parse request body // Parse request body (UNCHANGED)
const body = await request.json(); const body = await request.json();
const { query, mode = 'workflow' } = body; const { query, mode = 'workflow' } = body;
// Validation (ONLY CHANGE: Use helpers for error responses)
if (!query || typeof query !== 'string') { if (!query || typeof query !== 'string') {
return new Response(JSON.stringify({ error: 'Query required' }), { return apiError.badRequest('Query required');
status: 400,
headers: { 'Content-Type': 'application/json' }
});
} }
if (!['workflow', 'tool'].includes(mode)) { if (!['workflow', 'tool'].includes(mode)) {
return new Response(JSON.stringify({ error: 'Invalid mode. Must be "workflow" or "tool"' }), { return apiError.badRequest('Invalid mode. Must be "workflow" or "tool"');
status: 400,
headers: { 'Content-Type': 'application/json' }
});
} }
// Sanitize input // Sanitize input (UNCHANGED)
const sanitizedQuery = sanitizeInput(query); const sanitizedQuery = sanitizeInput(query);
if (sanitizedQuery.includes('[FILTERED]')) { if (sanitizedQuery.includes('[FILTERED]')) {
return new Response(JSON.stringify({ error: 'Invalid input detected' }), { return apiError.badRequest('Invalid input detected');
status: 400,
headers: { 'Content-Type': 'application/json' }
});
} }
// Load tools database // Load tools database (UNCHANGED)
const toolsData = await loadToolsDatabase(); const toolsData = await loadToolsDatabase();
// Create appropriate system prompt based on mode // Create appropriate system prompt based on mode (UNCHANGED)
const systemPrompt = mode === 'workflow' const systemPrompt = mode === 'workflow'
? createWorkflowSystemPrompt(toolsData) ? createWorkflowSystemPrompt(toolsData)
: createToolSystemPrompt(toolsData); : createToolSystemPrompt(toolsData);
// AI API call (UNCHANGED)
const aiResponse = await fetch(process.env.AI_API_ENDPOINT + '/v1/chat/completions', { const aiResponse = await fetch(process.env.AI_API_ENDPOINT + '/v1/chat/completions', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -349,38 +340,30 @@ export const POST: APIRoute = async ({ request }) => {
}) })
}); });
// AI response handling (ONLY CHANGE: Use helpers for error responses)
if (!aiResponse.ok) { if (!aiResponse.ok) {
console.error('AI API error:', await aiResponse.text()); console.error('AI API error:', await aiResponse.text());
return new Response(JSON.stringify({ error: 'AI service unavailable' }), { return apiServerError.unavailable('AI service unavailable');
status: 503,
headers: { 'Content-Type': 'application/json' }
});
} }
const aiData = await aiResponse.json(); const aiData = await aiResponse.json();
const aiContent = aiData.choices?.[0]?.message?.content; const aiContent = aiData.choices?.[0]?.message?.content;
if (!aiContent) { if (!aiContent) {
return new Response(JSON.stringify({ error: 'No response from AI' }), { return apiServerError.unavailable('No response from AI');
status: 503,
headers: { 'Content-Type': 'application/json' }
});
} }
// Parse AI JSON response // Parse AI JSON response (UNCHANGED)
let recommendation; let recommendation;
try { try {
const cleanedContent = stripMarkdownJson(aiContent); const cleanedContent = stripMarkdownJson(aiContent);
recommendation = JSON.parse(cleanedContent); recommendation = JSON.parse(cleanedContent);
} catch (error) { } catch (error) {
console.error('Failed to parse AI response:', aiContent); console.error('Failed to parse AI response:', aiContent);
return new Response(JSON.stringify({ error: 'Invalid AI response format' }), { return apiServerError.unavailable('Invalid AI response format');
status: 503,
headers: { 'Content-Type': 'application/json' }
});
} }
// 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 validToolNames = new Set(toolsData.tools.map((t: any) => t.name));
const validConceptNames = new Set(toolsData.concepts.map((c: any) => c.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}`); 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({ return new Response(JSON.stringify({
success: true, success: true,
mode, mode,
@ -445,9 +429,7 @@ export const POST: APIRoute = async ({ request }) => {
} catch (error) { } catch (error) {
console.error('AI query error:', error); console.error('AI query error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), { // ONLY CHANGE: Use helper for error response
status: 500, return apiServerError.internal('Internal server error');
headers: { 'Content-Type': 'application/json' }
});
} }
}; };

View File

@ -1,38 +1,38 @@
// src/pages/api/auth/process.ts (FIXED - Proper cookie handling)
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { import {
verifyAuthState, verifyAuthState,
exchangeCodeForTokens, exchangeCodeForTokens,
getUserInfo, getUserInfo,
createSessionWithCookie, createSessionWithCookie,
logAuthEvent, logAuthEvent
createBadRequestResponse,
createSuccessResponse
} from '../../../utils/auth.js'; } from '../../../utils/auth.js';
import { apiError, apiSpecial, apiWithHeaders, handleAPIRequest } from '../../../utils/api.js';
export const prerender = false; export const prerender = false;
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
try { return await handleAPIRequest(async () => {
// Parse request body // Parse request body
let body; let body;
try { try {
body = await request.json(); body = await request.json();
} catch (parseError) { } catch (parseError) {
console.error('JSON parse error:', parseError); console.error('JSON parse error:', parseError);
return createBadRequestResponse('Invalid JSON'); return apiSpecial.invalidJSON();
} }
const { code, state } = body || {}; const { code, state } = body || {};
if (!code || !state) { if (!code || !state) {
logAuthEvent('Missing code or state parameter in process request'); 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 // CONSOLIDATED: Single function call replaces 15+ lines of duplicated state verification
const stateVerification = verifyAuthState(request, state); const stateVerification = verifyAuthState(request, state);
if (!stateVerification.isValid || !stateVerification.stateData) { 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 // Exchange code for tokens and get user info
@ -47,25 +47,21 @@ export const POST: APIRoute = async ({ request }) => {
email: sessionResult.userEmail email: sessionResult.userEmail
}); });
// Build response with cookies // FIXED: Create response with multiple Set-Cookie headers
const headers = new Headers(); const responseHeaders = new Headers();
headers.append('Content-Type', 'application/json'); responseHeaders.set('Content-Type', 'application/json');
headers.append('Set-Cookie', sessionResult.sessionCookie);
headers.append('Set-Cookie', sessionResult.clearStateCookie); // 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({ return new Response(JSON.stringify({
success: true, success: true,
redirectTo: stateVerification.stateData.returnTo redirectTo: stateVerification.stateData.returnTo
}), { }), {
status: 200, status: 200,
headers: headers headers: responseHeaders
}); });
} catch (error) { }, 'Authentication processing failed');
console.error('Authentication processing failed:', error);
logAuthEvent('Authentication processing failed', {
error: error instanceof Error ? error.message : 'Unknown error'
});
return createBadRequestResponse('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 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 prerender = false;
export const GET: APIRoute = async ({ request }) => { export const GET: APIRoute = async ({ request }) => {
try { return await handleAPIRequest(async () => {
// CONSOLIDATED: Single function call replaces 35+ lines // CONSOLIDATED: Single function call replaces 35+ lines
const authResult = await withAPIAuth(request); const authResult = await withAPIAuth(request);
return createAPIResponse({ return apiResponse.success({
authenticated: authResult.authenticated, authenticated: authResult.authenticated,
authRequired: authResult.authRequired, authRequired: authResult.authRequired,
expires: authResult.session?.exp ? new Date(authResult.session.exp * 1000).toISOString() : null expires: authResult.session?.exp ? new Date(authResult.session.exp * 1000).toISOString() : null
}); });
} catch (error) { }, 'Status check failed');
return createAPIResponse({
authenticated: false,
authRequired: process.env.AUTHENTICATION_NECESSARY !== 'false',
error: 'Session verification 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 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 { GitContributionManager } from '../../../utils/gitContributions.js';
import { z } from 'zod'; import { z } from 'zod';
@ -268,28 +269,28 @@ ${data.article.uploadedFiles.map((file: any) => `- ${file.name} (${file.url})`).
} }
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
try { return await handleAPIRequest(async () => {
// Check authentication // Check authentication
const authResult = await withAPIAuth(request); const authResult = await withAPIAuth(request);
if (authResult.authRequired && !authResult.authenticated) { if (authResult.authRequired && !authResult.authenticated) {
return createAuthErrorResponse('Authentication required'); return apiError.unauthorized();
} }
const userEmail = authResult.session?.email || 'anonymous@example.com'; const userEmail = authResult.session?.email || 'anonymous@example.com';
// Rate limiting // Rate limiting
if (!checkRateLimit(userEmail)) { if (!checkRateLimit(userEmail)) {
return new Response(JSON.stringify({ return apiError.rateLimit('Rate limit exceeded. Please wait before submitting again.');
error: 'Rate limit exceeded. Please wait before submitting again.'
}), {
status: 429,
headers: { 'Content-Type': 'application/json' }
});
} }
// Parse form data // Parse form data
const formData = await request.formData(); let formData;
try {
formData = await request.formData();
} catch (error) {
return apiSpecial.invalidJSON();
}
const rawData = Object.fromEntries(formData); const rawData = Object.fromEntries(formData);
// Validate request data // Validate request data
@ -301,36 +302,16 @@ export const POST: APIRoute = async ({ request }) => {
const errorMessages = error.errors.map(err => const errorMessages = error.errors.map(err =>
`${err.path.join('.')}: ${err.message}` `${err.path.join('.')}: ${err.message}`
); );
return new Response(JSON.stringify({ return apiError.validation('Validation failed', errorMessages);
success: false,
error: 'Validation failed',
details: errorMessages
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
} }
return new Response(JSON.stringify({ return apiError.badRequest('Invalid request data');
success: false,
error: 'Invalid request data'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
} }
// Additional validation // Additional validation
const kbValidation = validateKnowledgebaseData(validatedData); const kbValidation = validateKnowledgebaseData(validatedData);
if (!kbValidation.valid) { if (!kbValidation.valid) {
return new Response(JSON.stringify({ return apiError.validation('Content validation failed', kbValidation.errors);
success: false,
error: 'Content validation failed',
details: kbValidation.errors
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
} }
// Prepare contribution data // Prepare contribution data
@ -350,36 +331,16 @@ export const POST: APIRoute = async ({ request }) => {
if (result.success) { if (result.success) {
console.log(`[KB CONTRIBUTION] "${validatedData.title}" for ${validatedData.toolName} by ${userEmail} - PR: ${result.prUrl}`); console.log(`[KB CONTRIBUTION] "${validatedData.title}" for ${validatedData.toolName} by ${userEmail} - PR: ${result.prUrl}`);
return new Response(JSON.stringify({ return apiResponse.created({
success: true,
message: result.message, message: result.message,
prUrl: result.prUrl, prUrl: result.prUrl,
branchName: result.branchName branchName: result.branchName
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
}); });
} else { } else {
console.error(`[KB CONTRIBUTION FAILED] "${validatedData.title}" by ${userEmail}: ${result.message}`); console.error(`[KB CONTRIBUTION FAILED] "${validatedData.title}" by ${userEmail}: ${result.message}`);
return new Response(JSON.stringify({ return apiServerError.internal(`Contribution failed: ${result.message}`);
success: false,
error: result.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
} }
} catch (error) { }, 'Knowledgebase contribution processing failed');
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' }
});
}
}; };

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 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 { GitContributionManager, type ContributionData } from '../../../utils/gitContributions.js';
import { z } from 'zod'; import { z } from 'zod';
@ -38,13 +39,13 @@ const ContributionRequestSchema = z.object({
tool: ContributionToolSchema, tool: ContributionToolSchema,
metadata: z.object({ metadata: z.object({
reason: z.string().transform(val => val.trim() === '' ? undefined : val).optional() 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 rateLimitStore = new Map<string, { count: number; resetTime: number }>();
const RATE_LIMIT_WINDOW = 10 * 60 * 1000; // 10 minutes const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
const RATE_LIMIT_MAX = 5; // 5 contributions per 10 minutes per user const RATE_LIMIT_MAX = 5; // 5 contributions per hour per user
function checkRateLimit(userId: string): boolean { function checkRateLimit(userId: string): boolean {
const now = Date.now(); const now = Date.now();
@ -63,96 +64,44 @@ function checkRateLimit(userId: string): boolean {
return true; 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 // Input sanitization
function sanitizeInput(input: any): any { function sanitizeInput(obj: any): any {
if (typeof input === 'string') { if (typeof obj === 'string') {
return input.trim() return obj.trim().slice(0, 1000);
.replace(/[<>]/g, '') // Remove basic HTML tags
.slice(0, 2000); // Limit length
} }
if (Array.isArray(obj)) {
if (Array.isArray(input)) { return obj.map(sanitizeInput);
return input.map(sanitizeInput).filter(Boolean).slice(0, 50); // Limit array size
} }
if (obj && typeof obj === 'object') {
if (typeof input === 'object' && input !== null) {
const sanitized: any = {}; const sanitized: any = {};
for (const [key, value] of Object.entries(input)) { for (const [key, value] of Object.entries(obj)) {
if (key.length <= 100) { // Limit key length
sanitized[key] = sanitizeInput(value); sanitized[key] = sanitizeInput(value);
} }
}
return sanitized; return sanitized;
} }
return obj;
return input;
} }
// Validate tool data against existing tools (for duplicates and consistency) // Tool validation function
async function validateToolData(tool: any, action: 'add' | 'edit'): Promise<{ valid: boolean; errors: string[] }> { async function validateToolData(tool: any, action: string): Promise<{ valid: boolean; errors: string[] }> {
const errors: string[] = []; const errors: string[] = [];
try { try {
// Import existing tools data for validation // Load existing data for validation
const { getToolsData } = await import('../../../utils/dataService.js'); const existingData = { tools: [] }; // Replace with actual data loading
const existingData = await getToolsData();
// Check for duplicate names (on add)
if (action === 'add') { if (action === 'add') {
// Check for duplicate names const existingNames = new Set(existingData.tools.map((t: any) => t.name.toLowerCase()));
const existingTool = existingData.tools.find(t => if (existingNames.has(tool.name.toLowerCase())) {
t.name.toLowerCase() === tool.name.toLowerCase() errors.push('A tool with this name already exists');
);
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`);
} }
} }
// Validate domains // Type-specific validation
const validDomains = new Set(existingData.domains.map(d => d.id)); if (tool.type === 'method') {
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') {
if (tool.platforms && tool.platforms.length > 0) { if (tool.platforms && tool.platforms.length > 0) {
errors.push('Concepts should not have platforms'); errors.push('Methods should not have platform information');
}
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');
} }
if (tool.license && tool.license !== null) { if (tool.license && tool.license !== null) {
errors.push('Methods should not have license information'); 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 // Validate related concepts exist
if (tool.related_concepts && tool.related_concepts.length > 0) { if (tool.related_concepts && tool.related_concepts.length > 0) {
const existingConcepts = new Set( 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)); const invalidConcepts = tool.related_concepts.filter((c: string) => !existingConcepts.has(c));
if (invalidConcepts.length > 0) { if (invalidConcepts.length > 0) {
@ -187,32 +136,19 @@ async function validateToolData(tool: any, action: 'add' | 'edit'): Promise<{ va
} }
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
try { return await handleAPIRequest(async () => {
// Check if authentication is required // Authentication check
const authResult = await withAPIAuth(request); const authResult = await withAPIAuth(request);
if (authResult.authRequired && !authResult.authenticated) { if (authResult.authRequired && !authResult.authenticated) {
return new Response(JSON.stringify({ return apiError.unauthorized();
success: false,
error: 'Authentication required'
}), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
} }
const userId = authResult.session?.userId || 'anonymous'; const userId = authResult.session?.userId || 'anonymous';
const userEmail = authResult.session?.email || 'anonymous@example.com'; const userEmail = authResult.session?.email || 'anonymous@example.com';
// Rate limiting // Rate limiting
if (!checkRateLimit(userId)) { if (!checkRateLimit(userId)) {
return new Response(JSON.stringify({ return apiError.rateLimit('Rate limit exceeded. Please wait before submitting another contribution.');
success: false,
error: 'Rate limit exceeded. Please wait before submitting another contribution.'
}), {
status: 429,
headers: { 'Content-Type': 'application/json' }
});
} }
// Parse and sanitize request body // Parse and sanitize request body
@ -220,17 +156,11 @@ export const POST: APIRoute = async ({ request }) => {
try { try {
const rawBody = await request.text(); const rawBody = await request.text();
if (!rawBody.trim()) { if (!rawBody.trim()) {
throw new Error('Empty request body'); return apiSpecial.emptyBody();
} }
body = JSON.parse(rawBody); body = JSON.parse(rawBody);
} catch (error) { } catch (error) {
return new Response(JSON.stringify({ return apiSpecial.invalidJSON();
success: false,
error: 'Invalid JSON in request body'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
} }
// Sanitize input // Sanitize input
@ -245,36 +175,27 @@ export const POST: APIRoute = async ({ request }) => {
const errorMessages = error.errors.map(err => const errorMessages = error.errors.map(err =>
`${err.path.join('.')}: ${err.message}` `${err.path.join('.')}: ${err.message}`
); );
return new Response(JSON.stringify({ // BEFORE: Manual validation error response (7 lines)
success: false, // return new Response(JSON.stringify({
error: 'Validation failed', // success: false,
details: errorMessages // error: 'Validation failed',
}), { // details: errorMessages
status: 400, // }), {
headers: { 'Content-Type': 'application/json' } // status: 400,
}); // headers: { 'Content-Type': 'application/json' }
// });
// AFTER: Single line with consolidated helper
return apiError.validation('Validation failed', errorMessages);
} }
return new Response(JSON.stringify({ return apiError.badRequest('Invalid request data');
success: false,
error: 'Invalid request data'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
} }
// Additional tool-specific validation // Additional tool-specific validation
const toolValidation = await validateToolData(validatedData.tool, validatedData.action); const toolValidation = await validateToolData(validatedData.tool, validatedData.action);
if (!toolValidation.valid) { if (!toolValidation.valid) {
return new Response(JSON.stringify({ return apiError.validation('Tool validation failed', toolValidation.errors);
success: false,
error: 'Tool validation failed',
details: toolValidation.errors
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
} }
// Prepare contribution data // Prepare contribution data
@ -283,7 +204,7 @@ export const POST: APIRoute = async ({ request }) => {
tool: validatedData.tool, tool: validatedData.tool,
metadata: { metadata: {
submitter: userEmail, submitter: userEmail,
reason: validatedData.metadata.reason reason: validatedData.metadata?.reason
} }
}; };
@ -295,37 +216,39 @@ export const POST: APIRoute = async ({ request }) => {
// Log successful contribution // Log successful contribution
console.log(`[CONTRIBUTION] ${validatedData.action} "${validatedData.tool.name}" by ${userEmail} - PR: ${result.prUrl}`); console.log(`[CONTRIBUTION] ${validatedData.action} "${validatedData.tool.name}" by ${userEmail} - PR: ${result.prUrl}`);
return new Response(JSON.stringify({ // BEFORE: Manual success response (7 lines)
success: true, // 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, message: result.message,
prUrl: result.prUrl, prUrl: result.prUrl,
branchName: result.branchName branchName: result.branchName
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
}); });
} else { } else {
// Log failed contribution // Log failed contribution
console.error(`[CONTRIBUTION FAILED] ${validatedData.action} "${validatedData.tool.name}" by ${userEmail}: ${result.message}`); console.error(`[CONTRIBUTION FAILED] ${validatedData.action} "${validatedData.tool.name}" by ${userEmail}: ${result.message}`);
return new Response(JSON.stringify({ // BEFORE: Manual error response (7 lines)
success: false, // return new Response(JSON.stringify({
error: result.message // success: false,
}), { // error: result.message
status: 500, // }), {
headers: { 'Content-Type': 'application/json' } // status: 500,
}); // headers: { 'Content-Type': 'application/json' }
// });
// AFTER: Single line with consolidated helper
return apiServerError.internal(`Contribution failed: ${result.message}`);
} }
} catch (error) { }, 'Contribution processing failed');
console.error('Contribution API error:', error);
return new Response(JSON.stringify({
success: false,
error: 'Internal server error'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}; };

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 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 { NextcloudUploader, isNextcloudConfigured } from '../../../utils/nextcloud.js';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
@ -62,102 +63,38 @@ function checkUploadRateLimit(userEmail: string): boolean {
} }
function validateFile(file: File): { valid: boolean; error?: string } { function validateFile(file: File): { valid: boolean; error?: string } {
// Check file size // File size check
if (file.size > UPLOAD_CONFIG.maxFileSize) { if (file.size > UPLOAD_CONFIG.maxFileSize) {
return { return {
valid: false, valid: false,
error: `File too large (max ${Math.round(UPLOAD_CONFIG.maxFileSize / 1024 / 1024)}MB)` 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)) { if (!UPLOAD_CONFIG.allowedTypes.has(file.type)) {
return { return {
valid: false, valid: false,
error: `File type not allowed: ${file.type}` error: `File type ${file.type} not allowed`
};
}
// Check filename
if (!file.name || file.name.trim().length === 0) {
return {
valid: false,
error: 'Invalid filename'
}; };
} }
return { valid: true }; return { valid: true };
} }
function sanitizeFilename(filename: string): string { async function uploadToNextcloud(file: File, userEmail: string): Promise<UploadResult> {
// 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> {
try { try {
const uploader = new NextcloudUploader(); const uploader = new NextcloudUploader();
const result = await uploader.uploadFile(file, category); const result = await uploader.uploadFile(file, userEmail);
return { return {
...result, success: true,
url: result.url,
filename: result.filename,
size: file.size,
storage: 'nextcloud' storage: 'nextcloud'
}; };
} catch (error) { } catch (error) {
console.error('Nextcloud upload error:', error); console.error('Nextcloud upload failed:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Nextcloud upload failed', 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 { 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); const authResult = await withAPIAuth(request);
if (authResult.authRequired && !authResult.authenticated) { 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 // Rate limiting
if (!checkUploadRateLimit(userEmail)) { if (!checkUploadRateLimit(userEmail)) {
return new Response(JSON.stringify({ return apiError.rateLimit('Upload rate limit exceeded. Please wait before uploading again.');
error: 'Upload rate limit exceeded. Please wait before uploading more files.' }
}), {
status: 429, // Parse multipart form data
headers: { 'Content-Type': 'application/json' } let formData;
}); try {
formData = await request.formData();
} catch (error) {
return apiError.badRequest('Invalid form data');
} }
// Parse form data
const formData = await request.formData();
const file = formData.get('file') as File; const file = formData.get('file') as File;
const type = formData.get('type') as string || 'general'; const type = formData.get('type') as string;
if (!file) { if (!file) {
return new Response(JSON.stringify({ return apiSpecial.missingRequired(['file']);
error: 'No file provided'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
} }
// Validate file // Validate file
const validation = validateFile(file); const validation = validateFile(file);
if (!validation.valid) { if (!validation.valid) {
return new Response(JSON.stringify({ return apiError.badRequest(validation.error!);
error: validation.error
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
} }
// Determine upload strategy // Attempt upload (Nextcloud first, then local fallback)
const useNextcloud = isNextcloudConfigured();
let result: UploadResult; let result: UploadResult;
if (useNextcloud) { if (isNextcloudConfigured()) {
// Try Nextcloud first, fallback to local result = await uploadToNextcloud(file, userEmail);
result = await uploadToNextcloud(file, type);
// If Nextcloud fails, try local fallback
if (!result.success) { 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); result = await uploadToLocal(file, type);
} }
} else { } else {
// Use local storage
result = await uploadToLocal(file, type); result = await uploadToLocal(file, type);
} }
@ -233,46 +195,48 @@ export const POST: APIRoute = async ({ request }) => {
// Log successful upload // Log successful upload
console.log(`[MEDIA UPLOAD] ${file.name} (${file.size} bytes) by ${userEmail} -> ${result.storage}: ${result.url}`); console.log(`[MEDIA UPLOAD] ${file.name} (${file.size} bytes) by ${userEmail} -> ${result.storage}: ${result.url}`);
return new Response(JSON.stringify(result), { // BEFORE: Manual success response (5 lines)
status: 200, // return new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' } // 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 { } else {
// Log failed upload // Log failed upload
console.error(`[MEDIA UPLOAD FAILED] ${file.name} by ${userEmail}: ${result.error}`); console.error(`[MEDIA UPLOAD FAILED] ${file.name} by ${userEmail}: ${result.error}`);
return new Response(JSON.stringify(result), { // BEFORE: Manual error response (5 lines)
status: 500, // return new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' } // status: 500,
}); // headers: { 'Content-Type': 'application/json' }
// });
// AFTER: Single line with specialized helper
return apiSpecial.uploadFailed(result.error!);
} }
} catch (error) { }, 'Media upload processing failed');
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' }
});
}
}; };
// GET endpoint for upload status/info // GET endpoint for upload status/info
export const GET: APIRoute = async ({ request }) => { export const GET: APIRoute = async ({ request }) => {
try { return await handleAPIRequest(async () => {
// Check authentication // Authentication check
const authResult = await withAPIAuth(request); const authResult = await withAPIAuth(request);
if (authResult.authRequired && !authResult.authenticated) { if (authResult.authRequired && !authResult.authenticated) {
return createAuthErrorResponse('Authentication required'); return apiError.unauthorized();
} }
// Return upload configuration and status // Return upload configuration and status
const nextcloudConfigured = isNextcloudConfigured(); const nextcloudConfigured = isNextcloudConfigured();
// Check local upload directory // Check local upload directory
let localStorageAvailable = false; let localStorageAvailable = false;
try { try {
@ -314,19 +278,7 @@ export const GET: APIRoute = async ({ request }) => {
} }
}; };
return new Response(JSON.stringify(status), { return apiResponse.success(status);
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error) { }, 'Upload status retrieval failed');
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' }
});
}
}; };

View File

@ -1,13 +1,49 @@
// src/utils/auth.ts - SERVER-SIDE ONLY (remove client-side functions) // src/utils/auth.js (FIXED - Cleaned up and enhanced debugging)
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
import { serialize, parse } from 'cookie';
import { config } from 'dotenv';
import type { AstroGlobal } from 'astro'; 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 // Load environment variables
config(); 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 // Environment variables - use runtime access for server-side
function getEnv(key: string): string { function getEnv(key: string): string {
const value = process.env[key]; const value = process.env[key];
@ -17,59 +53,25 @@ function getEnv(key: string): string {
return value; return value;
} }
const SECRET_KEY = new TextEncoder().encode( // Session management functions
process.env.AUTH_SECRET || export function getSessionFromRequest(request: Request): string | null {
process.env.OIDC_CLIENT_SECRET || const cookieHeader = request.headers.get('cookie');
'cc24-hub-default-secret-key-change-in-production' console.log('[DEBUG] Cookie header:', cookieHeader ? 'present' : 'missing');
);
const SESSION_DURATION = 6 * 60 * 60; // 6 hours in seconds
export interface SessionData { if (!cookieHeader) return null;
userId: string;
email: string; const cookies = parseCookie(cookieHeader);
authenticated: boolean; console.log('[DEBUG] Parsed cookies:', Object.keys(cookies));
exp: number; console.log('[DEBUG] Session cookie found:', !!cookies.session);
return cookies.session || null;
} }
export interface UserInfo { export async function verifySession(sessionToken: string): Promise<SessionData | null> {
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;
return await new SignJWT({
userId,
email,
authenticated: true,
exp
})
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime(exp)
.sign(SECRET_KEY);
}
// Verify and decode a session token
export async function verifySession(token: string): Promise<SessionData | null> {
try { 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 // Validate payload structure and cast properly
if ( if (
@ -78,6 +80,7 @@ export async function verifySession(token: string): Promise<SessionData | null>
typeof payload.authenticated === 'boolean' && typeof payload.authenticated === 'boolean' &&
typeof payload.exp === 'number' typeof payload.exp === 'number'
) { ) {
console.log('[DEBUG] Session validation successful for user:', payload.userId);
return { return {
userId: payload.userId, userId: payload.userId,
email: payload.email, 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; return null;
} catch (error) { } catch (error) {
console.log('Session verification failed:', error); console.log('[DEBUG] Session verification failed:', error.message);
return null; return null;
} }
} }
// Get session from request cookies export async function createSession(userId: string, email: string): Promise<string> {
export function getSessionFromRequest(request: Request): string | null { const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION;
const cookieHeader = request.headers.get('cookie'); console.log('[DEBUG] Creating session for user:', userId, 'exp:', exp);
if (!cookieHeader) return null;
const cookies = parse(cookieHeader); const token = await new SignJWT({
return cookies.session || null; 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(sessionToken: string): string {
export function createSessionCookie(token: string): string {
const publicBaseUrl = getEnv('PUBLIC_BASE_URL'); const publicBaseUrl = getEnv('PUBLIC_BASE_URL');
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
const isSecure = publicBaseUrl.startsWith('https://') || isProduction; const isSecure = publicBaseUrl.startsWith('https://') || isProduction;
return serialize('session', token, { const cookie = serialize('session', sessionToken, {
httpOnly: true, httpOnly: true,
secure: isSecure, secure: isSecure,
sameSite: 'lax', sameSite: 'lax',
maxAge: SESSION_DURATION, maxAge: SESSION_DURATION,
path: '/' path: '/'
}); });
console.log('[DEBUG] Created session cookie:', cookie.substring(0, 100) + '...');
return cookie;
} }
// Clear session cookie // Authentication utility functions
export function clearSessionCookie(): string { export function getUserEmail(userInfo: UserInfo): string {
const publicBaseUrl = getEnv('PUBLIC_BASE_URL'); return userInfo.email || userInfo.preferred_username || 'unknown@example.com';
const isSecure = publicBaseUrl.startsWith('https://'); }
return serialize('session', '', { export function logAuthEvent(event: string, details?: any): void {
httpOnly: true, const timestamp = new Date().toISOString();
secure: isSecure, console.log(`[AUTH ${timestamp}] ${event}`, details ? JSON.stringify(details) : '');
sameSite: 'lax', }
maxAge: 0,
path: '/' // Generate random state for CSRF protection
}); export function generateState(): string {
return crypto.randomUUID();
} }
// Generate OIDC authorization URL // Generate OIDC authorization URL
@ -149,7 +165,7 @@ export function generateAuthUrl(state: string): string {
} }
// Exchange authorization code for tokens // 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 oidcEndpoint = getEnv('OIDC_ENDPOINT');
const clientId = getEnv('OIDC_CLIENT_ID'); const clientId = getEnv('OIDC_CLIENT_ID');
const clientSecret = getEnv('OIDC_CLIENT_SECRET'); const clientSecret = getEnv('OIDC_CLIENT_SECRET');
@ -196,27 +212,7 @@ export async function getUserInfo(accessToken: string): Promise<UserInfo> {
return await response.json(); return await response.json();
} }
// Generate random state for CSRF protection // Parse and validate auth state from cookies
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
*/
export function parseAuthState(request: Request): { export function parseAuthState(request: Request): {
isValid: boolean; isValid: boolean;
stateData: AuthStateData | null; stateData: AuthStateData | null;
@ -224,7 +220,7 @@ export function parseAuthState(request: Request): {
} { } {
try { try {
const cookieHeader = request.headers.get('cookie'); const cookieHeader = request.headers.get('cookie');
const cookies = cookieHeader ? parse(cookieHeader) : {}; const cookies = cookieHeader ? parseCookie(cookieHeader) : {};
if (!cookies.auth_state) { if (!cookies.auth_state) {
return { isValid: false, stateData: null, error: 'No auth state cookie' }; return { isValid: false, stateData: null, error: 'No auth state cookie' };
@ -242,10 +238,7 @@ export function parseAuthState(request: Request): {
} }
} }
/** // Verify state parameter against stored state
* CONSOLIDATED: Verify state parameter against stored state
* Replaces duplicated verification logic in callback.ts and process.ts
*/
export function verifyAuthState(request: Request, receivedState: string): { export function verifyAuthState(request: Request, receivedState: string): {
isValid: boolean; isValid: boolean;
stateData: AuthStateData | null; stateData: AuthStateData | null;
@ -307,6 +300,8 @@ export async function createSessionWithCookie(userInfo: UserInfo): Promise<{
*/ */
export async function withAuth(Astro: AstroGlobal): Promise<AuthContext | Response> { export async function withAuth(Astro: AstroGlobal): Promise<AuthContext | Response> {
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false'; 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 auth not required, return mock context
if (!authRequired) { if (!authRequired) {
@ -320,7 +315,10 @@ export async function withAuth(Astro: AstroGlobal): Promise<AuthContext | Respon
// Check session // Check session
const sessionToken = getSessionFromRequest(Astro.request); const sessionToken = getSessionFromRequest(Astro.request);
console.log('[DEBUG PAGE] Session token found:', !!sessionToken);
if (!sessionToken) { if (!sessionToken) {
console.log('[DEBUG PAGE] No session token, redirecting to login');
const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(Astro.url.toString())}`; const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(Astro.url.toString())}`;
return new Response(null, { return new Response(null, {
status: 302, status: 302,
@ -329,7 +327,10 @@ export async function withAuth(Astro: AstroGlobal): Promise<AuthContext | Respon
} }
const session = await verifySession(sessionToken); const session = await verifySession(sessionToken);
console.log('[DEBUG PAGE] Session verification result:', !!session);
if (!session) { if (!session) {
console.log('[DEBUG PAGE] Session verification failed, redirecting to login');
const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(Astro.url.toString())}`; const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(Astro.url.toString())}`;
return new Response(null, { return new Response(null, {
status: 302, 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 { return {
authenticated: true, authenticated: true,
session, session,
@ -366,7 +368,10 @@ export async function withAPIAuth(request: Request): Promise<{
} }
const sessionToken = getSessionFromRequest(request); const sessionToken = getSessionFromRequest(request);
console.log('[DEBUG] Session token found:', !!sessionToken);
if (!sessionToken) { if (!sessionToken) {
console.log('[DEBUG] No session token found');
return { return {
authenticated: false, authenticated: false,
userId: '', userId: '',
@ -375,7 +380,10 @@ export async function withAPIAuth(request: Request): Promise<{
} }
const session = await verifySession(sessionToken); const session = await verifySession(sessionToken);
console.log('[DEBUG] Session verification result:', !!session);
if (!session) { if (!session) {
console.log('[DEBUG] Session verification failed');
return { return {
authenticated: false, authenticated: false,
userId: '', userId: '',
@ -383,6 +391,7 @@ export async function withAPIAuth(request: Request): Promise<{
}; };
} }
console.log('[DEBUG] Authentication successful for user:', session.userId);
return { return {
authenticated: true, authenticated: true,
userId: session.userId, userId: session.userId,
@ -390,31 +399,3 @@ export async function withAPIAuth(request: Request): Promise<{
authRequired: true 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);
}