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