diff --git a/package.json b/package.json index 45468ef..5ba57d5 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,7 @@ "start": "astro dev", "build": "astro build", "preview": "astro preview", - "astro": "astro", - "check:health": "curl -f http://localhost:4321/health || exit 1" + "astro": "astro" }, "dependencies": { "@astrojs/node": "^9.3.0", diff --git a/src/pages/api/contribute/health.ts b/src/pages/api/contribute/health.ts deleted file mode 100644 index 2e1694b..0000000 --- a/src/pages/api/contribute/health.ts +++ /dev/null @@ -1,478 +0,0 @@ -// src/pages/api/contribute/health.ts -import type { APIRoute } from 'astro'; -import { getSessionFromRequest, verifySession } from '../../../utils/auth.js'; -import { GitContributionManager } from '../../../utils/gitContributions.js'; -import { promises as fs } from 'fs'; -import { execSync } from 'child_process'; -import path from 'path'; - -export const prerender = false; - -interface HealthCheck { - component: string; - status: 'healthy' | 'warning' | 'error'; - message: string; - details?: any; - lastChecked: string; -} - -interface SystemHealth { - overall: 'healthy' | 'warning' | 'error'; - checks: HealthCheck[]; - summary: { - healthy: number; - warnings: number; - errors: number; - }; - timestamp: string; - uptime?: string; -} - -class HealthMonitor { - private checks: HealthCheck[] = []; - - async runAllChecks(): Promise { - this.checks = []; - - // Run all health checks - await Promise.allSettled([ - this.checkGitRepository(), - this.checkGitConnectivity(), - this.checkDiskSpace(), - this.checkMemoryUsage(), - this.checkDataFiles(), - this.checkAuthSystem(), - this.checkEnvironmentVariables(), - this.checkFilePermissions() - ]); - - // Calculate overall status - const errors = this.checks.filter(c => c.status === 'error').length; - const warnings = this.checks.filter(c => c.status === 'warning').length; - const healthy = this.checks.filter(c => c.status === 'healthy').length; - - let overall: 'healthy' | 'warning' | 'error' = 'healthy'; - if (errors > 0) { - overall = 'error'; - } else if (warnings > 0) { - overall = 'warning'; - } - - return { - overall, - checks: this.checks, - summary: { healthy, warnings: warnings, errors }, - timestamp: new Date().toISOString(), - uptime: this.getUptime() - }; - } - - private addCheck(component: string, status: 'healthy' | 'warning' | 'error', message: string, details?: any) { - this.checks.push({ - component, - status, - message, - details, - lastChecked: new Date().toISOString() - }); - } - - private async checkGitRepository(): Promise { - try { - const localRepoPath = process.env.LOCAL_REPO_PATH || '/var/git/cc24-hub'; - - // Check if repo exists - try { - await fs.access(localRepoPath); - } catch { - this.addCheck('Git Repository', 'error', 'Local git repository not found', { path: localRepoPath }); - return; - } - - // Check if it's a git repository - try { - execSync('git status', { cwd: localRepoPath, stdio: 'pipe' }); - } catch { - this.addCheck('Git Repository', 'error', 'Directory is not a git repository', { path: localRepoPath }); - return; - } - - // Check repository health - try { - const gitStatus = execSync('git status --porcelain', { cwd: localRepoPath, encoding: 'utf8' }); - const uncommittedChanges = gitStatus.trim().split('\n').filter(line => line.trim()).length; - - const branchInfo = execSync('git branch --show-current', { cwd: localRepoPath, encoding: 'utf8' }).trim(); - const lastCommit = execSync('git log -1 --format="%h %s (%ar)"', { cwd: localRepoPath, encoding: 'utf8' }).trim(); - - if (uncommittedChanges > 0) { - this.addCheck('Git Repository', 'warning', `Repository has ${uncommittedChanges} uncommitted changes`, { - branch: branchInfo, - lastCommit, - uncommittedChanges - }); - } else { - this.addCheck('Git Repository', 'healthy', 'Repository is clean and up to date', { - branch: branchInfo, - lastCommit - }); - } - - } catch (error) { - this.addCheck('Git Repository', 'warning', 'Could not check repository status', { error: error.message }); - } - - } catch (error) { - this.addCheck('Git Repository', 'error', 'Failed to check git repository', { error: error.message }); - } - } - - private async checkGitConnectivity(): Promise { - try { - const gitManager = new GitContributionManager(); - const health = await gitManager.checkHealth(); - - if (health.healthy) { - this.addCheck('Git Connectivity', 'healthy', 'Git API connectivity working'); - } else { - this.addCheck('Git Connectivity', 'error', 'Git API connectivity issues', { issues: health.issues }); - } - - } catch (error) { - this.addCheck('Git Connectivity', 'error', 'Failed to check git connectivity', { error: error.message }); - } - } - - private async checkDiskSpace(): Promise { - try { - // Get disk usage for the current working directory - const stats = await fs.statfs(process.cwd()); - const totalSpace = stats.bavail * stats.bsize; // Available space in bytes - const totalBlocks = stats.blocks * stats.bsize; // Total space in bytes - const usedSpace = totalBlocks - totalSpace; - const usagePercent = Math.round((usedSpace / totalBlocks) * 100); - - const freeSpaceGB = Math.round(totalSpace / (1024 * 1024 * 1024) * 100) / 100; - const totalSpaceGB = Math.round(totalBlocks / (1024 * 1024 * 1024) * 100) / 100; - - const details = { - freeSpace: `${freeSpaceGB} GB`, - totalSpace: `${totalSpaceGB} GB`, - usagePercent: `${usagePercent}%` - }; - - if (usagePercent > 90) { - this.addCheck('Disk Space', 'error', `Disk usage critical: ${usagePercent}%`, details); - } else if (usagePercent > 80) { - this.addCheck('Disk Space', 'warning', `Disk usage high: ${usagePercent}%`, details); - } else { - this.addCheck('Disk Space', 'healthy', `Disk usage normal: ${usagePercent}%`, details); - } - - } catch (error) { - this.addCheck('Disk Space', 'warning', 'Could not check disk space', { error: error.message }); - } - } - - private async checkMemoryUsage(): Promise { - try { - const memInfo = process.memoryUsage(); - const totalMemMB = Math.round(memInfo.heapTotal / 1024 / 1024 * 100) / 100; - const usedMemMB = Math.round(memInfo.heapUsed / 1024 / 1024 * 100) / 100; - const externalMemMB = Math.round(memInfo.external / 1024 / 1024 * 100) / 100; - - const details = { - heapUsed: `${usedMemMB} MB`, - heapTotal: `${totalMemMB} MB`, - external: `${externalMemMB} MB`, - rss: `${Math.round(memInfo.rss / 1024 / 1024 * 100) / 100} MB` - }; - - if (usedMemMB > 500) { - this.addCheck('Memory Usage', 'warning', `High memory usage: ${usedMemMB} MB`, details); - } else { - this.addCheck('Memory Usage', 'healthy', `Memory usage normal: ${usedMemMB} MB`, details); - } - - } catch (error) { - this.addCheck('Memory Usage', 'warning', 'Could not check memory usage', { error: error.message }); - } - } - - private async checkDataFiles(): Promise { - try { - const dataFiles = [ - 'src/data/tools.yaml', - 'src/content/knowledgebase/' - ]; - - const fileStatuses: Array<{ - path: string; - type?: 'file' | 'directory'; - fileCount?: number; - size?: string; - lastModified?: string; - error?: string; - }> = []; - - for (const filePath of dataFiles) { - try { - const stats = await fs.stat(filePath); - const isDirectory = stats.isDirectory(); - - if (isDirectory) { - // Count files in directory - const files = await fs.readdir(filePath); - const mdFiles = files.filter(f => f.endsWith('.md')).length; - fileStatuses.push({ - path: filePath, - type: 'directory', - fileCount: mdFiles, - lastModified: stats.mtime.toISOString() - }); - } else { - // Check file size and modification time - const fileSizeKB = Math.round(stats.size / 1024 * 100) / 100; - fileStatuses.push({ - path: filePath, - type: 'file', - size: `${fileSizeKB} KB`, - lastModified: stats.mtime.toISOString() - }); - } - } catch (error: any) { - fileStatuses.push({ - path: filePath, - error: error?.message || 'Unknown error' - }); - } - } - - const errors = fileStatuses.filter(f => f.error); - - if (errors.length > 0) { - this.addCheck('Data Files', 'error', `${errors.length} data files inaccessible`, { files: fileStatuses }); - } else { - this.addCheck('Data Files', 'healthy', 'All data files accessible', { files: fileStatuses }); - } - - } catch (error) { - this.addCheck('Data Files', 'error', 'Failed to check data files', { error: error.message }); - } - } - - private async checkAuthSystem(): Promise { - try { - const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false'; - - if (!authRequired) { - this.addCheck('Authentication', 'healthy', 'Authentication disabled', { mode: 'disabled' }); - return; - } - - const requiredEnvVars = [ - 'OIDC_ENDPOINT', - 'OIDC_CLIENT_ID', - 'OIDC_CLIENT_SECRET', - 'PUBLIC_BASE_URL' - ]; - - const missingVars = requiredEnvVars.filter(varName => !process.env[varName]); - - if (missingVars.length > 0) { - this.addCheck('Authentication', 'error', 'Missing OIDC configuration', { - missing: missingVars, - mode: 'enabled' - }); - return; - } - - // Test OIDC endpoint connectivity - try { - const oidcEndpoint = process.env.OIDC_ENDPOINT; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); - - const response = await fetch(`${oidcEndpoint}/.well-known/openid_configuration`, { - method: 'GET', - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (response.ok) { - this.addCheck('Authentication', 'healthy', 'OIDC provider accessible', { - endpoint: oidcEndpoint, - mode: 'enabled' - }); - } else { - this.addCheck('Authentication', 'warning', 'OIDC provider returned error', { - endpoint: oidcEndpoint, - status: response.status, - mode: 'enabled' - }); - } - } catch (error) { - this.addCheck('Authentication', 'error', 'Cannot reach OIDC provider', { - endpoint: process.env.OIDC_ENDPOINT, - error: error.message, - mode: 'enabled' - }); - } - - } catch (error) { - this.addCheck('Authentication', 'error', 'Failed to check auth system', { error: error.message }); - } - } - - private async checkEnvironmentVariables(): Promise { - try { - const requiredVars = [ - 'GIT_REPO_URL', - 'GIT_API_ENDPOINT', - 'GIT_API_TOKEN', - 'LOCAL_REPO_PATH' - ]; - - const optionalVars = [ - 'GIT_PROVIDER', - 'AUTHENTICATION_NECESSARY', - 'NODE_ENV' - ]; - - const missingRequired = requiredVars.filter(varName => !process.env[varName]); - const missingOptional = optionalVars.filter(varName => !process.env[varName]); - - const details = { - required: { - total: requiredVars.length, - missing: missingRequired.length, - missingVars: missingRequired - }, - optional: { - total: optionalVars.length, - missing: missingOptional.length, - missingVars: missingOptional - } - }; - - if (missingRequired.length > 0) { - this.addCheck('Environment Variables', 'error', `${missingRequired.length} required environment variables missing`, details); - } else if (missingOptional.length > 0) { - this.addCheck('Environment Variables', 'warning', `${missingOptional.length} optional environment variables missing`, details); - } else { - this.addCheck('Environment Variables', 'healthy', 'All environment variables configured', details); - } - - } catch (error) { - this.addCheck('Environment Variables', 'error', 'Failed to check environment variables', { error: error.message }); - } - } - - private async checkFilePermissions(): Promise { - try { - const localRepoPath = process.env.LOCAL_REPO_PATH || '/var/git/cc24-hub'; - - try { - // Test read permission - await fs.access(localRepoPath, fs.constants.R_OK); - - // Test write permission - await fs.access(localRepoPath, fs.constants.W_OK); - - this.addCheck('File Permissions', 'healthy', 'Repository has proper read/write permissions', { path: localRepoPath }); - - } catch (error) { - if (error.code === 'EACCES') { - this.addCheck('File Permissions', 'error', 'Insufficient permissions for repository', { - path: localRepoPath, - error: error.message - }); - } else { - this.addCheck('File Permissions', 'error', 'Repository path inaccessible', { - path: localRepoPath, - error: error.message - }); - } - } - - } catch (error) { - this.addCheck('File Permissions', 'warning', 'Could not check file permissions', { error: error.message }); - } - } - - private getUptime(): string { - const uptimeSeconds = process.uptime(); - const hours = Math.floor(uptimeSeconds / 3600); - const minutes = Math.floor((uptimeSeconds % 3600) / 60); - const seconds = Math.floor(uptimeSeconds % 60); - - return `${hours}h ${minutes}m ${seconds}s`; - } -} - -export const GET: APIRoute = async ({ request }) => { - try { - // Check authentication 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' } - }); - } - } - - // Run health checks - const monitor = new HealthMonitor(); - const health = await monitor.runAllChecks(); - - // Determine HTTP status code based on overall health - let statusCode = 200; - if (health.overall === 'warning') { - statusCode = 200; // Still OK, but with warnings - } else if (health.overall === 'error') { - statusCode = 503; // Service Unavailable - } - - return new Response(JSON.stringify(health), { - status: statusCode, - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-cache, no-store, must-revalidate' - } - }); - - } catch (error) { - console.error('Health check error:', error); - - const errorResponse: SystemHealth = { - overall: 'error', - checks: [{ - component: 'Health Monitor', - status: 'error', - message: 'Health check system failure', - details: { error: error.message }, - lastChecked: new Date().toISOString() - }], - summary: { healthy: 0, warnings: 0, errors: 1 }, - timestamp: new Date().toISOString() - }; - - return new Response(JSON.stringify(errorResponse), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); - } -}; \ No newline at end of file diff --git a/src/pages/api/contribute/knowledgebase.ts b/src/pages/api/contribute/knowledgebase.ts index 21e63b0..ca8a586 100644 --- a/src/pages/api/contribute/knowledgebase.ts +++ b/src/pages/api/contribute/knowledgebase.ts @@ -3,61 +3,39 @@ import type { APIRoute } from 'astro'; import { getSessionFromRequest, verifySession } from '../../../utils/auth.js'; import { GitContributionManager } from '../../../utils/gitContributions.js'; import { z } from 'zod'; -import { promises as fs } from 'fs'; -import path from 'path'; export const prerender = false; -// Enhanced knowledgebase schema for contributions +// Simplified schema for document-based contributions const KnowledgebaseContributionSchema = z.object({ - toolName: z.string().min(1, 'Tool name is required'), - title: z.string().min(5, 'Title must be at least 5 characters').max(100, 'Title too long'), - description: z.string().min(20, 'Description must be at least 20 characters').max(300, 'Description too long'), - content: z.string().min(50, 'Content must be at least 50 characters'), - difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert'], { - errorMap: () => ({ message: 'Invalid difficulty level' }) - }), + 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 []; - } + 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 []; - } + try { return JSON.parse(str); } catch { return []; } }).pipe(z.array(z.string()).default([])), - sections: z.string().transform(str => { - try { - return JSON.parse(str); - } catch { - return {}; - } - }).pipe(z.record(z.boolean()).default({})), uploadedFiles: z.string().transform(str => { - try { - return JSON.parse(str); - } catch { - return []; - } + try { return JSON.parse(str); } catch { return []; } }).pipe(z.array(z.any()).default([])) }); interface KnowledgebaseContributionData { - type: 'add' | 'edit'; + type: 'add'; article: { toolName: string; title: string; description: string; content: string; + externalLink?: string; difficulty: string; categories: string[]; tags: string[]; - sections: Record; uploadedFiles: any[]; }; metadata: { @@ -66,10 +44,10 @@ interface KnowledgebaseContributionData { }; } -// Rate limiting (same pattern as tool contributions) +// Rate limiting const rateLimitStore = new Map(); const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour -const RATE_LIMIT_MAX = 10; // Max 10 submissions per hour +const RATE_LIMIT_MAX = 5; // Max 5 submissions per hour per user function checkRateLimit(userEmail: string): boolean { const now = Date.now(); @@ -88,68 +66,17 @@ function checkRateLimit(userEmail: string): boolean { return true; } -async function validateKnowledgebaseData(article: any): Promise<{ valid: boolean; errors: string[] }> { - const errors: string[] = []; +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; - // Check if tool exists in the database - try { - const { getToolsData } = await import('../../../utils/dataService.js'); - const data = await getToolsData(); - const toolExists = data.tools.some((tool: any) => tool.name === article.toolName); - - if (!toolExists) { - errors.push(`Tool "${article.toolName}" not found in database`); - } - } catch (error) { - errors.push('Failed to validate tool existence'); + if (!hasContent && !hasLink && !hasFiles) { + return { valid: false, errors: ['Must provide content, link, or files'] }; } - // Validate content quality - if (article.content.trim().split(/\s+/).length < 50) { - errors.push('Article content should be at least 50 words'); - } - - // Check for required sections based on difficulty - const requiredSections = { - 'novice': ['overview'], - 'beginner': ['overview'], - 'intermediate': ['overview', 'usage_examples'], - 'advanced': ['overview', 'usage_examples'], - 'expert': ['overview', 'usage_examples', 'advanced_topics'] - }; - - const required = requiredSections[article.difficulty as keyof typeof requiredSections] || []; - const missingSections = required.filter(section => !article.sections[section]); - - if (missingSections.length > 0) { - errors.push(`Missing required sections for ${article.difficulty} difficulty: ${missingSections.join(', ')}`); - } - - // Validate categories and tags - if (article.categories.length === 0) { - errors.push('At least one category is required'); - } - - const maxCategories = 5; - const maxTags = 10; - - if (article.categories.length > maxCategories) { - errors.push(`Too many categories (max ${maxCategories})`); - } - - if (article.tags.length > maxTags) { - errors.push(`Too many tags (max ${maxTags})`); - } - - // Validate uploaded files - if (article.uploadedFiles.length > 20) { - errors.push('Too many uploaded files (max 20)'); - } - - return { - valid: errors.length === 0, - errors - }; + return { valid: true }; // That's it - maximum freedom } function generateArticleSlug(title: string, toolName: string): string { @@ -168,41 +95,75 @@ function generateArticleSlug(title: string, toolName: string): string { return `${toolSlug}-${baseSlug}`; } -function generateMarkdownFrontmatter(article: any): string { +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], // YYYY-MM-DD format + last_updated: now.toISOString().split('T')[0], author: 'CC24-Team', difficulty: article.difficulty, categories: article.categories, tags: article.tags, - sections: article.sections, - review_status: 'draft' + review_status: 'pending_review' }; - return `---\n${Object.entries(frontmatter) + const frontmatterYaml = Object.entries(frontmatter) .map(([key, value]) => { if (Array.isArray(value)) { return `${key}: [${value.map(v => `"${v}"`).join(', ')}]`; - } else if (typeof value === 'object') { - const obj = Object.entries(value) - .map(([k, v]) => ` ${k}: ${v}`) - .join('\n'); - return `${key}:\n${obj}`; } else { return `${key}: "${value}"`; } }) - .join('\n')}\n---\n\n`; + .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-${data.type}-${Date.now()}`; + const branchName = `kb-add-${Date.now()}`; try { // Create branch @@ -210,23 +171,22 @@ class KnowledgebaseGitManager extends GitContributionManager { // Generate file content const slug = generateArticleSlug(data.article.title, data.article.toolName); - const frontmatter = generateMarkdownFrontmatter(data.article); - const fullContent = frontmatter + data.article.content; + const markdownContent = generateMarkdownContent(data.article); // Write article file const articlePath = `src/content/knowledgebase/${slug}.md`; - await this.writeFile(articlePath, fullContent); - - // Update tools.yaml to add knowledgebase flag - await this.updateToolKnowledgebaseFlag(data.article.toolName); + await this.writeFile(articlePath, markdownContent); // Commit changes - const commitMessage = `${data.type === 'add' ? 'Add' : 'Update'} knowledgebase article: ${data.article.title} + 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}` : ''}`; @@ -238,13 +198,13 @@ ${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''}`; // Create pull request const prUrl = await this.createPullRequest( branchName, - `Knowledgebase: ${data.article.title}`, + `Add KB Article: ${data.article.title}`, this.generateKnowledgebasePRDescription(data) ); return { success: true, - message: `Knowledgebase article contribution submitted successfully`, + message: 'Knowledge base article submitted successfully', prUrl, branchName }; @@ -260,69 +220,50 @@ ${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''}`; throw error; } } - - private async updateToolKnowledgebaseFlag(toolName: string): Promise { - const toolsYamlPath = 'src/data/tools.yaml'; - const { load, dump } = await import('js-yaml'); - - try { - const content = await this.readFile(toolsYamlPath); - const data = load(content) as any; - - // Find and update the tool - const tool = data.tools.find((t: any) => t.name === toolName); - if (tool) { - tool.knowledgebase = true; - - const updatedContent = dump(data, { - lineWidth: -1, - noRefs: true, - quotingType: '"', - forceQuotes: false - }); - - await this.writeFile(toolsYamlPath, updatedContent); - } - } catch (error) { - console.warn('Failed to update tools.yaml knowledgebase flag:', error); - // Don't fail the entire contribution for this - } - } - - private generateKnowledgebasePRDescription(data: KnowledgebaseContributionData): string { - return `## Knowledgebase Article: ${data.article.title} -**Tool:** ${data.article.toolName} -**Type:** ${data.type === 'add' ? 'New Article' : 'Article Update'} + 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} -**Submitted by:** ${data.metadata.submitter} ### Article Details -- **Categories:** ${data.article.categories.join(', ')} -- **Tags:** ${data.article.tags.join(', ')} -- **Sections:** ${Object.entries(data.article.sections).filter(([_, enabled]) => enabled).map(([section, _]) => section).join(', ')} -- **Content Length:** ~${data.article.content.split(/\s+/).length} words +- **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}` : ''} -### Description -${data.article.description} +${data.metadata.reason ? `### Reason for Contribution +${data.metadata.reason} -${data.metadata.reason ? `### Reason for Contribution\n${data.metadata.reason}\n` : ''} +` : ''}### 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 -- [ ] Language is clear and appropriate for the difficulty level -- [ ] All sections are properly structured +- [ ] 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 -- [ ] Links and references are valid -- [ ] Media files (if any) are appropriate +- [ ] Proper attribution for external sources ### Files Changed -- \`src/content/knowledgebase/${generateArticleSlug(data.article.title, data.article.toolName)}.md\` (${data.type}) -- \`src/data/tools.yaml\` (knowledgebase flag update) +- \`src/content/knowledgebase/${generateArticleSlug(data.article.title, data.article.toolName)}.md\` (new article) --- -*This contribution was submitted via the CC24-Hub knowledgebase editor.*`; +*This contribution was submitted via the CC24-Hub document-based knowledge base system.*`; } } @@ -392,12 +333,12 @@ export const POST: APIRoute = async ({ request }) => { }); } - // Additional knowledgebase-specific validation - const kbValidation = await validateKnowledgebaseData(validatedData); + // Additional validation + const kbValidation = validateKnowledgebaseData(validatedData); if (!kbValidation.valid) { return new Response(JSON.stringify({ success: false, - error: 'Knowledgebase validation failed', + error: 'Content validation failed', details: kbValidation.errors }), { status: 400, @@ -407,7 +348,7 @@ export const POST: APIRoute = async ({ request }) => { // Prepare contribution data const contributionData: KnowledgebaseContributionData = { - type: 'add', // For now, only support adding new articles + type: 'add', article: validatedData, metadata: { submitter: userEmail, @@ -420,7 +361,6 @@ export const POST: APIRoute = async ({ request }) => { const result = await gitManager.submitKnowledgebaseContribution(contributionData); if (result.success) { - // Log successful contribution console.log(`[KB CONTRIBUTION] "${validatedData.title}" for ${validatedData.toolName} by ${userEmail} - PR: ${result.prUrl}`); return new Response(JSON.stringify({ @@ -433,7 +373,6 @@ export const POST: APIRoute = async ({ request }) => { headers: { 'Content-Type': 'application/json' } }); } else { - // Log failed contribution console.error(`[KB CONTRIBUTION FAILED] "${validatedData.title}" by ${userEmail}: ${result.message}`); return new Response(JSON.stringify({ diff --git a/src/pages/api/contribute/tool.ts b/src/pages/api/contribute/tool.ts index c1aad7a..f764324 100644 --- a/src/pages/api/contribute/tool.ts +++ b/src/pages/api/contribute/tool.ts @@ -346,48 +346,4 @@ export const POST: APIRoute = async ({ request }) => { 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' } - }); - } }; \ No newline at end of file diff --git a/src/pages/contribute/index.astro b/src/pages/contribute/index.astro index b5311fe..66660c9 100644 --- a/src/pages/contribute/index.astro +++ b/src/pages/contribute/index.astro @@ -2,6 +2,7 @@ // src/pages/contribute/index.astro - Updated for Phase 3 import BaseLayout from '../../layouts/BaseLayout.astro'; import { getSessionFromRequest, verifySession } from '../../utils/auth.js'; +import { getAuthContext, requireAuth } from '../../utils/serverAuth.js'; export const prerender = false; @@ -20,9 +21,9 @@ if (authRequired) { } } - if (!isAuthenticated) { - return Astro.redirect('/auth/login'); - } + const authContext = await getAuthContext(Astro); + const authRedirect = requireAuth(authContext, Astro.url.toString()); + if (authRedirect) return authRedirect; } --- @@ -148,12 +149,6 @@ if (authRequired) { Report Issue - - - - - System Health - @@ -199,95 +194,7 @@ if (authRequired) { - -
-

System Status

-
-
- Checking system health... -
- -
-

- Features Available: Tool contributions, knowledgebase articles, media uploads -

-

- Storage: Local + Nextcloud (if configured) | - Authentication: {authRequired ? 'Required' : 'Disabled'} | - Rate Limiting: Active -

-
-
- - - - + - const response = await fetch('/api/contribute/knowledgebase', { - method: 'POST', - body: formData - }); + \ No newline at end of file diff --git a/src/utils/gitContributions.ts b/src/utils/gitContributions.ts index e39ae6b..3139538 100644 --- a/src/utils/gitContributions.ts +++ b/src/utils/gitContributions.ts @@ -432,66 +432,4 @@ This contribution contains the raw tool data for manual review and integration.` --- *This contribution was submitted via the CC24-Hub web interface and contains only the raw tool data for manual integration.*`; } - - async checkHealth(): Promise<{healthy: boolean, issues?: string[]}> { - const issues: string[] = []; - - try { - // Check if local repo exists and is accessible - try { - await fs.access(this.config.localRepoPath); - } catch { - issues.push('Local repository path not accessible'); - } - - // Check git status - try { - execSync('git status', { cwd: this.config.localRepoPath, stdio: 'pipe' }); - } catch { - issues.push('Local repository is not a valid git repository'); - } - - // Test API connectivity - try { - let testUrl: string; - switch (this.config.provider) { - case 'gitea': - testUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}`; - break; - case 'github': - testUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}`; - break; - case 'gitlab': - testUrl = `${this.config.apiEndpoint}/projects/${encodeURIComponent(this.config.repoOwner + '/' + this.config.repoName)}`; - break; - default: - throw new Error('Unknown provider'); - } - - const response = await fetch(testUrl, { - headers: { - 'Authorization': `Bearer ${this.config.apiToken}` - } - }); - - if (!response.ok) { - issues.push(`API connectivity failed: ${response.status}`); - } - - } catch (error) { - issues.push(`API test failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - - return { - healthy: issues.length === 0, - issues: issues.length > 0 ? issues : undefined - }; - - } catch (error) { - return { - healthy: false, - issues: [`Health check failed: ${error instanceof Error ? error.message : 'Unknown error'}`] - }; - } - } } \ No newline at end of file diff --git a/src/utils/nextcloud.ts b/src/utils/nextcloud.ts index 51f1a8d..607e93b 100644 --- a/src/utils/nextcloud.ts +++ b/src/utils/nextcloud.ts @@ -168,6 +168,10 @@ export class NextcloudUploader { const categoryPath = this.sanitizeFilename(category); const remotePath = `${this.config.uploadPath}/${categoryPath}/${uniqueFilename}`; + // **FIX: Ensure directory exists before upload** + const dirPath = `${this.config.uploadPath}/${categoryPath}`; + await this.ensureDirectoryExists(dirPath); + // Convert file to buffer const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); @@ -207,6 +211,36 @@ export class NextcloudUploader { }; } } + + private async ensureDirectoryExists(dirPath: string): Promise { + try { + // Split path and create each directory level + const parts = dirPath.split('/').filter(part => part); + let currentPath = ''; + + for (const part of parts) { + currentPath += '/' + part; + + const mkcolUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${currentPath}`; + + const response = await fetch(mkcolUrl, { + method: 'MKCOL', + headers: { + 'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}` + } + }); + + // 201 = created, 405 = already exists, both are fine + if (response.status !== 201 && response.status !== 405) { + console.warn(`Directory creation failed: ${response.status} for ${currentPath}`); + } + } + + } catch (error) { + console.warn('Failed to ensure directory exists:', error); + // Don't fail upload for directory creation issues + } + } /** * Create a public share link for the uploaded file