// src/pages/api/contribute/knowledgebase.ts import type { APIRoute } from 'astro'; import { getSessionFromRequest, verifySession } from '../../../utils/auth.js'; import { GitContributionManager } from '../../../utils/gitContributions.js'; import { z } from 'zod'; export const prerender = false; // Simplified schema for document-based contributions const KnowledgebaseContributionSchema = z.object({ toolName: z.string().min(1), title: z.string().min(1), description: z.string().min(1), content: z.string().default(''), externalLink: z.string().url().optional().or(z.literal('')), difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert']), categories: z.string().transform(str => { try { return JSON.parse(str); } catch { return []; } }).pipe(z.array(z.string()).default([])), tags: z.string().transform(str => { try { return JSON.parse(str); } catch { return []; } }).pipe(z.array(z.string()).default([])), uploadedFiles: z.string().transform(str => { try { return JSON.parse(str); } catch { return []; } }).pipe(z.array(z.any()).default([])) }); interface KnowledgebaseContributionData { type: 'add'; article: { toolName: string; title: string; description: string; content: string; externalLink?: string; difficulty: string; categories: string[]; tags: string[]; uploadedFiles: any[]; }; metadata: { submitter: string; reason?: string; }; } // Rate limiting const rateLimitStore = new Map(); const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour const RATE_LIMIT_MAX = 5; // Max 5 submissions per hour per user function checkRateLimit(userEmail: string): boolean { const now = Date.now(); const userLimit = rateLimitStore.get(userEmail); if (!userLimit || now > userLimit.resetTime) { rateLimitStore.set(userEmail, { count: 1, resetTime: now + RATE_LIMIT_WINDOW }); return true; } if (userLimit.count >= RATE_LIMIT_MAX) { return false; } userLimit.count++; return true; } function validateKnowledgebaseData(data: any): { valid: boolean; errors?: string[] } { // Only check that they provided SOMETHING const hasContent = data.content?.trim().length > 0; const hasLink = data.externalLink?.trim().length > 0; const hasFiles = data.uploadedFiles?.length > 0; if (!hasContent && !hasLink && !hasFiles) { return { valid: false, errors: ['Must provide content, link, or files'] }; } return { valid: true }; // That's it - maximum freedom } function generateArticleSlug(title: string, toolName: string): string { const baseSlug = title.toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); const toolSlug = toolName.toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); return `${toolSlug}-${baseSlug}`; } function generateMarkdownContent(article: any): string { const now = new Date(); // Generate frontmatter const frontmatter = { title: article.title, tool_name: article.toolName, description: article.description, last_updated: now.toISOString().split('T')[0], author: 'CC24-Team', difficulty: article.difficulty, categories: article.categories, tags: article.tags, review_status: 'pending_review' }; const frontmatterYaml = Object.entries(frontmatter) .map(([key, value]) => { if (Array.isArray(value)) { return `${key}: [${value.map(v => `"${v}"`).join(', ')}]`; } else { return `${key}: "${value}"`; } }) .join('\n'); // Generate content sections let content = `---\n${frontmatterYaml}\n---\n\n`; content += `# ${article.title}\n\n`; content += `${article.description}\n\n`; // Add user content if provided if (article.content && article.content.trim().length > 0) { content += `## Content\n\n${article.content}\n\n`; } // Add external link if provided if (article.externalLink && article.externalLink.trim().length > 0) { content += `## External Resources\n\n`; content += `- [External Documentation](${article.externalLink})\n\n`; } // Add uploaded files section if (article.uploadedFiles && article.uploadedFiles.length > 0) { content += `## Uploaded Files\n\n`; article.uploadedFiles.forEach((file: any) => { const fileType = file.name.toLowerCase(); let icon = '📎'; if (fileType.includes('.pdf')) icon = '📄'; else if (fileType.match(/\.(png|jpg|jpeg|gif|webp)$/)) icon = '🖼️'; else if (fileType.match(/\.(mp4|webm|mov|avi)$/)) icon = '🎥'; else if (fileType.match(/\.(doc|docx)$/)) icon = '📝'; else if (fileType.match(/\.(zip|tar|gz)$/)) icon = '📦'; content += `- ${icon} [${file.name}](${file.url})\n`; }); content += '\n'; } content += `---\n\n`; content += `*This article was contributed via the CC24-Hub knowledge base submission system.*\n`; return content; } // Extended GitContributionManager for knowledgebase class KnowledgebaseGitManager extends GitContributionManager { async submitKnowledgebaseContribution(data: KnowledgebaseContributionData): Promise<{success: boolean, message: string, prUrl?: string, branchName?: string}> { const branchName = `kb-add-${Date.now()}`; try { // Create branch await this.createBranch(branchName); // Generate file content const slug = generateArticleSlug(data.article.title, data.article.toolName); const markdownContent = generateMarkdownContent(data.article); // Write article file const articlePath = `src/content/knowledgebase/${slug}.md`; await this.writeFile(articlePath, markdownContent); // Commit changes const commitMessage = `Add knowledgebase contribution: ${data.article.title} Contributed by: ${data.metadata.submitter} Tool: ${data.article.toolName} Type: Document-based contribution Difficulty: ${data.article.difficulty} Categories: ${data.article.categories.join(', ')} Files: ${data.article.uploadedFiles.length} uploaded ${data.article.externalLink ? `External Link: ${data.article.externalLink}` : ''} ${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''}`; await this.commitChanges(commitMessage); // Push branch await this.pushBranch(branchName); // Create pull request const prUrl = await this.createPullRequest( branchName, `Add KB Article: ${data.article.title}`, this.generateKnowledgebasePRDescription(data) ); return { success: true, message: 'Knowledge base article submitted successfully', prUrl, branchName }; } catch (error) { // Cleanup on failure try { await this.deleteBranch(branchName); } catch (cleanupError) { console.error('Failed to cleanup branch:', cleanupError); } throw error; } } private generateKnowledgebasePRDescription(data: KnowledgebaseContributionData): string { return `## Knowledge Base Article: ${data.article.title} **Tool:** ${data.article.toolName} **Submitted by:** ${data.metadata.submitter} **Difficulty:** ${data.article.difficulty} ### Article Details - **Description:** ${data.article.description} - **Categories:** ${data.article.categories.length > 0 ? data.article.categories.join(', ') : 'None'} - **Tags:** ${data.article.tags.length > 0 ? data.article.tags.join(', ') : 'None'} - **Content:** ${data.article.content && data.article.content.trim().length > 0 ? `~${data.article.content.split(/\s+/).length} words` : 'Document/link-based'} - **Uploaded Files:** ${data.article.uploadedFiles.length} files ${data.article.externalLink ? `- **External Link:** ${data.article.externalLink}` : ''} ${data.metadata.reason ? `### Reason for Contribution ${data.metadata.reason} ` : ''}### Content Overview ${data.article.content && data.article.content.trim().length > 0 ? ` **Provided Content:** ${data.article.content.substring(0, 200)}${data.article.content.length > 200 ? '...' : ''} ` : ''} ${data.article.uploadedFiles.length > 0 ? ` **Uploaded Files:** ${data.article.uploadedFiles.map((file: any) => `- ${file.name} (${file.url})`).join('\n')} ` : ''} ### Review Checklist - [ ] Article content is accurate and helpful - [ ] All uploaded files are accessible and appropriate - [ ] External links are valid and safe - [ ] Categories and tags are relevant - [ ] Difficulty level is appropriate - [ ] Content is well-organized and clear - [ ] No sensitive or inappropriate content - [ ] Proper attribution for external sources ### Files Changed - \`src/content/knowledgebase/${generateArticleSlug(data.article.title, data.article.toolName)}.md\` (new article) --- *This contribution was submitted via the CC24-Hub document-based knowledge base system.*`; } } export const POST: APIRoute = async ({ request }) => { try { // Check authentication 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 userEmail = session.email; // 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 const formData = await request.formData(); 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 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 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' } }); } // Prepare contribution data const contributionData: KnowledgebaseContributionData = { type: 'add', article: validatedData, metadata: { submitter: userEmail, reason: rawData.reason as string || undefined } }; // 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' } }); } } else { return new Response(JSON.stringify({ error: 'Authentication is disabled' }), { status: 501, 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' } }); } };