api unification
This commit is contained in:
parent
f76999ed2e
commit
209f173d7a
@ -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' }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
@ -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');
|
|
||||||
}
|
|
||||||
};
|
};
|
@ -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'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
@ -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' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
@ -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' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
@ -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
|
||||||
const authResult = await withAPIAuth(request);
|
await fs.mkdir(UPLOAD_CONFIG.localUploadPath, { recursive: true });
|
||||||
if (authResult.authRequired && !authResult.authenticated) {
|
|
||||||
return createAuthErrorResponse('Authentication required');
|
// 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'
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userEmail = authResult.session?.email || 'anonymous';
|
// 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 apiError.unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
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' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
@ -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);
|
|
||||||
}
|
|
Loading…
x
Reference in New Issue
Block a user