finalize knowledgebase commit
This commit is contained in:
@@ -1,20 +1,20 @@
|
||||
// src/pages/api/contribute/knowledgebase.ts (UPDATED - Using consolidated API responses)
|
||||
// src/pages/api/contribute/knowledgebase.ts - SIMPLIFIED: Issues only, minimal validation
|
||||
import type { APIRoute } from 'astro';
|
||||
import { withAPIAuth } from '../../../utils/auth.js';
|
||||
import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.js';
|
||||
import { apiResponse, apiError, apiServerError, handleAPIRequest } from '../../../utils/api.js';
|
||||
import { GitContributionManager } from '../../../utils/gitContributions.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// Simplified schema for document-based contributions
|
||||
// Simple schema - all fields optional except for having some content
|
||||
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']),
|
||||
toolName: z.string().optional().nullable().transform(val => val || undefined),
|
||||
title: z.string().optional().nullable().transform(val => val || undefined),
|
||||
description: z.string().optional().nullable().transform(val => val || undefined),
|
||||
content: z.string().optional().nullable().transform(val => val || undefined),
|
||||
externalLink: z.string().url().optional().nullable().catch(undefined),
|
||||
difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert']).optional().nullable().catch(undefined),
|
||||
categories: z.string().transform(str => {
|
||||
try { return JSON.parse(str); } catch { return []; }
|
||||
}).pipe(z.array(z.string()).default([])),
|
||||
@@ -23,32 +23,27 @@ const KnowledgebaseContributionSchema = z.object({
|
||||
}).pipe(z.array(z.string()).default([])),
|
||||
uploadedFiles: z.string().transform(str => {
|
||||
try { return JSON.parse(str); } catch { return []; }
|
||||
}).pipe(z.array(z.any()).default([]))
|
||||
}).pipe(z.array(z.any()).default([])),
|
||||
reason: z.string().optional().nullable().transform(val => val || undefined)
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
toolName?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
content?: string;
|
||||
externalLink?: string;
|
||||
difficulty?: string;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
uploadedFiles: any[];
|
||||
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
|
||||
const RATE_LIMIT_MAX = 3; // Max 3 submissions per hour per user
|
||||
|
||||
function checkRateLimit(userEmail: string): boolean {
|
||||
const now = Date.now();
|
||||
@@ -67,205 +62,23 @@ function checkRateLimit(userEmail: string): boolean {
|
||||
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;
|
||||
function validateKnowledgebaseData(data: KnowledgebaseContributionData): { valid: boolean; errors?: string[] } {
|
||||
// Very minimal validation - just check that SOMETHING was provided
|
||||
// Use nullish coalescing to avoid “possibly undefined” errors in strict mode
|
||||
const hasContent = (data.content ?? '').trim().length > 0;
|
||||
const hasLink = (data.externalLink ?? '').trim().length > 0;
|
||||
const hasFiles = Array.isArray(data.uploadedFiles) && data.uploadedFiles.length > 0;
|
||||
const hasTitle = (data.title ?? '').trim().length > 0;
|
||||
const hasDescription = (data.description ?? '').trim().length > 0;
|
||||
|
||||
if (!hasContent && !hasLink && !hasFiles) {
|
||||
return { valid: false, errors: ['Must provide content, link, or files'] };
|
||||
if (!hasContent && !hasLink && !hasFiles && !hasTitle && !hasDescription) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: ['Please provide at least a title, description, content, external link, or upload 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.*`;
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
@@ -288,12 +101,12 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
formData = await request.formData();
|
||||
} catch (error) {
|
||||
return apiSpecial.invalidJSON();
|
||||
return apiError.badRequest('Invalid form data');
|
||||
}
|
||||
|
||||
const rawData = Object.fromEntries(formData);
|
||||
|
||||
// Validate request data
|
||||
// Validate and sanitize data
|
||||
let validatedData;
|
||||
try {
|
||||
validatedData = KnowledgebaseContributionSchema.parse(rawData);
|
||||
@@ -308,39 +121,40 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
return apiError.badRequest('Invalid request data');
|
||||
}
|
||||
|
||||
// Additional validation
|
||||
const kbValidation = validateKnowledgebaseData(validatedData);
|
||||
if (!kbValidation.valid) {
|
||||
return apiError.validation('Content validation failed', kbValidation.errors);
|
||||
// Basic content validation
|
||||
const contentValidation = validateKnowledgebaseData(validatedData);
|
||||
if (!contentValidation.valid) {
|
||||
return apiError.validation('Content validation failed', contentValidation.errors);
|
||||
}
|
||||
|
||||
// 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 apiResponse.created({
|
||||
message: result.message,
|
||||
prUrl: result.prUrl,
|
||||
branchName: result.branchName
|
||||
// Submit as issue via Git
|
||||
try {
|
||||
const gitManager = new GitContributionManager();
|
||||
const result = await gitManager.submitKnowledgebaseContribution({
|
||||
...validatedData,
|
||||
submitter: userEmail
|
||||
});
|
||||
} else {
|
||||
console.error(`[KB CONTRIBUTION FAILED] "${validatedData.title}" by ${userEmail}: ${result.message}`);
|
||||
|
||||
if (result.success) {
|
||||
console.log(`[KB CONTRIBUTION] "${validatedData.title || 'Article'}" by ${userEmail} - Issue: ${result.issueUrl}`);
|
||||
|
||||
return apiResponse.created({
|
||||
success: true,
|
||||
message: result.message,
|
||||
issueUrl: result.issueUrl,
|
||||
issueNumber: result.issueNumber
|
||||
});
|
||||
} else {
|
||||
console.error(`[KB CONTRIBUTION FAILED] "${validatedData.title || 'Article'}" by ${userEmail}: ${result.message}`);
|
||||
|
||||
return apiServerError.internal(`Contribution failed: ${result.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[KB GIT ERROR] "${validatedData.title || 'Article'}" by ${userEmail}:`, error);
|
||||
|
||||
return apiServerError.internal(`Contribution failed: ${result.message}`);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Git operation failed';
|
||||
return apiServerError.internal(`Submission failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
}, 'Knowledgebase contribution processing failed');
|
||||
};
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ const ContributionRequestSchema = z.object({
|
||||
// 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; // 5 contributions per hour per user
|
||||
const RATE_LIMIT_MAX = 15; // 15 contributions per hour per user
|
||||
|
||||
function checkRateLimit(userId: string): boolean {
|
||||
const now = Date.now();
|
||||
|
||||
Reference in New Issue
Block a user