first draft contributions
This commit is contained in:
393
src/pages/api/contribute/tool.ts
Normal file
393
src/pages/api/contribute/tool.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
// src/pages/api/contribute/tool.ts
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getSessionFromRequest, verifySession } from '../../../utils/auth.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().max(500, 'Reason too long').optional()
|
||||
}).optional().default({})
|
||||
});
|
||||
|
||||
// Rate limiting storage
|
||||
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
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return input.map(sanitizeInput).filter(Boolean).slice(0, 50); // Limit array size
|
||||
}
|
||||
|
||||
if (typeof input === 'object' && input !== null) {
|
||||
const sanitized: any = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (key.length <= 100) { // Limit key length
|
||||
sanitized[key] = sanitizeInput(value);
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
// Validate tool data against existing tools (for duplicates and consistency)
|
||||
async function validateToolData(tool: any, action: 'add' | 'edit'): 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();
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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') {
|
||||
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');
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
);
|
||||
const invalidConcepts = tool.related_concepts.filter((c: string) => !existingConcepts.has(c));
|
||||
if (invalidConcepts.length > 0) {
|
||||
errors.push(`Referenced concepts not found: ${invalidConcepts.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
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 }) => {
|
||||
try {
|
||||
// Check if authentication is required
|
||||
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
|
||||
let userId = 'anonymous';
|
||||
let userEmail = 'anonymous@example.com';
|
||||
|
||||
if (authRequired) {
|
||||
// Authentication check
|
||||
const sessionToken = getSessionFromRequest(request);
|
||||
if (!sessionToken) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Authentication required'
|
||||
}), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const session = await verifySession(sessionToken);
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Invalid session'
|
||||
}), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
userId = session.userId;
|
||||
// In a real implementation, you might want to fetch user email from session or OIDC
|
||||
userEmail = `${userId}@cc24.dev`;
|
||||
}
|
||||
|
||||
// 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' }
|
||||
});
|
||||
}
|
||||
|
||||
// Parse and sanitize request body
|
||||
let body;
|
||||
try {
|
||||
const rawBody = await request.text();
|
||||
if (!rawBody.trim()) {
|
||||
throw new Error('Empty request body');
|
||||
}
|
||||
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' }
|
||||
});
|
||||
}
|
||||
|
||||
// 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 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' }
|
||||
});
|
||||
}
|
||||
|
||||
// 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' }
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare contribution data
|
||||
const contributionData: ContributionData = {
|
||||
type: validatedData.action,
|
||||
tool: validatedData.tool,
|
||||
metadata: {
|
||||
submitter: userEmail,
|
||||
reason: validatedData.metadata.reason
|
||||
}
|
||||
};
|
||||
|
||||
// Submit contribution via Git
|
||||
const gitManager = new GitContributionManager();
|
||||
const result = await gitManager.submitContribution(contributionData);
|
||||
|
||||
if (result.success) {
|
||||
// Log successful contribution
|
||||
console.log(`[CONTRIBUTION] ${validatedData.action} "${validatedData.tool.name}" 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 {
|
||||
// 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' }
|
||||
});
|
||||
}
|
||||
|
||||
} 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' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Health check endpoint
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
// Simple authentication check for health endpoint
|
||||
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
|
||||
|
||||
if (authRequired) {
|
||||
const sessionToken = getSessionFromRequest(request);
|
||||
if (!sessionToken) {
|
||||
return new Response(JSON.stringify({ error: 'Authentication required' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const session = await verifySession(sessionToken);
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid session' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const gitManager = new GitContributionManager();
|
||||
const health = await gitManager.checkHealth();
|
||||
|
||||
return new Response(JSON.stringify(health), {
|
||||
status: health.healthy ? 200 : 503,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Health check error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
healthy: false,
|
||||
issues: ['Health check failed']
|
||||
}), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user