2025-07-24 13:46:50 +02:00

385 lines
12 KiB
TypeScript

// src/pages/api/contribute/knowledgebase.ts
import type { APIRoute } from 'astro';
import { withAPIAuth, createAuthErrorResponse } 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<string, { count: number; resetTime: number }>();
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 authResult = await withAPIAuth(request);
if (authResult.authRequired && !authResult.authenticated) {
return createAuthErrorResponse('Authentication required');
}
const userEmail = authResult.session?.email || 'anonymous@example.com';
// 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' }
});
}
} 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' }
});
}
};