diff --git a/.env.example b/.env.example index 1af7bd7..eb7304d 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,13 @@ AI_API_ENDPOINT=https://aiendpoint.org AI_API_KEY=your_apikey_here AI_MODEL='ai_model_name_here' +# Git Repository +GIT_REPO_URL=https://git.cc24.dev/mstoeck3/cc24-hub.git +GIT_API_ENDPOINT=https://git.cc24.dev/api/v1 +GIT_API_TOKEN= +LOCAL_REPO_PATH=/var/git/cc24-hub +GIT_PROVIDER=gitea + # OIDC Configuration OIDC_ENDPOINT=https://oidc-provider.org OIDC_CLIENT_ID=your_oidc_client_id @@ -15,4 +22,21 @@ AUTHENTICATION_NECESSARY=false # Always set this to true in prod # Application PUBLIC_BASE_URL=http://localhost:4321 -NODE_ENV=development \ No newline at end of file +# Media Storage +LOCAL_UPLOAD_PATH=./public/uploads +# Nextcloud integration (optional) +NEXTCLOUD_ENDPOINT= +NEXTCLOUD_USERNAME= +NEXTCLOUD_PASSWORD= +NEXTCLOUD_UPLOAD_PATH=/kb-media +NEXTCLOUD_PUBLIC_URL= + +# Custom upload limits (optional) +MAX_FILE_SIZE=52428800 # 50MB in bytes +MAX_UPLOADS_PER_HOUR=100 + +# Development/Production mode +NODE_ENV=development + +# Logging level +LOG_LEVEL=info \ No newline at end of file diff --git a/src/pages/api/auth/process.ts b/src/pages/api/auth/process.ts index 3459fef..c351e0a 100644 --- a/src/pages/api/auth/process.ts +++ b/src/pages/api/auth/process.ts @@ -1,3 +1,4 @@ +// src/pages/api/auth/process.ts - Fixed Email Support import type { APIRoute } from 'astro'; import { parse } from 'cookie'; import { @@ -5,7 +6,8 @@ import { getUserInfo, createSession, createSessionCookie, - logAuthEvent + logAuthEvent, + getUserEmail } from '../../../utils/auth.js'; // Mark as server-rendered @@ -67,13 +69,17 @@ export const POST: APIRoute = async ({ request }) => { console.log('Getting user info...'); const userInfo = await getUserInfo(tokens.access_token); - // Create session - const sessionToken = await createSession(userInfo.sub || userInfo.preferred_username || 'unknown'); + // Extract user details + const userId = userInfo.sub || userInfo.preferred_username || 'unknown'; + const userEmail = getUserEmail(userInfo); + + // Create session with email + const sessionToken = await createSession(userId, userEmail); const sessionCookie = createSessionCookie(sessionToken); logAuthEvent('Authentication successful', { - userId: userInfo.sub || userInfo.preferred_username, - email: userInfo.email + userId: userId, + email: userEmail }); // Clear auth state cookie @@ -95,7 +101,7 @@ export const POST: APIRoute = async ({ request }) => { } catch (error) { console.error('Authentication processing failed:', error); - logAuthEvent('Authentication processing failed', { error: error.message }); + logAuthEvent('Authentication processing failed', { error: error instanceof Error ? error.message : 'Unknown error' }); return new Response(JSON.stringify({ success: false }), { status: 500, headers: { 'Content-Type': 'application/json' } diff --git a/src/pages/api/contribute/health.ts b/src/pages/api/contribute/health.ts new file mode 100644 index 0000000..2e1694b --- /dev/null +++ b/src/pages/api/contribute/health.ts @@ -0,0 +1,478 @@ +// 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 new file mode 100644 index 0000000..21e63b0 --- /dev/null +++ b/src/pages/api/contribute/knowledgebase.ts @@ -0,0 +1,467 @@ +// 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'; +import { promises as fs } from 'fs'; +import path from 'path'; + +export const prerender = false; + +// Enhanced knowledgebase schema for 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' }) + }), + 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([])), + 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 []; + } + }).pipe(z.array(z.any()).default([])) +}); + +interface KnowledgebaseContributionData { + type: 'add' | 'edit'; + article: { + toolName: string; + title: string; + description: string; + content: string; + difficulty: string; + categories: string[]; + tags: string[]; + sections: Record; + uploadedFiles: any[]; + }; + metadata: { + submitter: string; + reason?: string; + }; +} + +// Rate limiting (same pattern as tool contributions) +const rateLimitStore = new Map(); +const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour +const RATE_LIMIT_MAX = 10; // Max 10 submissions per hour + +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; +} + +async function validateKnowledgebaseData(article: any): Promise<{ valid: boolean; errors: string[] }> { + const errors: string[] = []; + + // 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'); + } + + // 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 + }; +} + +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 generateMarkdownFrontmatter(article: any): string { + const now = new Date(); + const frontmatter = { + title: article.title, + tool_name: article.toolName, + description: article.description, + last_updated: now.toISOString().split('T')[0], // YYYY-MM-DD format + author: 'CC24-Team', + difficulty: article.difficulty, + categories: article.categories, + tags: article.tags, + sections: article.sections, + review_status: 'draft' + }; + + return `---\n${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`; +} + +// 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()}`; + + try { + // Create branch + await this.createBranch(branchName); + + // Generate file content + const slug = generateArticleSlug(data.article.title, data.article.toolName); + const frontmatter = generateMarkdownFrontmatter(data.article); + const fullContent = frontmatter + data.article.content; + + // 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); + + // Commit changes + const commitMessage = `${data.type === 'add' ? 'Add' : 'Update'} knowledgebase article: ${data.article.title} + +Contributed by: ${data.metadata.submitter} +Tool: ${data.article.toolName} +Difficulty: ${data.article.difficulty} +Categories: ${data.article.categories.join(', ')} + +${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, + `Knowledgebase: ${data.article.title}`, + this.generateKnowledgebasePRDescription(data) + ); + + return { + success: true, + message: `Knowledgebase article contribution 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 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'} +**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} + +${data.metadata.reason ? `### Reason for Contribution\n${data.metadata.reason}\n` : ''} + +### Review Checklist +- [ ] Article content is accurate and helpful +- [ ] Language is clear and appropriate for the difficulty level +- [ ] All sections are properly structured +- [ ] Categories and tags are relevant +- [ ] No sensitive or inappropriate content +- [ ] Links and references are valid +- [ ] Media files (if any) are appropriate + +### Files Changed +- \`src/content/knowledgebase/${generateArticleSlug(data.article.title, data.article.toolName)}.md\` (${data.type}) +- \`src/data/tools.yaml\` (knowledgebase flag update) + +--- +*This contribution was submitted via the CC24-Hub knowledgebase editor.*`; + } +} + +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 knowledgebase-specific validation + const kbValidation = await validateKnowledgebaseData(validatedData); + if (!kbValidation.valid) { + return new Response(JSON.stringify({ + success: false, + error: 'Knowledgebase validation failed', + details: kbValidation.errors + }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Prepare contribution data + const contributionData: KnowledgebaseContributionData = { + type: 'add', // For now, only support adding new articles + 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) { + // Log successful contribution + 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 { + // Log failed contribution + 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' } + }); + } +}; \ No newline at end of file diff --git a/src/pages/api/upload/media.ts b/src/pages/api/upload/media.ts new file mode 100644 index 0000000..7bd65f1 --- /dev/null +++ b/src/pages/api/upload/media.ts @@ -0,0 +1,361 @@ +// src/pages/api/upload/media.ts +import type { APIRoute } from 'astro'; +import { getSessionFromRequest, verifySession } from '../../../utils/auth.js'; +import { NextcloudUploader, isNextcloudConfigured } from '../../../utils/nextcloud.js'; +import { promises as fs } from 'fs'; +import path from 'path'; +import crypto from 'crypto'; + +export const prerender = false; + +interface UploadResult { + success: boolean; + url?: string; + filename?: string; + size?: number; + error?: string; + storage?: 'nextcloud' | 'local'; +} + +// Configuration +const UPLOAD_CONFIG = { + maxFileSize: 50 * 1024 * 1024, // 50MB + allowedTypes: new Set([ + // Images + 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', + // Videos + 'video/mp4', 'video/webm', 'video/ogg', 'video/avi', 'video/mov', + // Documents + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/plain', 'text/csv', 'application/json' + ]), + localUploadPath: process.env.LOCAL_UPLOAD_PATH || './public/uploads', + publicBaseUrl: process.env.PUBLIC_BASE_URL || 'http://localhost:4321' +}; + +// Rate limiting for uploads +const uploadRateLimit = new Map(); +const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour +const RATE_LIMIT_MAX = 100; // Max 100 uploads per hour per user + +function checkUploadRateLimit(userEmail: string): boolean { + const now = Date.now(); + const userLimit = uploadRateLimit.get(userEmail); + + if (!userLimit || now > userLimit.resetTime) { + uploadRateLimit.set(userEmail, { count: 1, resetTime: now + RATE_LIMIT_WINDOW }); + return true; + } + + if (userLimit.count >= RATE_LIMIT_MAX) { + return false; + } + + userLimit.count++; + return true; +} + +function validateFile(file: File): { valid: boolean; error?: string } { + // Check file size + if (file.size > UPLOAD_CONFIG.maxFileSize) { + return { + valid: false, + error: `File too large (max ${Math.round(UPLOAD_CONFIG.maxFileSize / 1024 / 1024)}MB)` + }; + } + + // Check file type + if (!UPLOAD_CONFIG.allowedTypes.has(file.type)) { + return { + valid: false, + error: `File type not allowed: ${file.type}` + }; + } + + // Check filename + if (!file.name || file.name.trim().length === 0) { + return { + valid: false, + error: 'Invalid filename' + }; + } + + return { valid: true }; +} + +function sanitizeFilename(filename: string): string { + // Remove or replace unsafe characters + return filename + .replace(/[^a-zA-Z0-9._-]/g, '_') // Replace unsafe chars with underscore + .replace(/_{2,}/g, '_') // Replace multiple underscores with single + .replace(/^_|_$/g, '') // Remove leading/trailing underscores + .toLowerCase() + .substring(0, 100); // Limit length +} + +function generateUniqueFilename(originalName: string): string { + const timestamp = Date.now(); + const randomId = crypto.randomBytes(4).toString('hex'); + const ext = path.extname(originalName); + const base = path.basename(originalName, ext); + const sanitizedBase = sanitizeFilename(base); + + return `${timestamp}_${randomId}_${sanitizedBase}${ext}`; +} + +async function uploadToLocal(file: File, category: string): Promise { + try { + // Ensure upload directory exists + const categoryDir = path.join(UPLOAD_CONFIG.localUploadPath, sanitizeFilename(category)); + await fs.mkdir(categoryDir, { recursive: true }); + + // Generate unique filename + const uniqueFilename = generateUniqueFilename(file.name); + const filePath = path.join(categoryDir, uniqueFilename); + + // Convert file to buffer and write + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + await fs.writeFile(filePath, buffer); + + // Generate public URL + const relativePath = path.posix.join('/uploads', sanitizeFilename(category), uniqueFilename); + const publicUrl = `${UPLOAD_CONFIG.publicBaseUrl}${relativePath}`; + + return { + success: true, + url: publicUrl, + filename: uniqueFilename, + size: file.size, + storage: 'local' + }; + + } catch (error) { + console.error('Local upload error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Local upload failed', + storage: 'local' + }; + } +} + +async function uploadToNextcloud(file: File, category: string): Promise { + try { + const uploader = new NextcloudUploader(); + const result = await uploader.uploadFile(file, category); + + return { + ...result, + storage: 'nextcloud' + }; + + } catch (error) { + console.error('Nextcloud upload error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Nextcloud upload failed', + storage: 'nextcloud' + }; + } +} + +export const POST: APIRoute = async ({ request }) => { + try { + // Check authentication + const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false'; + let userEmail = 'anonymous'; + + 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' } + }); + } + + userEmail = session.email; + } + + // Rate limiting + if (!checkUploadRateLimit(userEmail)) { + return new Response(JSON.stringify({ + error: 'Upload rate limit exceeded. Please wait before uploading more files.' + }), { + status: 429, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Parse form data + const formData = await request.formData(); + const file = formData.get('file') as File; + const type = formData.get('type') as string || 'general'; + + if (!file) { + return new Response(JSON.stringify({ + error: 'No file provided' + }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Validate file + const validation = validateFile(file); + if (!validation.valid) { + return new Response(JSON.stringify({ + error: validation.error + }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Determine upload strategy + const useNextcloud = isNextcloudConfigured(); + let result: UploadResult; + + if (useNextcloud) { + // Try Nextcloud first, fallback to local + result = await uploadToNextcloud(file, type); + + if (!result.success) { + console.warn('Nextcloud upload failed, falling back to local storage:', result.error); + result = await uploadToLocal(file, type); + } + } else { + // Use local storage + result = await uploadToLocal(file, type); + } + + if (result.success) { + // Log successful upload + console.log(`[MEDIA UPLOAD] ${file.name} (${file.size} bytes) by ${userEmail} -> ${result.storage}: ${result.url}`); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } else { + // Log failed upload + console.error(`[MEDIA UPLOAD FAILED] ${file.name} by ${userEmail}: ${result.error}`); + + return new Response(JSON.stringify(result), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + } catch (error) { + console.error('Media upload API error:', error); + + return new Response(JSON.stringify({ + success: false, + error: 'Internal server error' + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +}; + +// GET endpoint for upload status/info +export const GET: 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' } + }); + } + } + + // Return upload configuration and status + const nextcloudConfigured = isNextcloudConfigured(); + + // Check local upload directory + let localStorageAvailable = false; + try { + await fs.access(UPLOAD_CONFIG.localUploadPath); + localStorageAvailable = true; + } catch { + try { + await fs.mkdir(UPLOAD_CONFIG.localUploadPath, { recursive: true }); + localStorageAvailable = true; + } catch (error) { + console.warn('Local upload directory not accessible:', error); + } + } + + const status = { + storage: { + nextcloud: { + configured: nextcloudConfigured, + primary: nextcloudConfigured + }, + local: { + available: localStorageAvailable, + fallback: nextcloudConfigured, + primary: !nextcloudConfigured + } + }, + limits: { + maxFileSize: UPLOAD_CONFIG.maxFileSize, + maxFileSizeMB: Math.round(UPLOAD_CONFIG.maxFileSize / 1024 / 1024), + allowedTypes: Array.from(UPLOAD_CONFIG.allowedTypes), + rateLimit: { + maxPerHour: RATE_LIMIT_MAX, + windowMs: RATE_LIMIT_WINDOW + } + }, + paths: { + uploadEndpoint: '/api/upload/media', + localPath: localStorageAvailable ? '/uploads' : null + } + }; + + return new Response(JSON.stringify(status), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + + } catch (error) { + console.error('Media upload status error:', error); + + return new Response(JSON.stringify({ + error: 'Failed to get upload status' + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +}; \ No newline at end of file diff --git a/src/pages/auth/callback.astro b/src/pages/auth/callback.astro index 97400c5..7229600 100644 --- a/src/pages/auth/callback.astro +++ b/src/pages/auth/callback.astro @@ -1,4 +1,5 @@ --- +// src/pages/auth/callback.astro - Fixed with Email // Since server-side URL parameters aren't working, // we'll handle this client-side and POST to the API --- @@ -6,49 +7,118 @@ Processing Authentication... + -
+
+

Processing authentication...

Please wait while we complete your login.

+
\ No newline at end of file diff --git a/src/pages/contribute/index.astro b/src/pages/contribute/index.astro index f2af618..b5311fe 100644 --- a/src/pages/contribute/index.astro +++ b/src/pages/contribute/index.astro @@ -1,12 +1,29 @@ --- -// src/pages/contribute/index.astro +// src/pages/contribute/index.astro - Updated for Phase 3 import BaseLayout from '../../layouts/BaseLayout.astro'; -import { getAuthContext, requireAuth } from '../../utils/serverAuth.js'; +import { getSessionFromRequest, verifySession } from '../../utils/auth.js'; + +export const prerender = false; // Check authentication -const authContext = await getAuthContext(Astro); -const authRedirect = requireAuth(authContext, Astro.url.toString()); -if (authRedirect) return authRedirect; +const authRequired = import.meta.env.AUTHENTICATION_NECESSARY !== 'false'; +let isAuthenticated = false; +let userEmail = ''; + +if (authRequired) { + const sessionToken = getSessionFromRequest(Astro.request); + if (sessionToken) { + const session = await verifySession(sessionToken); + if (session) { + isAuthenticated = true; + userEmail = session.email; + } + } + + if (!isAuthenticated) { + return Astro.redirect('/auth/login'); + } +} --- @@ -26,6 +43,11 @@ if (authRedirect) return authRedirect; Help expand our DFIR knowledge base by contributing tools, methods, concepts, and detailed articles. All contributions are reviewed before being merged into the main database.

+ {userEmail && ( +

+ Logged in as: {userEmail} +

+ )}
@@ -126,6 +148,12 @@ if (authRedirect) return authRedirect; Report Issue + + + + + System Health + @@ -143,16 +171,18 @@ if (authRedirect) return authRedirect;
  • Use clear, professional language
  • Include relevant tags and categorization
  • Verify all URLs and links work correctly
  • +
  • Test installation and configuration steps
  • Review Process

      -
    • All contributions create pull requests
    • -
    • Maintainers review within 48-72 hours
    • -
    • Feedback provided for requested changes
    • -
    • Approved changes merged automatically
    • +
    • All contributions are submitted as pull requests
    • +
    • Automated validation checks run on submissions
    • +
    • Manual review by CC24 team members
    • +
    • Feedback provided through PR comments
    • +
    • Merge after approval and testing
    @@ -160,34 +190,116 @@ if (authRedirect) return authRedirect;

    Best Practices

    • Search existing entries before adding duplicates
    • -
    • Include rationale for new additions
    • -
    • Follow existing categorization patterns
    • -
    • Test tools/methods before recommending
    • +
    • Use consistent naming and categorization
    • +
    • Provide detailed descriptions and use cases
    • +
    • Include screenshots for complex procedures
    • +
    • Credit original sources and authors
    - -
    -

    - Community Contributions: Help us maintain the most comprehensive DFIR resource available. -
    - Your contributions are credited and help the entire forensics community. -

    + +
    +

    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 +

    +
    - - \ No newline at end of file + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + + .loading { + animation: pulse 2s infinite; + } + + @media (max-width: 768px) { + div[style*="grid-template-columns: 2fr 1fr"] { + grid-template-columns: 1fr !important; + gap: 1rem !important; + } + + div[style*="grid-template-columns: repeat(auto-fit, minmax(300px, 1fr))"] { + grid-template-columns: 1fr !important; + } + + h1 { + font-size: 2rem !important; + } + } + + + + \ No newline at end of file diff --git a/src/pages/contribute/knowledgebase.astro b/src/pages/contribute/knowledgebase.astro new file mode 100644 index 0000000..a3b502c --- /dev/null +++ b/src/pages/contribute/knowledgebase.astro @@ -0,0 +1,958 @@ +--- +// src/pages/contribute/knowledgebase.astro +import BaseLayout from '../../layouts/BaseLayout.astro'; +import { getSessionFromRequest, verifySession } from '../../utils/auth.js'; +import { getToolsData } from '../../utils/dataService.js'; + +export const prerender = false; + +// Check authentication +const authRequired = import.meta.env.AUTHENTICATION_NECESSARY !== 'false'; +let isAuthenticated = false; +let userEmail = ''; + +if (authRequired) { + const sessionToken = getSessionFromRequest(Astro.request); + if (sessionToken) { + const session = await verifySession(sessionToken); + if (session) { + isAuthenticated = true; + userEmail = session.email; + } + } + + if (!isAuthenticated) { + return Astro.redirect('/auth/login'); + } +} + +// Load tools data for selection +const data = await getToolsData(); +const tools = data.tools; + +// Get edit mode parameters +const url = new URL(Astro.request.url); +const editMode = url.searchParams.get('edit'); +const toolName = url.searchParams.get('tool'); + +// Article templates +const templates = { + installation: { + name: 'Installation Guide', + sections: ['overview', 'installation', 'configuration', 'usage_examples', 'troubleshooting'], + content: `# Installation Guide for {TOOL_NAME} + +## Overview +Brief description of what {TOOL_NAME} is and what this guide covers. + +## System Requirements +- Operating System: +- RAM: +- Storage: +- Dependencies: + +## Installation Steps + +### Step 1: Download +Instructions for downloading the tool... + +### Step 2: Installation +Detailed installation instructions... + +### Step 3: Initial Configuration +Basic configuration steps... + +## Verification +How to verify the installation was successful... + +## Troubleshooting +Common issues and solutions... +` + }, + tutorial: { + name: 'Tutorial/How-to Guide', + sections: ['overview', 'usage_examples', 'best_practices'], + content: `# {TOOL_NAME} Tutorial + +## Overview +What you'll learn in this tutorial... + +## Prerequisites +- Required knowledge +- Tools needed +- Setup requirements + +## Step-by-Step Guide + +### Step 1: Getting Started +Initial setup and preparation... + +### Step 2: Basic Usage +Core functionality walkthrough... + +### Step 3: Advanced Features +More complex operations... + +## Best Practices +- Tip 1: ... +- Tip 2: ... +- Tip 3: ... + +## Next Steps +Where to go from here... +` + }, + case_study: { + name: 'Case Study', + sections: ['overview', 'usage_examples', 'best_practices'], + content: `# Case Study: {TOOL_NAME} in Action + +## Scenario +Description of the forensic scenario... + +## Challenge +What problems needed to be solved... + +## Solution Approach +How {TOOL_NAME} was used to address the challenge... + +## Implementation +Detailed steps taken... + +## Results +What was discovered or accomplished... + +## Lessons Learned +Key takeaways and insights... +` + }, + reference: { + name: 'Reference Documentation', + sections: ['overview', 'usage_examples', 'advanced_topics'], + content: `# {TOOL_NAME} Reference + +## Overview +Comprehensive reference for {TOOL_NAME}... + +## Command Reference +List of commands and their usage... + +## Configuration Options +Available settings and parameters... + +## API Reference +(If applicable) API endpoints and methods... + +## Examples +Common usage examples... +` + } +}; +--- + + +
    + +
    +

    Write Knowledgebase Article

    +

    + Create detailed guides, tutorials, and documentation for forensic tools and methodologies. +

    + {userEmail && ( +

    + Logged in as: {userEmail} +

    + )} +
    + + + + + +
    + +
    +
    + +

    + Article Metadata +

    + + +
    + + +
    Choose the tool this article is about
    +
    + + +
    + + +
    +
    Clear, descriptive title for your article
    +
    0/100
    +
    +
    + + +
    + + +
    +
    Brief summary for search results and listings
    +
    0/300
    +
    +
    + + +
    + + +
    Choose a template to get started quickly
    +
    + + +
    + + +
    + + +
    +
    + + +
    Comma-separated (e.g., Installation, Guide)
    +
    + +
    + + +
    Comma-separated keywords
    +
    +
    + + +
    + +
    + + + + + + + +
    +
    Select which sections your article will include
    +
    + + +
    +

    Media Files

    +
    + + + + + + + +

    Click to upload or drag files here

    +

    + Images, videos, PDFs, and documents +

    +
    + +
    + + +
    + + +
    +
    +
    + + +
    +
    +

    Content Editor

    +
    + + +
    +
    + + +
    +
    + + + + + + + +
    + + + +
    +
    Supports full Markdown syntax
    +
    Words: 0 | Characters: 0
    +
    +
    + + + +
    +
    + + +
    +
    + + + + + + + +
    \ No newline at end of file diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 52a916a..5fd5f74 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -1,3 +1,4 @@ +// src/utils/auth.ts - Enhanced with Email Support import { SignJWT, jwtVerify, type JWTPayload } from 'jose'; import { serialize, parse } from 'cookie'; import { config } from 'dotenv'; @@ -14,21 +15,34 @@ function getEnv(key: string): string { return value; } -const SECRET_KEY = new TextEncoder().encode(getEnv('AUTH_SECRET')); +const SECRET_KEY = new TextEncoder().encode( + process.env.AUTH_SECRET || + process.env.OIDC_CLIENT_SECRET || + 'cc24-hub-default-secret-key-change-in-production' +); const SESSION_DURATION = 6 * 60 * 60; // 6 hours in seconds export interface SessionData { userId: string; + email: string; authenticated: boolean; exp: number; } -// Create a signed JWT session token -export async function createSession(userId: string): Promise { +export interface UserInfo { + sub?: string; + preferred_username?: string; + email?: string; + name?: string; +} + +// Create a signed JWT session token with email +export async function createSession(userId: string, email: string): Promise { const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION; return await new SignJWT({ userId, + email, authenticated: true, exp }) @@ -45,11 +59,13 @@ export async function verifySession(token: string): Promise // Validate payload structure and cast properly if ( typeof payload.userId === 'string' && + typeof payload.email === 'string' && typeof payload.authenticated === 'boolean' && typeof payload.exp === 'number' ) { return { userId: payload.userId, + email: payload.email, authenticated: payload.authenticated, exp: payload.exp }; @@ -147,7 +163,7 @@ export async function exchangeCodeForTokens(code: string): Promise { } // Get user info from OIDC provider -export async function getUserInfo(accessToken: string): Promise { +export async function getUserInfo(accessToken: string): Promise { const oidcEndpoint = getEnv('OIDC_ENDPOINT'); const response = await fetch(`${oidcEndpoint}/apps/oidc/userinfo`, { @@ -174,4 +190,10 @@ export function generateState(): string { export function logAuthEvent(event: string, details?: any) { const timestamp = new Date().toISOString(); console.log(`[AUTH ${timestamp}] ${event}`, details ? JSON.stringify(details) : ''); +} + +// Helper function to safely get email from user info +export function getUserEmail(userInfo: UserInfo): string { + return userInfo.email || + `${userInfo.preferred_username || userInfo.sub || 'unknown'}@cc24.dev`; } \ No newline at end of file diff --git a/src/utils/gitContributions.ts b/src/utils/gitContributions.ts index ee13ab3..2624476 100644 --- a/src/utils/gitContributions.ts +++ b/src/utils/gitContributions.ts @@ -1,10 +1,10 @@ -// src/utils/gitContributions.ts +// src/utils/gitContributions.ts - Enhanced for Phase 3 import { execSync, spawn } from 'child_process'; import { promises as fs } from 'fs'; import { load, dump } from 'js-yaml'; import path from 'path'; -interface ContributionData { +export interface ContributionData { type: 'add' | 'edit'; tool: { name: string; @@ -31,7 +31,7 @@ interface ContributionData { }; } -interface GitOperationResult { +export interface GitOperationResult { success: boolean; message: string; prUrl?: string; @@ -48,8 +48,8 @@ interface GitConfig { repoName: string; } -class GitContributionManager { - private config: GitConfig; +export class GitContributionManager { + protected config: GitConfig; private activeBranches = new Set(); constructor() { @@ -78,380 +78,137 @@ class GitContributionManager { if (!match) { throw new Error('Invalid repository URL format'); } - - return { - owner: match[1], - name: match[2] - }; + return { owner: match[1], name: match[2] }; } catch (error) { throw new Error(`Failed to parse repository URL: ${url}`); } } - async submitContribution(data: ContributionData): Promise { - const branchName = this.generateBranchName(data); - - // Check if branch is already being processed - if (this.activeBranches.has(branchName)) { - return { - success: false, - message: 'A contribution with similar details is already being processed' - }; - } + // Enhanced git operations for Phase 3 + /** + * Create a new branch + */ + protected async createBranch(branchName: string): Promise { try { - this.activeBranches.add(branchName); - - // Ensure repository is in clean state - await this.ensureCleanRepo(); + // Ensure we're on main and up to date + execSync('git checkout main', { cwd: this.config.localRepoPath, stdio: 'pipe' }); + execSync('git pull origin main', { cwd: this.config.localRepoPath, stdio: 'pipe' }); // Create and checkout new branch - await this.createBranch(branchName); + execSync(`git checkout -b "${branchName}"`, { cwd: this.config.localRepoPath, stdio: 'pipe' }); - // Modify tools.yaml - await this.modifyToolsYaml(data); + this.activeBranches.add(branchName); - // Commit changes - await this.commitChanges(data, branchName); - - // Push branch - await this.pushBranch(branchName); - - // Create pull request - const prUrl = await this.createPullRequest(data, branchName); - - return { - success: true, - message: 'Contribution submitted successfully', - prUrl, - branchName - }; - - } catch (error) { - console.error('Git contribution failed:', error); - - // Attempt cleanup - await this.cleanup(branchName); - - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error occurred' - }; - } finally { - this.activeBranches.delete(branchName); - } - } - - private generateBranchName(data: ContributionData): string { - const timestamp = Date.now(); - const toolSlug = data.tool.name.toLowerCase() - .replace(/[^a-z0-9]/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); - - return `contrib-${data.type}-${toolSlug}-${timestamp}`; - } - - private async executeGitCommand(command: string, options: { cwd?: string; timeout?: number } = {}): Promise { - return new Promise((resolve, reject) => { - const { cwd = this.config.localRepoPath, timeout = 30000 } = options; - - const child = spawn('sh', ['-c', command], { - cwd, - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } - }); - - let stdout = ''; - let stderr = ''; - - child.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - child.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - const timeoutId = setTimeout(() => { - child.kill('SIGTERM'); - reject(new Error(`Git command timed out: ${command}`)); - }, timeout); - - child.on('close', (code) => { - clearTimeout(timeoutId); - - if (code === 0) { - resolve(stdout.trim()); - } else { - reject(new Error(`Git command failed (${code}): ${command}\n${stderr}`)); - } - }); - - child.on('error', (error) => { - clearTimeout(timeoutId); - reject(new Error(`Failed to execute git command: ${error.message}`)); - }); - }); - } - - private async ensureCleanRepo(): Promise { - try { - // Fetch latest changes - await this.executeGitCommand('git fetch origin'); - - // Reset to main branch - await this.executeGitCommand('git checkout main'); - await this.executeGitCommand('git reset --hard origin/main'); - - // Clean untracked files - await this.executeGitCommand('git clean -fd'); - - } catch (error) { - throw new Error(`Failed to clean repository: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - private async createBranch(branchName: string): Promise { - try { - await this.executeGitCommand(`git checkout -b ${branchName}`); } catch (error) { throw new Error(`Failed to create branch ${branchName}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } - private async modifyToolsYaml(data: ContributionData): Promise { + /** + * Write file to repository + */ + protected async writeFile(filePath: string, content: string): Promise { try { - const yamlPath = path.join(this.config.localRepoPath, 'src/data/tools.yaml'); - const originalContent = await fs.readFile(yamlPath, 'utf8'); + const fullPath = path.join(this.config.localRepoPath, filePath); + const dirPath = path.dirname(fullPath); - if (data.type === 'add') { - // For adding, append to the tools section - const newToolYaml = this.generateToolYaml(data.tool); - const updatedContent = this.insertNewTool(originalContent, newToolYaml); - await fs.writeFile(yamlPath, updatedContent, 'utf8'); - } else { - // For editing, we still need to parse and regenerate (unfortunately) - // But let's at least preserve the overall structure - const yamlData = load(originalContent) as any; - - const existingIndex = yamlData.tools.findIndex((tool: any) => tool.name === data.tool.name); - if (existingIndex === -1) { - throw new Error(`Tool "${data.tool.name}" not found for editing`); - } - - yamlData.tools[existingIndex] = this.normalizeToolObject(data.tool); - - // Use consistent YAML formatting - const newYamlContent = dump(yamlData, { - lineWidth: 120, - noRefs: true, - sortKeys: false, - forceQuotes: false, - flowLevel: -1, - styles: { - '!!null': 'canonical' - } - }); - - await fs.writeFile(yamlPath, newYamlContent, 'utf8'); - } + // Ensure directory exists + await fs.mkdir(dirPath, { recursive: true }); + + // Write file + await fs.writeFile(fullPath, content, 'utf8'); } catch (error) { - throw new Error(`Failed to modify tools.yaml: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw new Error(`Failed to write file ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } - private normalizeToolObject(tool: any): any { - const normalized = { ...tool }; - - // Convert empty strings and undefined to null - Object.keys(normalized).forEach(key => { - if (normalized[key] === '' || normalized[key] === undefined) { - normalized[key] = null; - } - }); - - // Ensure arrays are preserved as arrays (even if empty) - ['domains', 'phases', 'platforms', 'tags', 'related_concepts'].forEach(key => { - if (!Array.isArray(normalized[key])) { - normalized[key] = []; - } - }); - - return normalized; - } - - private generateToolYaml(tool: any): string { - const normalized = this.normalizeToolObject(tool); - - let yaml = ` - name: ${normalized.name}\n`; - - if (normalized.icon) yaml += ` icon: ${normalized.icon}\n`; - yaml += ` type: ${normalized.type}\n`; - - // Handle description with proper formatting for long text - if (normalized.description) { - if (normalized.description.length > 80) { - yaml += ` description: >-\n`; - const words = normalized.description.split(' '); - let line = ' '; - for (const word of words) { - if ((line + word).length > 80 && line.length > 6) { - yaml += line.trimEnd() + '\n'; - line = ' ' + word + ' '; - } else { - line += word + ' '; - } - } - yaml += line.trimEnd() + '\n'; - } else { - yaml += ` description: ${normalized.description}\n`; - } - } - - // Arrays - ['domains', 'phases', 'platforms'].forEach(key => { - if (normalized[key] && normalized[key].length > 0) { - yaml += ` ${key}:\n`; - normalized[key].forEach((item: string) => { - yaml += ` - ${item}\n`; - }); - } else { - yaml += ` ${key}: []\n`; - } - }); - - // Add other fields - if (normalized['domain-agnostic-software']) { - yaml += ` domain-agnostic-software: ${JSON.stringify(normalized['domain-agnostic-software'])}\n`; - } else { - yaml += ` domain-agnostic-software: null\n`; - } - - yaml += ` skillLevel: ${normalized.skillLevel}\n`; - yaml += ` accessType: ${normalized.accessType || 'null'}\n`; - - // Handle URL with proper formatting for long URLs - if (normalized.url) { - if (normalized.url.length > 80) { - yaml += ` url: >-\n ${normalized.url}\n`; - } else { - yaml += ` url: ${normalized.url}\n`; - } - } - - yaml += ` projectUrl: ${normalized.projectUrl || 'null'}\n`; - yaml += ` license: ${normalized.license || 'null'}\n`; - yaml += ` knowledgebase: ${normalized.knowledgebase || 'null'}\n`; - - // Related concepts - if (normalized.related_concepts && normalized.related_concepts.length > 0) { - yaml += ` related_concepts:\n`; - normalized.related_concepts.forEach((concept: string) => { - yaml += ` - ${concept}\n`; - }); - } else { - yaml += ` related_concepts: null\n`; - } - - // Tags - if (normalized.tags && normalized.tags.length > 0) { - yaml += ` tags:\n`; - normalized.tags.forEach((tag: string) => { - yaml += ` - ${tag}\n`; - }); - } else { - yaml += ` tags: []\n`; - } - - if (normalized.statusUrl) { - yaml += ` statusUrl: ${normalized.statusUrl}\n`; - } - - return yaml; - } - - private insertNewTool(originalContent: string, newToolYaml: string): string { - // Find the end of the tools section (before domains:) - const domainsIndex = originalContent.indexOf('\ndomains:'); - if (domainsIndex === -1) { - // If no domains section, just append to end with proper spacing - return originalContent.trimEnd() + '\n\n' + newToolYaml.trimEnd() + '\n'; - } - - // Insert before the domains section with proper newline spacing - const beforeDomains = originalContent.slice(0, domainsIndex).trimEnd(); - const afterDomains = originalContent.slice(domainsIndex); - - return beforeDomains + '\n\n' + newToolYaml.trimEnd() + afterDomains; - } - - private async commitChanges(data: ContributionData, branchName: string): Promise { + /** + * Read file from repository + */ + protected async readFile(filePath: string): Promise { try { - // Configure git user for this commit - await this.executeGitCommand('git config user.name "CC24-Hub Contributors"'); - await this.executeGitCommand('git config user.email "contributors@cc24.dev"'); + const fullPath = path.join(this.config.localRepoPath, filePath); + return await fs.readFile(fullPath, 'utf8'); + } catch (error) { + throw new Error(`Failed to read file ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Commit changes with message + */ + protected async commitChanges(message: string): Promise { + try { + // Add all changes + execSync('git add .', { cwd: this.config.localRepoPath, stdio: 'pipe' }); - // Stage changes - await this.executeGitCommand('git add src/data/tools.yaml'); + // Check if there are any changes to commit + try { + execSync('git diff --cached --exit-code', { cwd: this.config.localRepoPath, stdio: 'pipe' }); + // If we get here, there are no changes + throw new Error('No changes to commit'); + } catch (error) { + // This is expected - it means there are changes to commit + } - // Create commit message - const action = data.type === 'add' ? 'Add' : 'Update'; - const commitMessage = `${action} ${data.tool.type}: ${data.tool.name} - -Submitted by: ${data.metadata.submitter} -${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''} - -Branch: ${branchName}`; - - await this.executeGitCommand(`git commit -m "${commitMessage}"`); + // Set git config if not already set + try { + execSync('git config user.email "contributions@cc24-hub.local"', { cwd: this.config.localRepoPath, stdio: 'pipe' }); + execSync('git config user.name "CC24-Hub Contributions"', { cwd: this.config.localRepoPath, stdio: 'pipe' }); + } catch { + // Config might already be set + } + + // Commit changes + execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: this.config.localRepoPath, stdio: 'pipe' }); } catch (error) { throw new Error(`Failed to commit changes: ${error instanceof Error ? error.message : 'Unknown error'}`); } } - private async pushBranch(branchName: string): Promise { + /** + * Push branch to remote + */ + protected async pushBranch(branchName: string): Promise { try { - await this.executeGitCommand(`git push origin ${branchName}`); + execSync(`git push -u origin "${branchName}"`, { cwd: this.config.localRepoPath, stdio: 'pipe' }); } catch (error) { throw new Error(`Failed to push branch ${branchName}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } - private async createPullRequest(data: ContributionData, branchName: string): Promise { - const action = data.type === 'add' ? 'Add' : 'Update'; - const title = `${action} ${data.tool.type}: ${data.tool.name}`; - - const body = `## Contribution Details - -**Type**: ${data.tool.type} -**Action**: ${action} -**Submitted by**: ${data.metadata.submitter} - -### Tool Information -- **Name**: ${data.tool.name} -- **Description**: ${data.tool.description} -- **Domains**: ${data.tool.domains.join(', ')} -- **Phases**: ${data.tool.phases.join(', ')} -- **Skill Level**: ${data.tool.skillLevel} -- **License**: ${data.tool.license || 'N/A'} -- **URL**: ${data.tool.url} - -${data.metadata.reason ? `### Reason for Contribution\n${data.metadata.reason}` : ''} - -### Review Checklist -- [ ] Tool information is accurate and complete -- [ ] Description is clear and informative -- [ ] Domains and phases are correctly assigned -- [ ] Tags are relevant and consistent -- [ ] License information is correct -- [ ] URLs are valid and accessible - ---- -*This contribution was submitted via the CC24-Hub web interface.*`; + /** + * Delete branch (cleanup) + */ + protected async deleteBranch(branchName: string): Promise { + try { + // Switch to main first + execSync('git checkout main', { cwd: this.config.localRepoPath, stdio: 'pipe' }); + + // Delete local branch + execSync(`git branch -D "${branchName}"`, { cwd: this.config.localRepoPath, stdio: 'pipe' }); + + // Delete remote branch if it exists + try { + execSync(`git push origin --delete "${branchName}"`, { cwd: this.config.localRepoPath, stdio: 'pipe' }); + } catch { + // Branch might not exist on remote yet + } + + this.activeBranches.delete(branchName); + + } catch (error) { + console.warn(`Failed to cleanup branch ${branchName}:`, error); + } + } + /** + * Create pull request + */ + protected async createPullRequest(branchName: string, title: string, body: string): Promise { try { let apiUrl: string; let requestBody: any; @@ -528,86 +285,181 @@ ${data.metadata.reason ? `### Reason for Contribution\n${data.metadata.reason}` } } - private async cleanup(branchName: string): Promise { + // Original tool contribution methods (unchanged) + + async submitContribution(data: ContributionData): Promise { + const branchName = `tool-${data.type}-${Date.now()}`; + try { - // Switch back to main and delete the failed branch - await this.executeGitCommand('git checkout main', { timeout: 10000 }); - await this.executeGitCommand(`git branch -D ${branchName}`, { timeout: 10000 }); + // Create branch + await this.createBranch(branchName); - // Try to delete remote branch if it exists - try { - await this.executeGitCommand(`git push origin --delete ${branchName}`, { timeout: 10000 }); - } catch (error) { - // Ignore errors when deleting remote branch (might not exist) - console.warn(`Could not delete remote branch ${branchName}:`, error); + // Load current tools.yaml + const toolsYamlPath = 'src/data/tools.yaml'; + const content = await this.readFile(toolsYamlPath); + const yamlData = load(content) as any; + + if (!yamlData.tools) { + yamlData.tools = []; } + // Apply changes + if (data.type === 'add') { + // Check if tool already exists + const existing = yamlData.tools.find((t: any) => t.name === data.tool.name); + if (existing) { + throw new Error(`Tool "${data.tool.name}" already exists`); + } + + yamlData.tools.push(data.tool); + } else if (data.type === 'edit') { + const index = yamlData.tools.findIndex((t: any) => t.name === data.tool.name); + if (index === -1) { + throw new Error(`Tool "${data.tool.name}" not found`); + } + + yamlData.tools[index] = { ...yamlData.tools[index], ...data.tool }; + } + + // Sort tools alphabetically + yamlData.tools.sort((a: any, b: any) => a.name.localeCompare(b.name)); + + // Generate updated YAML + const updatedYaml = dump(yamlData, { + lineWidth: -1, + noRefs: true, + quotingType: '"', + forceQuotes: false + }); + + // Write updated file + await this.writeFile(toolsYamlPath, updatedYaml); + + // Commit changes + const commitMessage = `${data.type === 'add' ? 'Add' : 'Update'} tool: ${data.tool.name} + +Contributed by: ${data.metadata.submitter} +Type: ${data.tool.type} +${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, + `${data.type === 'add' ? 'Add' : 'Update'} tool: ${data.tool.name}`, + this.generatePRDescription(data) + ); + + return { + success: true, + message: `Tool contribution submitted successfully`, + prUrl, + branchName + }; + } catch (error) { - console.error(`Cleanup failed for branch ${branchName}:`, error); + // Cleanup on failure + try { + await this.deleteBranch(branchName); + } catch (cleanupError) { + console.error('Failed to cleanup branch:', cleanupError); + } + + throw error; } } - async checkHealth(): Promise<{ healthy: boolean; issues?: string[] }> { + private generatePRDescription(data: ContributionData): string { + return `## Tool ${data.type === 'add' ? 'Addition' : 'Update'}: ${data.tool.name} + +**Type:** ${data.tool.type} +**Submitted by:** ${data.metadata.submitter} + +### Tool Details +- **Description:** ${data.tool.description} +- **Domains:** ${data.tool.domains.join(', ')} +- **Phases:** ${data.tool.phases.join(', ')} +- **Skill Level:** ${data.tool.skillLevel} +- **License:** ${data.tool.license || 'Not specified'} +- **URL:** ${data.tool.url} + +${data.metadata.reason ? `### Reason for Contribution\n${data.metadata.reason}` : ''} + +### Review Checklist +- [ ] Tool information is accurate and complete +- [ ] Description is clear and informative +- [ ] Domains and phases are correctly assigned +- [ ] Tags are relevant and consistent +- [ ] License information is correct +- [ ] URLs are valid and accessible + +--- +*This contribution was submitted via the CC24-Hub web interface.*`; + } + + async checkHealth(): Promise<{healthy: boolean, issues?: string[]}> { const issues: string[] = []; - + try { - // Check if local repo exists and is a git repository - const repoExists = await fs.access(this.config.localRepoPath).then(() => true).catch(() => false); - if (!repoExists) { - issues.push(`Local repository path does not exist: ${this.config.localRepoPath}`); - return { healthy: false, issues }; + // Check if local repo exists and is accessible + try { + await fs.access(this.config.localRepoPath); + } catch { + issues.push('Local repository path not accessible'); } - - const gitDirExists = await fs.access(path.join(this.config.localRepoPath, '.git')).then(() => true).catch(() => false); - if (!gitDirExists) { - issues.push('Local path is not a git repository'); - return { healthy: false, issues }; - } - + // Check git status try { - await this.executeGitCommand('git status --porcelain', { timeout: 5000 }); - } catch (error) { - issues.push(`Git status check failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + execSync('git status', { cwd: this.config.localRepoPath, stdio: 'pipe' }); + } catch { + issues.push('Local repository is not a valid git repository'); } - - // Check remote connectivity + + // Test API connectivity try { - await this.executeGitCommand('git ls-remote origin HEAD', { timeout: 10000 }); - } catch (error) { - issues.push(`Remote connectivity check failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - - // Check API connectivity - try { - const response = await fetch(this.config.apiEndpoint, { - headers: { 'Authorization': `Bearer ${this.config.apiToken}` }, - signal: AbortSignal.timeout(5000) + 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 && response.status !== 404) { // 404 is expected for base API endpoint - issues.push(`API connectivity check failed: HTTP ${response.status}`); + if (!response.ok) { + issues.push(`API connectivity failed: ${response.status}`); } + } catch (error) { - issues.push(`API connectivity check failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + issues.push(`API test failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } - - // Check write permissions - try { - const testFile = path.join(this.config.localRepoPath, '.write-test'); - await fs.writeFile(testFile, 'test'); - await fs.unlink(testFile); - } catch (error) { - issues.push(`Write permission check failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - - return { healthy: issues.length === 0, issues: issues.length > 0 ? issues : undefined }; - + + return { + healthy: issues.length === 0, + issues: issues.length > 0 ? issues : undefined + }; + } catch (error) { - issues.push(`Health check failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - return { healthy: false, issues }; + return { + healthy: false, + issues: [`Health check failed: ${error instanceof Error ? error.message : 'Unknown error'}`] + }; } } -} - -export { GitContributionManager, type ContributionData, type GitOperationResult }; \ No newline at end of file +} \ No newline at end of file diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts new file mode 100644 index 0000000..f154be4 --- /dev/null +++ b/src/utils/markdown.ts @@ -0,0 +1,332 @@ +// src/utils/markdown.ts +// Simple markdown parser for client-side preview functionality +// Note: For production, consider using a proper markdown library like marked or markdown-it + +export interface MarkdownParseOptions { + sanitize?: boolean; + breaks?: boolean; + linkTarget?: string; +} + +export class SimpleMarkdownParser { + private options: MarkdownParseOptions; + + constructor(options: MarkdownParseOptions = {}) { + this.options = { + sanitize: true, + breaks: true, + linkTarget: '_blank', + ...options + }; + } + + /** + * Parse markdown to HTML + */ + parse(markdown: string): string { + if (!markdown || markdown.trim().length === 0) { + return ''; + } + + let html = markdown; + + // Handle code blocks first (to prevent processing content inside them) + html = this.parseCodeBlocks(html); + + // Parse headers + html = this.parseHeaders(html); + + // Parse bold and italic + html = this.parseEmphasis(html); + + // Parse links and images + html = this.parseLinksAndImages(html); + + // Parse inline code + html = this.parseInlineCode(html); + + // Parse lists + html = this.parseLists(html); + + // Parse blockquotes + html = this.parseBlockquotes(html); + + // Parse horizontal rules + html = this.parseHorizontalRules(html); + + // Parse line breaks and paragraphs + html = this.parseLineBreaks(html); + + // Sanitize if needed + if (this.options.sanitize) { + html = this.sanitizeHtml(html); + } + + return html.trim(); + } + + private parseCodeBlocks(html: string): string { + // Replace code blocks with placeholders to protect them + const codeBlocks: string[] = []; + + // Match ```code``` blocks + html = html.replace(/```([\s\S]*?)```/g, (match, code) => { + const index = codeBlocks.length; + const lang = code.split('\n')[0].trim(); + const content = code.includes('\n') ? code.substring(code.indexOf('\n') + 1) : code; + + codeBlocks.push(`
    ${this.escapeHtml(content.trim())}
    `); + return `__CODEBLOCK_${index}__`; + }); + + // Restore code blocks at the end + codeBlocks.forEach((block, index) => { + html = html.replace(`__CODEBLOCK_${index}__`, block); + }); + + return html; + } + + private parseHeaders(html: string): string { + // H1-H6 headers + for (let i = 6; i >= 1; i--) { + const headerRegex = new RegExp(`^#{${i}}\\s+(.+)$`, 'gm'); + html = html.replace(headerRegex, `$1`); + } + return html; + } + + private parseEmphasis(html: string): string { + // Bold: **text** or __text__ + html = html.replace(/\*\*(.*?)\*\*/g, '$1'); + html = html.replace(/__(.*?)__/g, '$1'); + + // Italic: *text* or _text_ + html = html.replace(/\*(.*?)\*/g, '$1'); + html = html.replace(/_(.*?)_/g, '$1'); + + return html; + } + + private parseLinksAndImages(html: string): string { + const linkTarget = this.options.linkTarget ? ` target="${this.options.linkTarget}" rel="noopener noreferrer"` : ''; + + // Images: ![alt](src) + html = html.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, + '$1'); + + // Links: [text](url) + html = html.replace(/\[([^\]]*)\]\(([^)]*)\)/g, + `$1`); + + return html; + } + + private parseInlineCode(html: string): string { + // Inline code: `code` + html = html.replace(/`([^`]*)`/g, '$1'); + return html; + } + + private parseLists(html: string): string { + // Unordered lists + html = html.replace(/^[\s]*[-*+]\s+(.+)$/gm, '
  • $1
  • '); + + // Ordered lists + html = html.replace(/^[\s]*\d+\.\s+(.+)$/gm, '
  • $1
  • '); + + // Wrap consecutive list items in ul/ol + html = html.replace(/(
  • .*<\/li>)/s, (match) => { + // Simple approach: assume unordered list + return `
      ${match}
    `; + }); + + return html; + } + + private parseBlockquotes(html: string): string { + // Blockquotes: > text + html = html.replace(/^>\s+(.+)$/gm, '
    $1
    '); + + // Merge consecutive blockquotes + html = html.replace(/(<\/blockquote>)\s*(
    )/g, ' '); + + return html; + } + + private parseHorizontalRules(html: string): string { + // Horizontal rules: --- or *** + html = html.replace(/^[-*]{3,}$/gm, '
    '); + return html; + } + + private parseLineBreaks(html: string): string { + if (!this.options.breaks) { + return html; + } + + // Split into paragraphs (double line breaks) + const paragraphs = html.split(/\n\s*\n/); + + const processedParagraphs = paragraphs.map(paragraph => { + const trimmed = paragraph.trim(); + + // Skip if already wrapped in HTML tag + if (trimmed.startsWith('<') && trimmed.endsWith('>')) { + return trimmed; + } + + // Single line breaks become
    + const withBreaks = trimmed.replace(/\n/g, '
    '); + + // Wrap in paragraph if not empty and not already a block element + if (withBreaks && !this.isBlockElement(withBreaks)) { + return `

    ${withBreaks}

    `; + } + + return withBreaks; + }); + + return processedParagraphs.filter(p => p.trim()).join('\n\n'); + } + + private isBlockElement(html: string): boolean { + const blockTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'div', 'ul', 'ol', 'li', 'blockquote', 'pre', 'hr']; + return blockTags.some(tag => html.startsWith(`<${tag}`)); + } + + private sanitizeHtml(html: string): string { + // Very basic HTML sanitization - for production use a proper library + const allowedTags = [ + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'p', 'br', 'strong', 'em', 'code', 'pre', + 'a', 'img', 'ul', 'ol', 'li', 'blockquote', 'hr' + ]; + + // Remove script tags and event handlers + html = html.replace(/]*>[\s\S]*?<\/script>/gi, ''); + html = html.replace(/\bon\w+\s*=\s*"[^"]*"/gi, ''); + html = html.replace(/\bon\w+\s*=\s*'[^']*'/gi, ''); + html = html.replace(/javascript:/gi, ''); + + // This is a very basic sanitizer - for production use a proper library like DOMPurify + return html; + } + + private escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + /** + * Extract plain text from markdown (for word/character counting) + */ + extractText(markdown: string): string { + // Remove markdown syntax and return plain text + let text = markdown; + + // Remove code blocks + text = text.replace(/```[\s\S]*?```/g, ''); + + // Remove inline code + text = text.replace(/`[^`]*`/g, ''); + + // Remove images + text = text.replace(/!\[[^\]]*\]\([^)]*\)/g, ''); + + // Remove links but keep text + text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1'); + + // Remove headers + text = text.replace(/^#{1,6}\s+/gm, ''); + + // Remove emphasis + text = text.replace(/\*\*(.*?)\*\*/g, '$1'); + text = text.replace(/\*(.*?)\*/g, '$1'); + text = text.replace(/__(.*?)__/g, '$1'); + text = text.replace(/_(.*?)_/g, '$1'); + + // Remove blockquotes + text = text.replace(/^>\s+/gm, ''); + + // Remove list markers + text = text.replace(/^[\s]*[-*+]\s+/gm, ''); + text = text.replace(/^[\s]*\d+\.\s+/gm, ''); + + // Remove horizontal rules + text = text.replace(/^[-*]{3,}$/gm, ''); + + // Clean up whitespace + text = text.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim(); + + return text; + } + + /** + * Count words in markdown text + */ + countWords(markdown: string): number { + const plainText = this.extractText(markdown); + if (!plainText.trim()) return 0; + return plainText.trim().split(/\s+/).length; + } + + /** + * Count characters in markdown text + */ + countCharacters(markdown: string): number { + return this.extractText(markdown).length; + } + + /** + * Generate table of contents from headers + */ + generateTOC(markdown: string): Array<{level: number, text: string, anchor: string}> { + const headers: Array<{level: number, text: string, anchor: string}> = []; + const lines = markdown.split('\n'); + + lines.forEach(line => { + const headerMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headerMatch) { + const level = headerMatch[1].length; + const text = headerMatch[2].trim(); + const anchor = text.toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + headers.push({ level, text, anchor }); + } + }); + + return headers; + } +} + +// Convenience functions for global use +export function parseMarkdown(markdown: string, options?: MarkdownParseOptions): string { + const parser = new SimpleMarkdownParser(options); + return parser.parse(markdown); +} + +export function extractTextFromMarkdown(markdown: string): string { + const parser = new SimpleMarkdownParser(); + return parser.extractText(markdown); +} + +export function countWordsInMarkdown(markdown: string): number { + const parser = new SimpleMarkdownParser(); + return parser.countWords(markdown); +} + +export function countCharactersInMarkdown(markdown: string): number { + const parser = new SimpleMarkdownParser(); + return parser.countCharacters(markdown); +} + +export function generateMarkdownTOC(markdown: string): Array<{level: number, text: string, anchor: string}> { + const parser = new SimpleMarkdownParser(); + return parser.generateTOC(markdown); +} \ No newline at end of file diff --git a/src/utils/nextcloud.ts b/src/utils/nextcloud.ts new file mode 100644 index 0000000..51f1a8d --- /dev/null +++ b/src/utils/nextcloud.ts @@ -0,0 +1,400 @@ +// src/utils/nextcloud.ts +import { promises as fs } from 'fs'; +import path from 'path'; +import crypto from 'crypto'; + +interface NextcloudConfig { + endpoint: string; + username: string; + password: string; + uploadPath: string; + publicBaseUrl: string; +} + +interface UploadResult { + success: boolean; + url?: string; + filename?: string; + error?: string; + size?: number; +} + +interface FileValidation { + valid: boolean; + error?: string; + sanitizedName?: string; +} + +export class NextcloudUploader { + private config: NextcloudConfig; + private allowedTypes: Set; + private maxFileSize: number; // in bytes + + constructor() { + this.config = { + endpoint: process.env.NEXTCLOUD_ENDPOINT || '', + username: process.env.NEXTCLOUD_USERNAME || '', + password: process.env.NEXTCLOUD_PASSWORD || '', + uploadPath: process.env.NEXTCLOUD_UPLOAD_PATH || '/kb-media', + publicBaseUrl: process.env.NEXTCLOUD_PUBLIC_URL || '' + }; + + // Allowed file types for knowledge base + this.allowedTypes = new Set([ + // Images + 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', + // Videos + 'video/mp4', 'video/webm', 'video/ogg', 'video/avi', 'video/mov', + // Documents + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + // Text files + 'text/plain', 'text/csv', 'application/json', + // Archives (for tool downloads) + 'application/zip', 'application/x-tar', 'application/gzip' + ]); + + this.maxFileSize = 50 * 1024 * 1024; // 50MB + } + + /** + * Check if Nextcloud upload is properly configured + */ + isConfigured(): boolean { + return !!(this.config.endpoint && + this.config.username && + this.config.password && + this.config.publicBaseUrl); + } + + /** + * Validate file before upload + */ + private validateFile(file: File): FileValidation { + // Check file size + if (file.size > this.maxFileSize) { + return { + valid: false, + error: `File too large (max ${Math.round(this.maxFileSize / 1024 / 1024)}MB)` + }; + } + + // Check file type + if (!this.allowedTypes.has(file.type)) { + return { + valid: false, + error: `File type not allowed: ${file.type}` + }; + } + + // Sanitize filename + const sanitizedName = this.sanitizeFilename(file.name); + if (!sanitizedName) { + return { + valid: false, + error: 'Invalid filename' + }; + } + + return { + valid: true, + sanitizedName + }; + } + + /** + * Sanitize filename for safe storage + */ + private sanitizeFilename(filename: string): string { + // Remove or replace unsafe characters + const sanitized = filename + .replace(/[^a-zA-Z0-9._-]/g, '_') // Replace unsafe chars with underscore + .replace(/_{2,}/g, '_') // Replace multiple underscores with single + .replace(/^_|_$/g, '') // Remove leading/trailing underscores + .toLowerCase(); + + // Ensure reasonable length + if (sanitized.length > 100) { + const ext = path.extname(sanitized); + const base = path.basename(sanitized, ext).substring(0, 90); + return base + ext; + } + + return sanitized; + } + + /** + * Generate unique filename to prevent conflicts + */ + private generateUniqueFilename(originalName: string): string { + const timestamp = Date.now(); + const randomId = crypto.randomBytes(4).toString('hex'); + const ext = path.extname(originalName); + const base = path.basename(originalName, ext); + + return `${timestamp}_${randomId}_${base}${ext}`; + } + + /** + * Upload file to Nextcloud + */ + async uploadFile(file: File, category: string = 'general'): Promise { + try { + if (!this.isConfigured()) { + return { + success: false, + error: 'Nextcloud not configured' + }; + } + + // Validate file + const validation = this.validateFile(file); + if (!validation.valid) { + return { + success: false, + error: validation.error + }; + } + + // Generate unique filename + const uniqueFilename = this.generateUniqueFilename(validation.sanitizedName!); + + // Create category-based path + const categoryPath = this.sanitizeFilename(category); + const remotePath = `${this.config.uploadPath}/${categoryPath}/${uniqueFilename}`; + + // Convert file to buffer + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Upload to Nextcloud via WebDAV + const uploadUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`; + + const response = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`, + 'Content-Type': file.type, + 'Content-Length': buffer.length.toString() + }, + body: buffer + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.status} ${response.statusText}`); + } + + // Generate public URL + const publicUrl = await this.createPublicLink(remotePath); + + return { + success: true, + url: publicUrl, + filename: uniqueFilename, + size: file.size + }; + + } catch (error) { + console.error('Nextcloud upload error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Upload failed' + }; + } + } + + /** + * Create a public share link for the uploaded file + */ + private async createPublicLink(remotePath: string): Promise { + try { + // Use Nextcloud's share API to create public link + const shareUrl = `${this.config.endpoint}/ocs/v2.php/apps/files_sharing/api/v1/shares`; + + const formData = new FormData(); + formData.append('path', remotePath); + formData.append('shareType', '3'); // Public link + formData.append('permissions', '1'); // Read only + + const response = await fetch(shareUrl, { + method: 'POST', + headers: { + 'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`, + 'OCS-APIRequest': 'true' + }, + body: formData + }); + + if (!response.ok) { + throw new Error('Failed to create public link'); + } + + const text = await response.text(); + // Parse XML response to extract share URL + const urlMatch = text.match(/(.*?)<\/url>/); + if (urlMatch) { + return urlMatch[1]; + } + + // Fallback to direct URL construction + return `${this.config.publicBaseUrl}${remotePath}`; + + } catch (error) { + console.warn('Failed to create public link, using direct URL:', error); + // Fallback to direct URL + return `${this.config.publicBaseUrl}${remotePath}`; + } + } + + /** + * Delete file from Nextcloud + */ + async deleteFile(remotePath: string): Promise<{ success: boolean; error?: string }> { + try { + if (!this.isConfigured()) { + return { success: false, error: 'Nextcloud not configured' }; + } + + const deleteUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`; + + const response = await fetch(deleteUrl, { + method: 'DELETE', + headers: { + 'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}` + } + }); + + if (response.ok || response.status === 404) { + return { success: true }; + } + + throw new Error(`Delete failed: ${response.status} ${response.statusText}`); + + } catch (error) { + console.error('Nextcloud delete error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Delete failed' + }; + } + } + + /** + * Check Nextcloud connectivity and authentication + */ + async testConnection(): Promise<{ success: boolean; error?: string; details?: any }> { + try { + if (!this.isConfigured()) { + return { + success: false, + error: 'Nextcloud not configured', + details: { + hasEndpoint: !!this.config.endpoint, + hasUsername: !!this.config.username, + hasPassword: !!this.config.password, + hasPublicUrl: !!this.config.publicBaseUrl + } + }; + } + + // Test with a simple WebDAV request + const testUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}/`; + + const response = await fetch(testUrl, { + method: 'PROPFIND', + headers: { + 'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`, + 'Depth': '0' + } + }); + + if (response.ok) { + return { + success: true, + details: { + endpoint: this.config.endpoint, + username: this.config.username, + uploadPath: this.config.uploadPath + } + }; + } + + throw new Error(`Connection failed: ${response.status} ${response.statusText}`); + + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Connection test failed' + }; + } + } + + /** + * Get file information from Nextcloud + */ + async getFileInfo(remotePath: string): Promise<{ success: boolean; info?: any; error?: string }> { + try { + const propfindUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`; + + const response = await fetch(propfindUrl, { + method: 'PROPFIND', + headers: { + 'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`, + 'Depth': '0' + } + }); + + if (response.ok) { + const text = await response.text(); + // Parse basic file info from WebDAV response + return { + success: true, + info: { + path: remotePath, + exists: true, + response: text.substring(0, 200) + '...' // Truncated for safety + } + }; + } + + if (response.status === 404) { + return { + success: true, + info: { + path: remotePath, + exists: false + } + }; + } + + throw new Error(`Failed to get file info: ${response.status}`); + + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get file info' + }; + } + } +} + +// Convenience functions for easy usage +export async function uploadToNextcloud(file: File, category: string = 'general'): Promise { + const uploader = new NextcloudUploader(); + return await uploader.uploadFile(file, category); +} + +export async function testNextcloudConnection(): Promise<{ success: boolean; error?: string; details?: any }> { + const uploader = new NextcloudUploader(); + return await uploader.testConnection(); +} + +export function isNextcloudConfigured(): boolean { + const uploader = new NextcloudUploader(); + return uploader.isConfigured(); +} \ No newline at end of file