219 lines
7.6 KiB
TypeScript
219 lines
7.6 KiB
TypeScript
// src/pages/api/contribute/tool.ts (UPDATED - Using consolidated API responses)
|
|
import type { APIRoute } from 'astro';
|
|
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';
|
|
|
|
export const prerender = false;
|
|
|
|
// Enhanced tool schema for contributions (stricter validation)
|
|
const ContributionToolSchema = z.object({
|
|
name: z.string().min(1, 'Tool name is required').max(100, 'Tool name too long'),
|
|
icon: z.string().optional().nullable(),
|
|
type: z.enum(['software', 'method', 'concept'], {
|
|
errorMap: () => ({ message: 'Type must be software, method, or concept' })
|
|
}),
|
|
description: z.string().min(10, 'Description must be at least 10 characters').max(1000, 'Description too long'),
|
|
domains: z.array(z.string()).default([]),
|
|
phases: z.array(z.string()).default([]),
|
|
platforms: z.array(z.string()).default([]),
|
|
skillLevel: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert'], {
|
|
errorMap: () => ({ message: 'Invalid skill level' })
|
|
}),
|
|
accessType: z.string().optional().nullable(),
|
|
url: z.string().url('Must be a valid URL'),
|
|
projectUrl: z.string().url('Must be a valid URL').optional().nullable(),
|
|
license: z.string().optional().nullable(),
|
|
knowledgebase: z.boolean().optional().nullable(),
|
|
'domain-agnostic-software': z.array(z.string()).optional().nullable(),
|
|
related_concepts: z.array(z.string()).optional().nullable(),
|
|
tags: z.array(z.string()).default([]),
|
|
statusUrl: z.string().url('Must be a valid URL').optional().nullable()
|
|
});
|
|
|
|
const ContributionRequestSchema = z.object({
|
|
action: z.enum(['add', 'edit'], {
|
|
errorMap: () => ({ message: 'Action must be add or edit' })
|
|
}),
|
|
tool: ContributionToolSchema,
|
|
metadata: z.object({
|
|
reason: z.string().transform(val => val.trim() === '' ? undefined : val).optional()
|
|
}).optional()
|
|
});
|
|
|
|
// Rate limiting
|
|
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
|
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
|
|
const RATE_LIMIT_MAX = 15; // 15 contributions per hour per user
|
|
|
|
function checkRateLimit(userId: string): boolean {
|
|
const now = Date.now();
|
|
const userLimit = rateLimitStore.get(userId);
|
|
|
|
if (!userLimit || now > userLimit.resetTime) {
|
|
rateLimitStore.set(userId, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
|
|
return true;
|
|
}
|
|
|
|
if (userLimit.count >= RATE_LIMIT_MAX) {
|
|
return false;
|
|
}
|
|
|
|
userLimit.count++;
|
|
return true;
|
|
}
|
|
|
|
// Input sanitization
|
|
function sanitizeInput(obj: any): any {
|
|
if (typeof obj === 'string') {
|
|
return obj.trim().slice(0, 1000);
|
|
}
|
|
if (Array.isArray(obj)) {
|
|
return obj.map(sanitizeInput);
|
|
}
|
|
if (obj && typeof obj === 'object') {
|
|
const sanitized: any = {};
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
sanitized[key] = sanitizeInput(value);
|
|
}
|
|
return sanitized;
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
// Tool validation function
|
|
async function validateToolData(tool: any, action: string): Promise<{ valid: boolean; errors: string[] }> {
|
|
const errors: string[] = [];
|
|
|
|
try {
|
|
// Load existing data for validation
|
|
const existingData = { tools: [] }; // Replace with actual data loading
|
|
|
|
// Check for duplicate names (on add)
|
|
if (action === 'add') {
|
|
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');
|
|
}
|
|
}
|
|
|
|
// Type-specific validation
|
|
if (tool.type === 'method') {
|
|
if (tool.platforms && tool.platforms.length > 0) {
|
|
errors.push('Methods should not have platform information');
|
|
}
|
|
if (tool.license && tool.license !== null) {
|
|
errors.push('Methods should not have license information');
|
|
}
|
|
} else if (tool.type === 'software') {
|
|
if (!tool.platforms || tool.platforms.length === 0) {
|
|
errors.push('Software tools must specify at least one platform');
|
|
}
|
|
if (!tool.license) {
|
|
errors.push('Software tools must specify a license');
|
|
}
|
|
}
|
|
|
|
return { valid: errors.length === 0, errors };
|
|
|
|
} catch (error) {
|
|
console.error('Tool validation failed:', error);
|
|
errors.push('Validation failed due to system error');
|
|
return { valid: false, errors };
|
|
}
|
|
}
|
|
|
|
export const POST: APIRoute = async ({ request }) => {
|
|
return await handleAPIRequest(async () => {
|
|
// Authentication check
|
|
const authResult = await withAPIAuth(request, 'contributions');
|
|
if (authResult.authRequired && !authResult.authenticated) {
|
|
return apiError.unauthorized();
|
|
}
|
|
|
|
const userId = authResult.session?.userId || 'anonymous';
|
|
const userEmail = authResult.session?.email || 'anon@anon.anon';
|
|
|
|
// Rate limiting
|
|
if (!checkRateLimit(userId)) {
|
|
return apiError.rateLimit('Rate limit exceeded. Please wait before submitting another contribution.');
|
|
}
|
|
|
|
// Parse and sanitize request body
|
|
let body;
|
|
try {
|
|
const rawBody = await request.text();
|
|
if (!rawBody.trim()) {
|
|
return apiSpecial.emptyBody();
|
|
}
|
|
body = JSON.parse(rawBody);
|
|
} catch (error) {
|
|
return apiSpecial.invalidJSON();
|
|
}
|
|
|
|
// Sanitize input
|
|
const sanitizedBody = sanitizeInput(body);
|
|
|
|
// Validate request structure
|
|
let validatedData;
|
|
try {
|
|
validatedData = ContributionRequestSchema.parse(sanitizedBody);
|
|
} 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');
|
|
}
|
|
|
|
// Additional tool-specific validation
|
|
const toolValidation = await validateToolData(validatedData.tool, validatedData.action);
|
|
if (!toolValidation.valid) {
|
|
return apiError.validation('Tool validation failed', toolValidation.errors);
|
|
}
|
|
|
|
// Prepare contribution data
|
|
const contributionData: ContributionData = {
|
|
type: validatedData.action,
|
|
tool: validatedData.tool,
|
|
metadata: {
|
|
submitter: userEmail,
|
|
reason: validatedData.metadata?.reason
|
|
}
|
|
};
|
|
|
|
// CRITICAL FIX: Enhanced error handling for Git operations
|
|
try {
|
|
// Submit contribution via Git (now creates issue instead of PR)
|
|
const gitManager = new GitContributionManager();
|
|
const result = await gitManager.submitContribution(contributionData);
|
|
|
|
if (result.success) {
|
|
console.log(`[CONTRIBUTION] Issue created for "${validatedData.tool.name}" by ${userEmail} - Issue: ${result.issueUrl}`);
|
|
|
|
return apiResponse.created({
|
|
success: true,
|
|
message: result.message,
|
|
issueUrl: result.issueUrl,
|
|
issueNumber: result.issueNumber
|
|
});
|
|
} else {
|
|
console.error(`[CONTRIBUTION FAILED] "${validatedData.tool.name}" by ${userEmail}: ${result.message}`);
|
|
|
|
return apiServerError.internal(`Contribution failed: ${result.message}`);
|
|
}
|
|
} catch (gitError) {
|
|
// CRITICAL: Handle Git operation errors properly
|
|
console.error(`[GIT ERROR] ${validatedData.action} "${validatedData.tool.name}" by ${userEmail}:`, gitError);
|
|
|
|
// Return proper error response
|
|
const errorMessage = gitError instanceof Error ? gitError.message : 'Git operation failed';
|
|
return apiServerError.internal(`Git operation failed: ${errorMessage}`);
|
|
}
|
|
|
|
}, 'Contribution processing failed');
|
|
}; |