first draft contributions

This commit is contained in:
overcuriousity
2025-07-22 21:56:01 +02:00
parent 9798837806
commit 043a2d32ac
4 changed files with 1742 additions and 0 deletions

View 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' }
});
}
};