iterate on contrib
This commit is contained in:
		
							parent
							
								
									f4acf39ca7
								
							
						
					
					
						commit
						3d42fcef79
					
				
							
								
								
									
										26
									
								
								.env.example
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								.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
 | 
			
		||||
# 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
 | 
			
		||||
@ -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' }
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										478
									
								
								src/pages/api/contribute/health.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										478
									
								
								src/pages/api/contribute/health.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<SystemHealth> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
    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' }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										467
									
								
								src/pages/api/contribute/knowledgebase.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										467
									
								
								src/pages/api/contribute/knowledgebase.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<string, boolean>;
 | 
			
		||||
    uploadedFiles: any[];
 | 
			
		||||
  };
 | 
			
		||||
  metadata: {
 | 
			
		||||
    submitter: string;
 | 
			
		||||
    reason?: string;
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Rate limiting (same pattern as tool contributions)
 | 
			
		||||
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
 | 
			
		||||
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<void> {
 | 
			
		||||
    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' }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										361
									
								
								src/pages/api/upload/media.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										361
									
								
								src/pages/api/upload/media.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<string, { count: number; resetTime: number }>();
 | 
			
		||||
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<UploadResult> {
 | 
			
		||||
  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<UploadResult> {
 | 
			
		||||
  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' }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
@ -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 @@
 | 
			
		||||
<html>
 | 
			
		||||
<head>
 | 
			
		||||
  <title>Processing Authentication...</title>
 | 
			
		||||
  <style>
 | 
			
		||||
    body {
 | 
			
		||||
      font-family: system-ui, -apple-system, sans-serif;
 | 
			
		||||
      background: var(--color-bg, #ffffff);
 | 
			
		||||
      color: var(--color-text, #000000);
 | 
			
		||||
      margin: 0;
 | 
			
		||||
      padding: 0;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    .container {
 | 
			
		||||
      text-align: center;
 | 
			
		||||
      padding: 4rem 2rem;
 | 
			
		||||
      max-width: 500px;
 | 
			
		||||
      margin: 0 auto;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    .spinner {
 | 
			
		||||
      width: 40px;
 | 
			
		||||
      height: 40px;
 | 
			
		||||
      border: 4px solid #f3f3f3;
 | 
			
		||||
      border-top: 4px solid #3498db;
 | 
			
		||||
      border-radius: 50%;
 | 
			
		||||
      animation: spin 1s linear infinite;
 | 
			
		||||
      margin: 0 auto 1rem;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    @keyframes spin {
 | 
			
		||||
      0% { transform: rotate(0deg); }
 | 
			
		||||
      100% { transform: rotate(360deg); }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    .error {
 | 
			
		||||
      color: #e74c3c;
 | 
			
		||||
      background: #fdf2f2;
 | 
			
		||||
      padding: 1rem;
 | 
			
		||||
      border-radius: 0.5rem;
 | 
			
		||||
      border: 1px solid #e74c3c;
 | 
			
		||||
      margin-top: 1rem;
 | 
			
		||||
    }
 | 
			
		||||
  </style>
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
  <div style="text-align: center; padding: 4rem; font-family: sans-serif;">
 | 
			
		||||
  <div class="container">
 | 
			
		||||
    <div class="spinner"></div>
 | 
			
		||||
    <h2>Processing authentication...</h2>
 | 
			
		||||
    <p>Please wait while we complete your login.</p>
 | 
			
		||||
    <div id="error-message" style="display: none;" class="error"></div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <script>
 | 
			
		||||
    // Get URL parameters from client-side
 | 
			
		||||
    const urlParams = new URLSearchParams(window.location.search);
 | 
			
		||||
    const code = urlParams.get('code');
 | 
			
		||||
    const state = urlParams.get('state');
 | 
			
		||||
    const error = urlParams.get('error');
 | 
			
		||||
    
 | 
			
		||||
    console.log('Client-side callback params:', { code: !!code, state: !!state, error });
 | 
			
		||||
    
 | 
			
		||||
    if (error) {
 | 
			
		||||
      window.location.href = '/?auth=error';
 | 
			
		||||
    } else if (code && state) {
 | 
			
		||||
      // Send the parameters to our API endpoint
 | 
			
		||||
      fetch('/api/auth/process', {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Content-Type': 'application/json',
 | 
			
		||||
        },
 | 
			
		||||
        body: JSON.stringify({ code, state })
 | 
			
		||||
      })
 | 
			
		||||
      .then(response => response.json())
 | 
			
		||||
      .then(data => {
 | 
			
		||||
        if (data.success) {
 | 
			
		||||
          window.location.href = data.redirectTo || '/';
 | 
			
		||||
        } else {
 | 
			
		||||
          window.location.href = '/?auth=error';
 | 
			
		||||
    (function() {
 | 
			
		||||
      // Get URL parameters from client-side
 | 
			
		||||
      const urlParams = new URLSearchParams(window.location.search);
 | 
			
		||||
      const code = urlParams.get('code');
 | 
			
		||||
      const state = urlParams.get('state');
 | 
			
		||||
      const error = urlParams.get('error');
 | 
			
		||||
      
 | 
			
		||||
      console.log('Client-side callback params:', { code: !!code, state: !!state, error });
 | 
			
		||||
      
 | 
			
		||||
      const errorDiv = document.getElementById('error-message') as HTMLElement;
 | 
			
		||||
      
 | 
			
		||||
      if (error) {
 | 
			
		||||
        if (errorDiv) {
 | 
			
		||||
          errorDiv.textContent = `Authentication error: ${error}`;
 | 
			
		||||
          errorDiv.style.display = 'block';
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
      .catch(error => {
 | 
			
		||||
        console.error('Authentication processing failed:', error);
 | 
			
		||||
        window.location.href = '/?auth=error';
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
      console.error('Missing code or state parameters');
 | 
			
		||||
      window.location.href = '/?auth=error';
 | 
			
		||||
    }
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
          window.location.href = '/?auth=error';
 | 
			
		||||
        }, 3000);
 | 
			
		||||
      } else if (code && state) {
 | 
			
		||||
        // Send the parameters to our API endpoint
 | 
			
		||||
        fetch('/api/auth/process', {
 | 
			
		||||
          method: 'POST',
 | 
			
		||||
          headers: {
 | 
			
		||||
            'Content-Type': 'application/json',
 | 
			
		||||
          },
 | 
			
		||||
          body: JSON.stringify({ code, state })
 | 
			
		||||
        })
 | 
			
		||||
        .then(response => {
 | 
			
		||||
          if (!response.ok) {
 | 
			
		||||
            throw new Error(`HTTP ${response.status}`);
 | 
			
		||||
          }
 | 
			
		||||
          return response.json();
 | 
			
		||||
        })
 | 
			
		||||
        .then(data => {
 | 
			
		||||
          if (data.success) {
 | 
			
		||||
            window.location.href = data.redirectTo || '/';
 | 
			
		||||
          } else {
 | 
			
		||||
            throw new Error(data.error || 'Authentication failed');
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
        .catch(error => {
 | 
			
		||||
          console.error('Authentication processing failed:', error);
 | 
			
		||||
          if (errorDiv) {
 | 
			
		||||
            errorDiv.textContent = `Authentication failed: ${error.message}`;
 | 
			
		||||
            errorDiv.style.display = 'block';
 | 
			
		||||
          }
 | 
			
		||||
          setTimeout(() => {
 | 
			
		||||
            window.location.href = '/?auth=error';
 | 
			
		||||
          }, 3000);
 | 
			
		||||
        });
 | 
			
		||||
      } else {
 | 
			
		||||
        console.error('Missing code or state parameters');
 | 
			
		||||
        if (errorDiv) {
 | 
			
		||||
          errorDiv.textContent = 'Missing authentication parameters';
 | 
			
		||||
          errorDiv.style.display = 'block';
 | 
			
		||||
        }
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
          window.location.href = '/?auth=error';
 | 
			
		||||
        }, 3000);
 | 
			
		||||
      }
 | 
			
		||||
    })();
 | 
			
		||||
  </script>
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
@ -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');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<BaseLayout title="Contribute" description="Contribute tools, methods, concepts, and knowledge articles to CC24-Guide">
 | 
			
		||||
@ -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.
 | 
			
		||||
      </p>
 | 
			
		||||
      {userEmail && (
 | 
			
		||||
        <p style="margin-top: 1rem; opacity: 0.8; font-size: 0.9rem;">
 | 
			
		||||
          Logged in as: <strong>{userEmail}</strong>
 | 
			
		||||
        </p>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Contribution Options -->
 | 
			
		||||
@ -126,6 +148,12 @@ if (authRedirect) return authRedirect;
 | 
			
		||||
              </svg>
 | 
			
		||||
              Report Issue
 | 
			
		||||
            </a>
 | 
			
		||||
            <a href="/api/contribute/health" class="btn btn-secondary" target="_blank">
 | 
			
		||||
              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
                <path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              System Health
 | 
			
		||||
            </a>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
@ -143,16 +171,18 @@ if (authRedirect) return authRedirect;
 | 
			
		||||
            <li>Use clear, professional language</li>
 | 
			
		||||
            <li>Include relevant tags and categorization</li>
 | 
			
		||||
            <li>Verify all URLs and links work correctly</li>
 | 
			
		||||
            <li>Test installation and configuration steps</li>
 | 
			
		||||
          </ul>
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
        <div>
 | 
			
		||||
          <h4 style="margin-bottom: 0.75rem; color: var(--color-accent);">Review Process</h4>
 | 
			
		||||
          <ul style="margin: 0; padding-left: 1.5rem; line-height: 1.6;">
 | 
			
		||||
            <li>All contributions create pull requests</li>
 | 
			
		||||
            <li>Maintainers review within 48-72 hours</li>
 | 
			
		||||
            <li>Feedback provided for requested changes</li>
 | 
			
		||||
            <li>Approved changes merged automatically</li>
 | 
			
		||||
            <li>All contributions are submitted as pull requests</li>
 | 
			
		||||
            <li>Automated validation checks run on submissions</li>
 | 
			
		||||
            <li>Manual review by CC24 team members</li>
 | 
			
		||||
            <li>Feedback provided through PR comments</li>
 | 
			
		||||
            <li>Merge after approval and testing</li>
 | 
			
		||||
          </ul>
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
@ -160,34 +190,116 @@ if (authRedirect) return authRedirect;
 | 
			
		||||
          <h4 style="margin-bottom: 0.75rem; color: var(--color-warning);">Best Practices</h4>
 | 
			
		||||
          <ul style="margin: 0; padding-left: 1.5rem; line-height: 1.6;">
 | 
			
		||||
            <li>Search existing entries before adding duplicates</li>
 | 
			
		||||
            <li>Include rationale for new additions</li>
 | 
			
		||||
            <li>Follow existing categorization patterns</li>
 | 
			
		||||
            <li>Test tools/methods before recommending</li>
 | 
			
		||||
            <li>Use consistent naming and categorization</li>
 | 
			
		||||
            <li>Provide detailed descriptions and use cases</li>
 | 
			
		||||
            <li>Include screenshots for complex procedures</li>
 | 
			
		||||
            <li>Credit original sources and authors</li>
 | 
			
		||||
          </ul>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Statistics -->
 | 
			
		||||
    <div style="text-align: center; padding: 1.5rem; background-color: var(--color-bg-secondary); border-radius: 0.75rem;">
 | 
			
		||||
      <p class="text-muted" style="margin: 0; font-size: 0.9375rem;">
 | 
			
		||||
        <strong>Community Contributions:</strong> Help us maintain the most comprehensive DFIR resource available.
 | 
			
		||||
        <br>
 | 
			
		||||
        Your contributions are credited and help the entire forensics community.
 | 
			
		||||
      </p>
 | 
			
		||||
    <!-- System Status -->
 | 
			
		||||
    <div class="card" style="background-color: var(--color-bg-secondary);">
 | 
			
		||||
      <h3 style="margin-bottom: 1rem; color: var(--color-text);">System Status</h3>
 | 
			
		||||
      <div id="system-status" style="display: flex; align-items: center; gap: 1rem;">
 | 
			
		||||
        <div style="width: 12px; height: 12px; background-color: var(--color-text-secondary); border-radius: 50%; animation: pulse 2s infinite;"></div>
 | 
			
		||||
        <span style="color: var(--color-text-secondary);">Checking system health...</span>
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
      <div style="margin-top: 1rem; font-size: 0.875rem; color: var(--color-text-secondary);">
 | 
			
		||||
        <p style="margin: 0;">
 | 
			
		||||
          <strong>Features Available:</strong> Tool contributions, knowledgebase articles, media uploads
 | 
			
		||||
        </p>
 | 
			
		||||
        <p style="margin: 0.5rem 0 0 0;">
 | 
			
		||||
          <strong>Storage:</strong> Local + Nextcloud (if configured) | 
 | 
			
		||||
          <strong>Authentication:</strong> {authRequired ? 'Required' : 'Disabled'} |
 | 
			
		||||
          <strong>Rate Limiting:</strong> Active
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </section>
 | 
			
		||||
</BaseLayout>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
  .card:hover {
 | 
			
		||||
    transform: translateY(-2px);
 | 
			
		||||
    box-shadow: var(--shadow-lg);
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  @media (width <= 768px) {
 | 
			
		||||
    div[style*="grid-template-columns: 2fr 1fr"] {
 | 
			
		||||
      grid-template-columns: 1fr !important;
 | 
			
		||||
  <style>
 | 
			
		||||
    .card:hover {
 | 
			
		||||
      transform: translateY(-2px);
 | 
			
		||||
      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
    @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;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  </style>
 | 
			
		||||
 | 
			
		||||
  <script>
 | 
			
		||||
    // Check system health on page load
 | 
			
		||||
    document.addEventListener('DOMContentLoaded', async function() {
 | 
			
		||||
      const statusEl = document.getElementById('system-status');
 | 
			
		||||
      
 | 
			
		||||
      if (!statusEl) return;
 | 
			
		||||
      
 | 
			
		||||
      try {
 | 
			
		||||
        const response = await fetch('/api/contribute/health');
 | 
			
		||||
        const health = await response.json();
 | 
			
		||||
        
 | 
			
		||||
        let statusColor = 'var(--color-success)';
 | 
			
		||||
        let statusText = 'All systems operational';
 | 
			
		||||
        
 | 
			
		||||
        if (health.overall === 'warning') {
 | 
			
		||||
          statusColor = 'var(--color-warning)';
 | 
			
		||||
          statusText = `${health.summary.warnings} warning(s) detected`;
 | 
			
		||||
        } else if (health.overall === 'error') {
 | 
			
		||||
          statusColor = 'var(--color-error)';
 | 
			
		||||
          statusText = `${health.summary.errors} error(s) detected`;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        statusEl.innerHTML = `
 | 
			
		||||
          <div style="width: 12px; height: 12px; background-color: ${statusColor}; border-radius: 50%;"></div>
 | 
			
		||||
          <span style="color: var(--color-text);">${statusText}</span>
 | 
			
		||||
          <a href="/api/contribute/health" target="_blank" style="color: var(--color-primary); text-decoration: underline; font-size: 0.875rem;">View Details</a>
 | 
			
		||||
        `;
 | 
			
		||||
        
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error('Health check failed:', error);
 | 
			
		||||
        statusEl.innerHTML = `
 | 
			
		||||
          <div style="width: 12px; height: 12px; background-color: var(--color-error); border-radius: 50%;"></div>
 | 
			
		||||
          <span style="color: var(--color-error);">Health check failed</span>
 | 
			
		||||
        `;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Add hover effects for cards
 | 
			
		||||
    document.querySelectorAll('.card[onclick]').forEach((card) => {
 | 
			
		||||
      const cardEl = card as HTMLElement;
 | 
			
		||||
      cardEl.addEventListener('mouseenter', function() {
 | 
			
		||||
        this.style.transform = 'translateY(-2px)';
 | 
			
		||||
        this.style.boxShadow = '0 8px 32px rgba(0, 0, 0, 0.12)';
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      cardEl.addEventListener('mouseleave', function() {
 | 
			
		||||
        this.style.transform = 'translateY(0)';
 | 
			
		||||
        this.style.boxShadow = '';
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  </script>
 | 
			
		||||
</BaseLayout>
 | 
			
		||||
							
								
								
									
										958
									
								
								src/pages/contribute/knowledgebase.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										958
									
								
								src/pages/contribute/knowledgebase.astro
									
									
									
									
									
										Normal file
									
								
							@ -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...
 | 
			
		||||
`
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<BaseLayout title="Contribute - Knowledgebase">
 | 
			
		||||
  <section style="max-width: 1200px; margin: 0 auto;">
 | 
			
		||||
    <!-- Header -->
 | 
			
		||||
    <header style="margin-bottom: 2rem; text-align: center;">
 | 
			
		||||
      <h1 style="margin-bottom: 1rem; color: var(--color-primary);">Write Knowledgebase Article</h1>
 | 
			
		||||
      <p class="text-muted" style="font-size: 1.125rem; max-width: 600px; margin: 0 auto;">
 | 
			
		||||
        Create detailed guides, tutorials, and documentation for forensic tools and methodologies.
 | 
			
		||||
      </p>
 | 
			
		||||
      {userEmail && (
 | 
			
		||||
        <p style="margin-top: 0.5rem; font-size: 0.875rem; color: var(--color-text-secondary);">
 | 
			
		||||
          Logged in as: <strong>{userEmail}</strong>
 | 
			
		||||
        </p>
 | 
			
		||||
      )}
 | 
			
		||||
    </header>
 | 
			
		||||
 | 
			
		||||
    <!-- Navigation -->
 | 
			
		||||
    <nav style="margin-bottom: 2rem;">
 | 
			
		||||
      <a href="/contribute" class="btn btn-secondary">
 | 
			
		||||
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
          <polyline points="15,18 9,12 15,6"></polyline>
 | 
			
		||||
        </svg>
 | 
			
		||||
        Back to Contribute
 | 
			
		||||
      </a>
 | 
			
		||||
    </nav>
 | 
			
		||||
 | 
			
		||||
    <!-- Main Form -->
 | 
			
		||||
    <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem;">
 | 
			
		||||
      <!-- Form Section -->
 | 
			
		||||
      <div class="card" style="padding: 2rem;">
 | 
			
		||||
        <form id="kb-form" style="display: flex; flex-direction: column; gap: 1.5rem;">
 | 
			
		||||
          <!-- Article Metadata -->
 | 
			
		||||
          <h3 style="margin: 0 0 1rem 0; color: var(--color-accent); border-bottom: 2px solid var(--color-accent); padding-bottom: 0.5rem;">
 | 
			
		||||
            Article Metadata
 | 
			
		||||
          </h3>
 | 
			
		||||
 | 
			
		||||
          <!-- Tool Selection -->
 | 
			
		||||
          <div>
 | 
			
		||||
            <label for="tool-select" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
              Associated Tool <span style="color: var(--color-error);">*</span>
 | 
			
		||||
            </label>
 | 
			
		||||
            <select id="tool-select" name="toolName" required>
 | 
			
		||||
              <option value="">Select a tool...</option>
 | 
			
		||||
              {tools.map((tool: any) => (
 | 
			
		||||
                <option value={tool.name} selected={toolName === tool.name}>
 | 
			
		||||
                  {tool.icon ? `${tool.icon} ` : ''}{tool.name}
 | 
			
		||||
                </option>
 | 
			
		||||
              ))}
 | 
			
		||||
            </select>
 | 
			
		||||
            <div class="field-help">Choose the tool this article is about</div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Title -->
 | 
			
		||||
          <div>
 | 
			
		||||
            <label for="article-title" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
              Article Title <span style="color: var(--color-error);">*</span>
 | 
			
		||||
            </label>
 | 
			
		||||
            <input type="text" id="article-title" name="title" required minlength="5" maxlength="100"
 | 
			
		||||
                   placeholder="e.g., Installing and Configuring Wireshark" />
 | 
			
		||||
            <div style="display: flex; justify-content: space-between; margin-top: 0.25rem;">
 | 
			
		||||
              <div class="field-help">Clear, descriptive title for your article</div>
 | 
			
		||||
              <div id="title-count" style="font-size: 0.75rem; color: var(--color-text-secondary);">0/100</div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Description -->
 | 
			
		||||
          <div>
 | 
			
		||||
            <label for="article-description" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
              Description <span style="color: var(--color-error);">*</span>
 | 
			
		||||
            </label>
 | 
			
		||||
            <textarea id="article-description" name="description" required rows="3" minlength="20" maxlength="300"
 | 
			
		||||
                      placeholder="Brief summary of what this article covers..."></textarea>
 | 
			
		||||
            <div style="display: flex; justify-content: space-between; margin-top: 0.25rem;">
 | 
			
		||||
              <div class="field-help">Brief summary for search results and listings</div>
 | 
			
		||||
              <div id="description-count" style="font-size: 0.75rem; color: var(--color-text-secondary);">0/300</div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Template Selection -->
 | 
			
		||||
          <div>
 | 
			
		||||
            <label for="template-select" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
              Article Template
 | 
			
		||||
            </label>
 | 
			
		||||
            <select id="template-select" name="template">
 | 
			
		||||
              <option value="">Start from scratch</option>
 | 
			
		||||
              {Object.entries(templates).map(([key, template]) => (
 | 
			
		||||
                <option value={key}>{template.name}</option>
 | 
			
		||||
              ))}
 | 
			
		||||
            </select>
 | 
			
		||||
            <div class="field-help">Choose a template to get started quickly</div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Difficulty Level -->
 | 
			
		||||
          <div>
 | 
			
		||||
            <label for="difficulty-select" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
              Difficulty Level <span style="color: var(--color-error);">*</span>
 | 
			
		||||
            </label>
 | 
			
		||||
            <select id="difficulty-select" name="difficulty" required>
 | 
			
		||||
              <option value="">Select difficulty...</option>
 | 
			
		||||
              <option value="novice">Novice - No prior experience needed</option>
 | 
			
		||||
              <option value="beginner">Beginner - Basic computer skills required</option>
 | 
			
		||||
              <option value="intermediate">Intermediate - Some forensics knowledge</option>
 | 
			
		||||
              <option value="advanced">Advanced - Experienced practitioners</option>
 | 
			
		||||
              <option value="expert">Expert - Deep technical expertise required</option>
 | 
			
		||||
            </select>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Categories and Tags -->
 | 
			
		||||
          <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
 | 
			
		||||
            <div>
 | 
			
		||||
              <label for="categories" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
                Categories
 | 
			
		||||
              </label>
 | 
			
		||||
              <input type="text" id="categories" name="categories" 
 | 
			
		||||
                     placeholder="Installation, Tutorial, Configuration..."
 | 
			
		||||
                     title="Comma-separated categories" />
 | 
			
		||||
              <div class="field-help">Comma-separated (e.g., Installation, Guide)</div>
 | 
			
		||||
            </div>
 | 
			
		||||
            
 | 
			
		||||
            <div>
 | 
			
		||||
              <label for="tags" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
                Tags
 | 
			
		||||
              </label>
 | 
			
		||||
              <input type="text" id="tags" name="tags" 
 | 
			
		||||
                     placeholder="forensics, network, analysis..."
 | 
			
		||||
                     title="Comma-separated tags" />
 | 
			
		||||
              <div class="field-help">Comma-separated keywords</div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Content Sections -->
 | 
			
		||||
          <div>
 | 
			
		||||
            <label style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
 | 
			
		||||
              Content Sections
 | 
			
		||||
            </label>
 | 
			
		||||
            <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem;">
 | 
			
		||||
              <label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem;">
 | 
			
		||||
                <input type="checkbox" name="sections" value="overview" checked>
 | 
			
		||||
                Overview
 | 
			
		||||
              </label>
 | 
			
		||||
              <label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem;">
 | 
			
		||||
                <input type="checkbox" name="sections" value="installation">
 | 
			
		||||
                Installation
 | 
			
		||||
              </label>
 | 
			
		||||
              <label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem;">
 | 
			
		||||
                <input type="checkbox" name="sections" value="configuration">
 | 
			
		||||
                Configuration
 | 
			
		||||
              </label>
 | 
			
		||||
              <label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem;">
 | 
			
		||||
                <input type="checkbox" name="sections" value="usage_examples" checked>
 | 
			
		||||
                Usage Examples
 | 
			
		||||
              </label>
 | 
			
		||||
              <label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem;">
 | 
			
		||||
                <input type="checkbox" name="sections" value="best_practices" checked>
 | 
			
		||||
                Best Practices
 | 
			
		||||
              </label>
 | 
			
		||||
              <label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem;">
 | 
			
		||||
                <input type="checkbox" name="sections" value="troubleshooting">
 | 
			
		||||
                Troubleshooting
 | 
			
		||||
              </label>
 | 
			
		||||
              <label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem;">
 | 
			
		||||
                <input type="checkbox" name="sections" value="advanced_topics">
 | 
			
		||||
                Advanced Topics
 | 
			
		||||
              </label>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="field-help">Select which sections your article will include</div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Media Upload Section -->
 | 
			
		||||
          <div>
 | 
			
		||||
            <h4 style="margin: 1rem 0 0.5rem 0; color: var(--color-text);">Media Files</h4>
 | 
			
		||||
            <div id="media-upload" style="border: 2px dashed var(--color-border); border-radius: 0.5rem; padding: 2rem; text-align: center; background-color: var(--color-bg-secondary); cursor: pointer; transition: var(--transition-fast);">
 | 
			
		||||
              <input type="file" id="media-input" multiple accept="image/*,video/*,.pdf,.doc,.docx" style="display: none;">
 | 
			
		||||
              <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" stroke-width="1.5" style="margin: 0 auto 1rem;">
 | 
			
		||||
                <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
 | 
			
		||||
                <polyline points="14 2 14 8 20 8"/>
 | 
			
		||||
                <line x1="12" y1="11" x2="12" y2="17"/>
 | 
			
		||||
                <polyline points="9 14 12 11 15 14"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              <p style="margin: 0; color: var(--color-text-secondary);">Click to upload or drag files here</p>
 | 
			
		||||
              <p style="margin: 0.5rem 0 0 0; font-size: 0.875rem; color: var(--color-text-tertiary);">
 | 
			
		||||
                Images, videos, PDFs, and documents
 | 
			
		||||
              </p>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div id="uploaded-files" style="margin-top: 1rem; display: none;">
 | 
			
		||||
              <h5 style="margin: 0 0 0.5rem 0; color: var(--color-text);">Uploaded Files</h5>
 | 
			
		||||
              <div id="files-list" style="display: flex; flex-direction: column; gap: 0.5rem;"></div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Action Buttons -->
 | 
			
		||||
          <div style="display: flex; gap: 1rem; margin-top: 2rem; border-top: 1px solid var(--color-border); padding-top: 1.5rem;">
 | 
			
		||||
            <button type="button" id="preview-btn" class="btn btn-secondary" style="flex: 1;">
 | 
			
		||||
              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
                <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
 | 
			
		||||
                <circle cx="12" cy="12" r="3"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Preview
 | 
			
		||||
            </button>
 | 
			
		||||
            <button type="submit" id="submit-btn" class="btn btn-accent" style="flex: 2;" disabled>
 | 
			
		||||
              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
                <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
 | 
			
		||||
                <polyline points="17 21v-8H7v8"/>
 | 
			
		||||
                <polyline points="7 3v5h8"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Submit Article
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </form>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <!-- Editor and Preview Section -->
 | 
			
		||||
      <div class="card" style="padding: 2rem; display: flex; flex-direction: column;">
 | 
			
		||||
        <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; border-bottom: 2px solid var(--color-accent); padding-bottom: 0.5rem;">
 | 
			
		||||
          <h3 style="margin: 0; color: var(--color-accent);">Content Editor</h3>
 | 
			
		||||
          <div style="display: flex; gap: 0.5rem;">
 | 
			
		||||
            <button id="editor-tab" class="btn btn-small" style="background-color: var(--color-accent); color: white;">
 | 
			
		||||
              Editor
 | 
			
		||||
            </button>
 | 
			
		||||
            <button id="preview-tab" class="btn btn-small btn-secondary">
 | 
			
		||||
              Preview
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <!-- Markdown Editor -->
 | 
			
		||||
        <div id="editor-section" style="flex: 1; display: flex; flex-direction: column;">
 | 
			
		||||
          <div style="margin-bottom: 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
 | 
			
		||||
            <button type="button" class="toolbar-btn" data-action="bold" title="Bold">
 | 
			
		||||
              <strong>B</strong>
 | 
			
		||||
            </button>
 | 
			
		||||
            <button type="button" class="toolbar-btn" data-action="italic" title="Italic">
 | 
			
		||||
              <em>I</em>
 | 
			
		||||
            </button>
 | 
			
		||||
            <button type="button" class="toolbar-btn" data-action="heading" title="Heading">
 | 
			
		||||
              H1
 | 
			
		||||
            </button>
 | 
			
		||||
            <button type="button" class="toolbar-btn" data-action="link" title="Link">
 | 
			
		||||
              🔗
 | 
			
		||||
            </button>
 | 
			
		||||
            <button type="button" class="toolbar-btn" data-action="image" title="Image">
 | 
			
		||||
              🖼️
 | 
			
		||||
            </button>
 | 
			
		||||
            <button type="button" class="toolbar-btn" data-action="code" title="Code Block">
 | 
			
		||||
              </>
 | 
			
		||||
            </button>
 | 
			
		||||
            <button type="button" class="toolbar-btn" data-action="list" title="List">
 | 
			
		||||
              📝
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <textarea id="markdown-editor" name="content" 
 | 
			
		||||
                    placeholder="Write your article content in Markdown..."
 | 
			
		||||
                    style="flex: 1; min-height: 400px; font-family: 'Courier New', monospace; font-size: 0.9rem; line-height: 1.5; border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1rem; background-color: var(--color-bg); color: var(--color-text); resize: vertical;"></textarea>
 | 
			
		||||
 | 
			
		||||
          <div style="margin-top: 0.5rem; display: flex; justify-content: space-between; align-items: center; font-size: 0.875rem; color: var(--color-text-secondary);">
 | 
			
		||||
            <div>Supports full Markdown syntax</div>
 | 
			
		||||
            <div id="content-stats">Words: 0 | Characters: 0</div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <!-- Preview Section -->
 | 
			
		||||
        <div id="preview-section" style="flex: 1; display: none; flex-direction: column;">
 | 
			
		||||
          <div id="preview-content" style="flex: 1; min-height: 400px; border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1rem; background-color: var(--color-bg); overflow-y: auto;">
 | 
			
		||||
            <p class="text-muted" style="text-align: center; margin-top: 2rem;">Start writing to see preview...</p>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Success/Error Messages -->
 | 
			
		||||
    <div id="form-messages" style="position: fixed; top: 1rem; right: 1rem; z-index: 1000; max-width: 400px;"></div>
 | 
			
		||||
  </section>
 | 
			
		||||
 | 
			
		||||
  <!-- Load templates as JSON for JavaScript -->
 | 
			
		||||
  <script type="application/json" id="article-templates">
 | 
			
		||||
    {JSON.stringify(templates)}
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <style>
 | 
			
		||||
    .toolbar-btn {
 | 
			
		||||
      background: var(--color-bg-secondary);
 | 
			
		||||
      border: 1px solid var(--color-border);
 | 
			
		||||
      border-radius: 0.25rem;
 | 
			
		||||
      padding: 0.375rem 0.75rem;
 | 
			
		||||
      font-size: 0.875rem;
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
      transition: var(--transition-fast);
 | 
			
		||||
      color: var(--color-text);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .toolbar-btn:hover {
 | 
			
		||||
      background-color: var(--color-bg-tertiary);
 | 
			
		||||
      border-color: var(--color-primary);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .toolbar-btn:active {
 | 
			
		||||
      background-color: var(--color-primary);
 | 
			
		||||
      color: white;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #media-upload:hover {
 | 
			
		||||
      border-color: var(--color-primary);
 | 
			
		||||
      background-color: var(--color-bg);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .file-item {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      justify-content: space-between;
 | 
			
		||||
      padding: 0.75rem;
 | 
			
		||||
      background-color: var(--color-bg);
 | 
			
		||||
      border: 1px solid var(--color-border);
 | 
			
		||||
      border-radius: 0.5rem;
 | 
			
		||||
      font-size: 0.875rem;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .file-item .file-info {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      gap: 0.5rem;
 | 
			
		||||
      flex: 1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .file-item .file-actions {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      gap: 0.5rem;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .file-item .file-actions button {
 | 
			
		||||
      background: none;
 | 
			
		||||
      border: none;
 | 
			
		||||
      color: var(--color-text-secondary);
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
      padding: 0.25rem;
 | 
			
		||||
      border-radius: 0.25rem;
 | 
			
		||||
      transition: var(--transition-fast);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .file-item .file-actions button:hover {
 | 
			
		||||
      color: var(--color-primary);
 | 
			
		||||
      background-color: var(--color-bg-secondary);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .loading {
 | 
			
		||||
      opacity: 0.7;
 | 
			
		||||
      pointer-events: none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @media (max-width: 768px) {
 | 
			
		||||
      div[style*="grid-template-columns: 1fr 1fr"] {
 | 
			
		||||
        display: block !important;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      div[style*="grid-template-columns: 1fr 1fr"] > * {
 | 
			
		||||
        margin-bottom: 2rem;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  </style>
 | 
			
		||||
 | 
			
		||||
  <script>
 | 
			
		||||
    // Import templates with null safety
 | 
			
		||||
    const templatesEl = document.getElementById('article-templates');
 | 
			
		||||
    const templates = templatesEl ? JSON.parse(templatesEl.textContent || '{}') : {};
 | 
			
		||||
    
 | 
			
		||||
    // Form elements with null checks
 | 
			
		||||
    const form = document.getElementById('kb-form') as HTMLFormElement | null;
 | 
			
		||||
    const toolSelect = document.getElementById('tool-select') as HTMLSelectElement | null;
 | 
			
		||||
    const titleInput = document.getElementById('article-title') as HTMLInputElement | null;
 | 
			
		||||
    const descriptionInput = document.getElementById('article-description') as HTMLTextAreaElement | null;
 | 
			
		||||
    const templateSelect = document.getElementById('template-select') as HTMLSelectElement | null;
 | 
			
		||||
    const markdownEditor = document.getElementById('markdown-editor') as HTMLTextAreaElement | null;
 | 
			
		||||
    const previewContent = document.getElementById('preview-content') as HTMLElement | null;
 | 
			
		||||
    const submitBtn = document.getElementById('submit-btn') as HTMLButtonElement | null;
 | 
			
		||||
    const editorTab = document.getElementById('editor-tab') as HTMLButtonElement | null;
 | 
			
		||||
    const previewTab = document.getElementById('preview-tab') as HTMLButtonElement | null;
 | 
			
		||||
    const editorSection = document.getElementById('editor-section') as HTMLElement | null;
 | 
			
		||||
    const previewSection = document.getElementById('preview-section') as HTMLElement | null;
 | 
			
		||||
    const mediaUpload = document.getElementById('media-upload') as HTMLElement | null;
 | 
			
		||||
    const mediaInput = document.getElementById('media-input') as HTMLInputElement | null;
 | 
			
		||||
    const uploadedFiles = document.getElementById('uploaded-files') as HTMLElement | null;
 | 
			
		||||
    const filesList = document.getElementById('files-list') as HTMLElement | null;
 | 
			
		||||
    
 | 
			
		||||
    // Character counters
 | 
			
		||||
    const titleCount = document.getElementById('title-count') as HTMLElement | null;
 | 
			
		||||
    const descriptionCount = document.getElementById('description-count') as HTMLElement | null;
 | 
			
		||||
    const contentStats = document.getElementById('content-stats') as HTMLElement | null;
 | 
			
		||||
 | 
			
		||||
    // Uploaded files tracking
 | 
			
		||||
    interface UploadedFile {
 | 
			
		||||
      id: string;
 | 
			
		||||
      file: File;
 | 
			
		||||
      name: string;
 | 
			
		||||
      size: string;
 | 
			
		||||
      type: string;
 | 
			
		||||
      uploaded: boolean;
 | 
			
		||||
      url: string | null;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    let uploadedFilesList: UploadedFile[] = [];
 | 
			
		||||
 | 
			
		||||
    // Initialize
 | 
			
		||||
    document.addEventListener('DOMContentLoaded', function() {
 | 
			
		||||
      // Character counting with null checks
 | 
			
		||||
      if (titleInput && titleCount) {
 | 
			
		||||
        titleInput.addEventListener('input', () => {
 | 
			
		||||
          titleCount.textContent = `${titleInput.value.length}/100`;
 | 
			
		||||
          validateForm();
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      if (descriptionInput && descriptionCount) {
 | 
			
		||||
        descriptionInput.addEventListener('input', () => {
 | 
			
		||||
          descriptionCount.textContent = `${descriptionInput.value.length}/300`;
 | 
			
		||||
          validateForm();
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Content stats with null checks
 | 
			
		||||
      if (markdownEditor && contentStats) {
 | 
			
		||||
        markdownEditor.addEventListener('input', () => {
 | 
			
		||||
          const content = markdownEditor.value;
 | 
			
		||||
          const words = content.trim() ? content.trim().split(/\s+/).length : 0;
 | 
			
		||||
          const chars = content.length;
 | 
			
		||||
          contentStats.textContent = `Words: ${words} | Characters: ${chars}`;
 | 
			
		||||
          validateForm();
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Template selection with null checks
 | 
			
		||||
      if (templateSelect && markdownEditor && toolSelect) {
 | 
			
		||||
        templateSelect.addEventListener('change', () => {
 | 
			
		||||
          if (templateSelect.value && templates[templateSelect.value]) {
 | 
			
		||||
            const template = templates[templateSelect.value];
 | 
			
		||||
            const toolName = toolSelect.value || '{TOOL_NAME}';
 | 
			
		||||
            const content = template.content.replace(/{TOOL_NAME}/g, toolName);
 | 
			
		||||
            markdownEditor.value = content;
 | 
			
		||||
            
 | 
			
		||||
            // Update sections checkboxes
 | 
			
		||||
            const sectionCheckboxes = document.querySelectorAll('input[name="sections"]') as NodeListOf<HTMLInputElement>;
 | 
			
		||||
            sectionCheckboxes.forEach(cb => {
 | 
			
		||||
              cb.checked = template.sections.includes(cb.value);
 | 
			
		||||
            });
 | 
			
		||||
            
 | 
			
		||||
            markdownEditor.dispatchEvent(new Event('input'));
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Tool selection updates template
 | 
			
		||||
      if (toolSelect && templateSelect) {
 | 
			
		||||
        toolSelect.addEventListener('change', () => {
 | 
			
		||||
          if (templateSelect.value && toolSelect.value) {
 | 
			
		||||
            templateSelect.dispatchEvent(new Event('change'));
 | 
			
		||||
          }
 | 
			
		||||
          validateForm();
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Tab switching with null checks
 | 
			
		||||
      if (editorTab && previewTab && editorSection && previewSection) {
 | 
			
		||||
        editorTab.addEventListener('click', () => {
 | 
			
		||||
          editorTab.style.backgroundColor = 'var(--color-accent)';
 | 
			
		||||
          editorTab.style.color = 'white';
 | 
			
		||||
          previewTab.style.backgroundColor = 'var(--color-bg-secondary)';
 | 
			
		||||
          previewTab.style.color = 'var(--color-text)';
 | 
			
		||||
          editorSection.style.display = 'flex';
 | 
			
		||||
          previewSection.style.display = 'none';
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        previewTab.addEventListener('click', () => {
 | 
			
		||||
          previewTab.style.backgroundColor = 'var(--color-accent)';
 | 
			
		||||
          previewTab.style.color = 'white';
 | 
			
		||||
          editorTab.style.backgroundColor = 'var(--color-bg-secondary)';
 | 
			
		||||
          editorTab.style.color = 'var(--color-text)';
 | 
			
		||||
          editorSection.style.display = 'none';
 | 
			
		||||
          previewSection.style.display = 'flex';
 | 
			
		||||
          updatePreview();
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Toolbar actions with null checks
 | 
			
		||||
      document.querySelectorAll('.toolbar-btn').forEach((btn) => {
 | 
			
		||||
        const button = btn as HTMLButtonElement;
 | 
			
		||||
        button.addEventListener('click', () => {
 | 
			
		||||
          const action = button.dataset.action;
 | 
			
		||||
          if (action) {
 | 
			
		||||
            insertMarkdown(action);
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      // Media upload with null checks
 | 
			
		||||
      if (mediaUpload && mediaInput) {
 | 
			
		||||
        mediaUpload.addEventListener('click', () => mediaInput.click());
 | 
			
		||||
        mediaUpload.addEventListener('dragover', (e) => {
 | 
			
		||||
          e.preventDefault();
 | 
			
		||||
          mediaUpload.style.borderColor = 'var(--color-primary)';
 | 
			
		||||
        });
 | 
			
		||||
        mediaUpload.addEventListener('dragleave', () => {
 | 
			
		||||
          mediaUpload.style.borderColor = 'var(--color-border)';
 | 
			
		||||
        });
 | 
			
		||||
        mediaUpload.addEventListener('drop', (e) => {
 | 
			
		||||
          e.preventDefault();
 | 
			
		||||
          mediaUpload.style.borderColor = 'var(--color-border)';
 | 
			
		||||
          if (e.dataTransfer?.files) {
 | 
			
		||||
            handleFiles(e.dataTransfer.files);
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        mediaInput.addEventListener('change', (e) => {
 | 
			
		||||
          const target = e.target as HTMLInputElement;
 | 
			
		||||
          if (target.files) {
 | 
			
		||||
            handleFiles(target.files);
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Form submission with null checks
 | 
			
		||||
      if (form) {
 | 
			
		||||
        form.addEventListener('submit', handleSubmit);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Initial validation
 | 
			
		||||
      validateForm();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    function validateForm() {
 | 
			
		||||
      if (!toolSelect || !titleInput || !descriptionInput || !markdownEditor || !submitBtn) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      const difficultySelect = document.getElementById('difficulty-select') as HTMLSelectElement | null;
 | 
			
		||||
      if (!difficultySelect) return;
 | 
			
		||||
      
 | 
			
		||||
      const isValid = toolSelect.value && 
 | 
			
		||||
                     titleInput.value.length >= 5 && 
 | 
			
		||||
                     descriptionInput.value.length >= 20 &&
 | 
			
		||||
                     difficultySelect.value &&
 | 
			
		||||
                     markdownEditor.value.trim().length >= 50;
 | 
			
		||||
      
 | 
			
		||||
      submitBtn.disabled = !isValid;
 | 
			
		||||
      submitBtn.style.opacity = isValid ? '1' : '0.6';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function insertMarkdown(action: string) {
 | 
			
		||||
      if (!markdownEditor) return;
 | 
			
		||||
      
 | 
			
		||||
      const editor = markdownEditor;
 | 
			
		||||
      const start = editor.selectionStart;
 | 
			
		||||
      const end = editor.selectionEnd;
 | 
			
		||||
      const selectedText = editor.value.substring(start, end);
 | 
			
		||||
      let insertText = '';
 | 
			
		||||
 | 
			
		||||
      switch (action) {
 | 
			
		||||
        case 'bold':
 | 
			
		||||
          insertText = `**${selectedText || 'bold text'}**`;
 | 
			
		||||
          break;
 | 
			
		||||
        case 'italic':
 | 
			
		||||
          insertText = `*${selectedText || 'italic text'}*`;
 | 
			
		||||
          break;
 | 
			
		||||
        case 'heading':
 | 
			
		||||
          insertText = `## ${selectedText || 'Heading'}`;
 | 
			
		||||
          break;
 | 
			
		||||
        case 'link':
 | 
			
		||||
          insertText = `[${selectedText || 'link text'}](url)`;
 | 
			
		||||
          break;
 | 
			
		||||
        case 'image':
 | 
			
		||||
          insertText = ``;
 | 
			
		||||
          break;
 | 
			
		||||
        case 'code':
 | 
			
		||||
          insertText = selectedText ? `\`\`\`\n${selectedText}\n\`\`\`` : '```\ncode\n```';
 | 
			
		||||
          break;
 | 
			
		||||
        case 'list':
 | 
			
		||||
          insertText = selectedText ? selectedText.split('\n').map(line => `- ${line}`).join('\n') : '- List item';
 | 
			
		||||
          break;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      editor.value = editor.value.substring(0, start) + insertText + editor.value.substring(end);
 | 
			
		||||
      editor.focus();
 | 
			
		||||
      editor.setSelectionRange(start + insertText.length, start + insertText.length);
 | 
			
		||||
      editor.dispatchEvent(new Event('input'));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function updatePreview() {
 | 
			
		||||
      if (!markdownEditor || !previewContent) return;
 | 
			
		||||
      
 | 
			
		||||
      const content = markdownEditor.value;
 | 
			
		||||
      if (!content.trim()) {
 | 
			
		||||
        previewContent.innerHTML = '<p class="text-muted" style="text-align: center; margin-top: 2rem;">Start writing to see preview...</p>';
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Simple markdown parsing (in production, use a proper markdown parser)
 | 
			
		||||
      let html = content
 | 
			
		||||
        .replace(/^### (.*$)/gim, '<h3>$1</h3>')
 | 
			
		||||
        .replace(/^## (.*$)/gim, '<h2>$1</h2>')
 | 
			
		||||
        .replace(/^# (.*$)/gim, '<h1>$1</h1>')
 | 
			
		||||
        .replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
 | 
			
		||||
        .replace(/\*(.*)\*/gim, '<em>$1</em>')
 | 
			
		||||
        .replace(/\[([^\]]*)\]\(([^\)]*)\)/gim, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
 | 
			
		||||
        .replace(/!\[([^\]]*)\]\(([^\)]*)\)/gim, '<img src="$2" alt="$1" style="max-width: 100%; height: auto;">')
 | 
			
		||||
        .replace(/```([\s\S]*?)```/gim, '<pre><code>$1</code></pre>')
 | 
			
		||||
        .replace(/`([^`]*)`/gim, '<code>$1</code>')
 | 
			
		||||
        .replace(/^\* (.*$)/gim, '<li>$1</li>')
 | 
			
		||||
        .replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
 | 
			
		||||
        .replace(/\n\n/gim, '</p><p>')
 | 
			
		||||
        .replace(/\n/gim, '<br>');
 | 
			
		||||
 | 
			
		||||
      // Wrap in paragraphs
 | 
			
		||||
      html = '<p>' + html + '</p>';
 | 
			
		||||
      
 | 
			
		||||
      previewContent.innerHTML = html;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function handleFiles(files: FileList) {
 | 
			
		||||
      Array.from(files).forEach((file: File) => {
 | 
			
		||||
        if (file.size > 10 * 1024 * 1024) { // 10MB limit
 | 
			
		||||
          showMessage('error', `File ${file.name} is too large (max 10MB)`);
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const fileItem: UploadedFile = {
 | 
			
		||||
          id: (Date.now() + Math.random()).toString(),
 | 
			
		||||
          file: file,
 | 
			
		||||
          name: file.name,
 | 
			
		||||
          size: formatFileSize(file.size),
 | 
			
		||||
          type: file.type,
 | 
			
		||||
          uploaded: false,
 | 
			
		||||
          url: null
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        uploadedFilesList.push(fileItem);
 | 
			
		||||
        renderFilesList();
 | 
			
		||||
        uploadFile(fileItem);
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function renderFilesList() {
 | 
			
		||||
      if (!uploadedFiles || !filesList) return;
 | 
			
		||||
      
 | 
			
		||||
      if (uploadedFilesList.length > 0) {
 | 
			
		||||
        uploadedFiles.style.display = 'block';
 | 
			
		||||
        filesList.innerHTML = uploadedFilesList.map(file => `
 | 
			
		||||
          <div class="file-item" data-file-id="${file.id}">
 | 
			
		||||
            <div class="file-info">
 | 
			
		||||
              <span>${getFileIcon(file.type)}</span>
 | 
			
		||||
              <span style="font-weight: 500;">${file.name}</span>
 | 
			
		||||
              <span style="color: var(--color-text-secondary);">(${file.size})</span>
 | 
			
		||||
              ${file.uploaded ? '<span style="color: var(--color-success); font-size: 0.75rem;">✓ Uploaded</span>' : '<span style="color: var(--color-warning); font-size: 0.75rem;">⏳ Uploading...</span>'}
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="file-actions">
 | 
			
		||||
              ${file.uploaded ? `<button onclick="insertFileReference('${file.url}', '${file.name}', '${file.type}')" title="Insert into content">📝</button>` : ''}
 | 
			
		||||
              <button onclick="removeFile('${file.id}')" title="Remove">🗑️</button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        `).join('');
 | 
			
		||||
      } else {
 | 
			
		||||
        uploadedFiles.style.display = 'none';
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async function uploadFile(fileItem: UploadedFile) {
 | 
			
		||||
      const formData = new FormData();
 | 
			
		||||
      formData.append('file', fileItem.file);
 | 
			
		||||
      formData.append('type', 'knowledgebase');
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        const response = await fetch('/api/upload/media', {
 | 
			
		||||
          method: 'POST',
 | 
			
		||||
          body: formData
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (response.ok) {
 | 
			
		||||
          const result = await response.json();
 | 
			
		||||
          fileItem.uploaded = true;
 | 
			
		||||
          fileItem.url = result.url;
 | 
			
		||||
          renderFilesList();
 | 
			
		||||
        } else {
 | 
			
		||||
          throw new Error('Upload failed');
 | 
			
		||||
        }
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        showMessage('error', `Failed to upload ${fileItem.name}`);
 | 
			
		||||
        removeFile(fileItem.id);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function removeFile(fileId: string) {
 | 
			
		||||
      uploadedFilesList = uploadedFilesList.filter(f => f.id !== fileId);
 | 
			
		||||
      renderFilesList();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function insertFileReference(url: string, name: string, type: string) {
 | 
			
		||||
      if (!markdownEditor) return;
 | 
			
		||||
      
 | 
			
		||||
      let insertText = '';
 | 
			
		||||
      if (type.startsWith('image/')) {
 | 
			
		||||
        insertText = ``;
 | 
			
		||||
      } else {
 | 
			
		||||
        insertText = `[📎 ${name}](${url})`;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const editor = markdownEditor;
 | 
			
		||||
      const cursorPos = editor.selectionStart;
 | 
			
		||||
      editor.value = editor.value.substring(0, cursorPos) + insertText + editor.value.substring(cursorPos);
 | 
			
		||||
      editor.focus();
 | 
			
		||||
      editor.setSelectionRange(cursorPos + insertText.length, cursorPos + insertText.length);
 | 
			
		||||
      editor.dispatchEvent(new Event('input'));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function getFileIcon(type: string) {
 | 
			
		||||
      if (type.startsWith('image/')) return '🖼️';
 | 
			
		||||
      if (type.startsWith('video/')) return '🎥';
 | 
			
		||||
      if (type.includes('pdf')) return '📄';
 | 
			
		||||
      if (type.includes('document') || type.includes('word')) return '📝';
 | 
			
		||||
      return '📎';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function formatFileSize(bytes: number) {
 | 
			
		||||
      if (bytes === 0) return '0 Bytes';
 | 
			
		||||
      const k = 1024;
 | 
			
		||||
      const sizes = ['Bytes', 'KB', 'MB', 'GB'];
 | 
			
		||||
      const i = Math.floor(Math.log(bytes) / Math.log(k));
 | 
			
		||||
      return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async function handleSubmit(e: Event) {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      
 | 
			
		||||
      if (!submitBtn || !form || submitBtn.disabled) return;
 | 
			
		||||
      
 | 
			
		||||
      submitBtn.classList.add('loading');
 | 
			
		||||
      submitBtn.innerHTML = '⏳ Submitting...';
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        const formData = new FormData(form);
 | 
			
		||||
        
 | 
			
		||||
        // Collect sections
 | 
			
		||||
        const sections: Record<string, boolean> = {};
 | 
			
		||||
        document.querySelectorAll('input[name="sections"]:checked').forEach((checkbox) => {
 | 
			
		||||
          const cb = checkbox as HTMLInputElement;
 | 
			
		||||
          sections[cb.value] = true;
 | 
			
		||||
        });
 | 
			
		||||
        formData.set('sections', JSON.stringify(sections));
 | 
			
		||||
 | 
			
		||||
        // Process categories and tags
 | 
			
		||||
        const categoriesValue = formData.get('categories') as string || '';
 | 
			
		||||
        const tagsValue = formData.get('tags') as string || '';
 | 
			
		||||
        
 | 
			
		||||
        const categories = categoriesValue.split(',').map(s => s.trim()).filter(s => s);
 | 
			
		||||
        const tags = tagsValue.split(',').map(s => s.trim()).filter(s => s);
 | 
			
		||||
        formData.set('categories', JSON.stringify(categories));
 | 
			
		||||
        formData.set('tags', JSON.stringify(tags));
 | 
			
		||||
 | 
			
		||||
        // Add uploaded files
 | 
			
		||||
        formData.set('uploadedFiles', JSON.stringify(uploadedFilesList.filter(f => f.uploaded)));
 | 
			
		||||
 | 
			
		||||
        const response = await fetch('/api/contribute/knowledgebase', {
 | 
			
		||||
          method: 'POST',
 | 
			
		||||
          body: formData
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const result = await response.json();
 | 
			
		||||
 | 
			
		||||
        if (result.success) {
 | 
			
		||||
          showMessage('success', `Article submitted successfully! <a href="${result.prUrl}" target="_blank" rel="noopener noreferrer">View Pull Request</a>`);
 | 
			
		||||
          // Reset form or redirect
 | 
			
		||||
          setTimeout(() => {
 | 
			
		||||
            window.location.href = '/contribute';
 | 
			
		||||
          }, 3000);
 | 
			
		||||
        } else {
 | 
			
		||||
          throw new Error(result.error || 'Submission failed');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error('Submission error:', error);
 | 
			
		||||
        showMessage('error', `Submission failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
 | 
			
		||||
      } finally {
 | 
			
		||||
        submitBtn.classList.remove('loading');
 | 
			
		||||
        submitBtn.innerHTML = `
 | 
			
		||||
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
            <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
 | 
			
		||||
            <polyline points="17 21v-8H7v8"/>
 | 
			
		||||
            <polyline points="7 3v5h8"/>
 | 
			
		||||
          </svg>
 | 
			
		||||
          Submit Article
 | 
			
		||||
        `;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function showMessage(type: 'success' | 'error', message: string) {
 | 
			
		||||
      const messageEl = document.createElement('div');
 | 
			
		||||
      messageEl.className = `card ${type === 'success' ? 'card-success' : 'card-error'}`;
 | 
			
		||||
      messageEl.style.cssText = 'padding: 1rem; margin-bottom: 1rem; animation: slideIn 0.3s ease-out;';
 | 
			
		||||
      messageEl.innerHTML = message;
 | 
			
		||||
      
 | 
			
		||||
      const container = document.getElementById('form-messages');
 | 
			
		||||
      if (container) {
 | 
			
		||||
        container.appendChild(messageEl);
 | 
			
		||||
        
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
          messageEl.remove();
 | 
			
		||||
        }, 5000);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  </script>
 | 
			
		||||
</BaseLayout>
 | 
			
		||||
@ -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<string> {
 | 
			
		||||
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<string> {
 | 
			
		||||
  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<SessionData | null>
 | 
			
		||||
    // 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<any> {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Get user info from OIDC provider
 | 
			
		||||
export async function getUserInfo(accessToken: string): Promise<any> {
 | 
			
		||||
export async function getUserInfo(accessToken: string): Promise<UserInfo> {
 | 
			
		||||
  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`;
 | 
			
		||||
}
 | 
			
		||||
@ -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<string>();
 | 
			
		||||
 | 
			
		||||
  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<GitOperationResult> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
    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<string> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
  /**
 | 
			
		||||
   * Write file to repository
 | 
			
		||||
   */
 | 
			
		||||
  protected async writeFile(filePath: string, content: string): Promise<void> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
  /**
 | 
			
		||||
   * Read file from repository
 | 
			
		||||
   */
 | 
			
		||||
  protected async readFile(filePath: string): Promise<string> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
  /**
 | 
			
		||||
   * Push branch to remote
 | 
			
		||||
   */
 | 
			
		||||
  protected async pushBranch(branchName: string): Promise<void> {
 | 
			
		||||
    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<string> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
    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<string> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
  // Original tool contribution methods (unchanged)
 | 
			
		||||
 | 
			
		||||
  async submitContribution(data: ContributionData): Promise<GitOperationResult> {
 | 
			
		||||
    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 };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										332
									
								
								src/utils/markdown.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										332
									
								
								src/utils/markdown.ts
									
									
									
									
									
										Normal file
									
								
							@ -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(`<pre><code class="language-${this.escapeHtml(lang)}">${this.escapeHtml(content.trim())}</code></pre>`);
 | 
			
		||||
      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, `<h${i}>$1</h${i}>`);
 | 
			
		||||
    }
 | 
			
		||||
    return html;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseEmphasis(html: string): string {
 | 
			
		||||
    // Bold: **text** or __text__
 | 
			
		||||
    html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
 | 
			
		||||
    html = html.replace(/__(.*?)__/g, '<strong>$1</strong>');
 | 
			
		||||
    
 | 
			
		||||
    // Italic: *text* or _text_
 | 
			
		||||
    html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
 | 
			
		||||
    html = html.replace(/_(.*?)_/g, '<em>$1</em>');
 | 
			
		||||
    
 | 
			
		||||
    return html;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseLinksAndImages(html: string): string {
 | 
			
		||||
    const linkTarget = this.options.linkTarget ? ` target="${this.options.linkTarget}" rel="noopener noreferrer"` : '';
 | 
			
		||||
    
 | 
			
		||||
    // Images: 
 | 
			
		||||
    html = html.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, 
 | 
			
		||||
      '<img src="$2" alt="$1" style="max-width: 100%; height: auto; border-radius: 0.25rem; margin: 0.5rem 0;" />');
 | 
			
		||||
    
 | 
			
		||||
    // Links: [text](url)
 | 
			
		||||
    html = html.replace(/\[([^\]]*)\]\(([^)]*)\)/g, 
 | 
			
		||||
      `<a href="$2"${linkTarget}>$1</a>`);
 | 
			
		||||
    
 | 
			
		||||
    return html;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseInlineCode(html: string): string {
 | 
			
		||||
    // Inline code: `code`
 | 
			
		||||
    html = html.replace(/`([^`]*)`/g, '<code>$1</code>');
 | 
			
		||||
    return html;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseLists(html: string): string {
 | 
			
		||||
    // Unordered lists
 | 
			
		||||
    html = html.replace(/^[\s]*[-*+]\s+(.+)$/gm, '<li>$1</li>');
 | 
			
		||||
    
 | 
			
		||||
    // Ordered lists
 | 
			
		||||
    html = html.replace(/^[\s]*\d+\.\s+(.+)$/gm, '<li>$1</li>');
 | 
			
		||||
    
 | 
			
		||||
    // Wrap consecutive list items in ul/ol
 | 
			
		||||
    html = html.replace(/(<li>.*<\/li>)/s, (match) => {
 | 
			
		||||
      // Simple approach: assume unordered list
 | 
			
		||||
      return `<ul>${match}</ul>`;
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    return html;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseBlockquotes(html: string): string {
 | 
			
		||||
    // Blockquotes: > text
 | 
			
		||||
    html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
 | 
			
		||||
    
 | 
			
		||||
    // Merge consecutive blockquotes
 | 
			
		||||
    html = html.replace(/(<\/blockquote>)\s*(<blockquote>)/g, ' ');
 | 
			
		||||
    
 | 
			
		||||
    return html;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseHorizontalRules(html: string): string {
 | 
			
		||||
    // Horizontal rules: --- or ***
 | 
			
		||||
    html = html.replace(/^[-*]{3,}$/gm, '<hr>');
 | 
			
		||||
    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 <br>
 | 
			
		||||
      const withBreaks = trimmed.replace(/\n/g, '<br>');
 | 
			
		||||
      
 | 
			
		||||
      // Wrap in paragraph if not empty and not already a block element
 | 
			
		||||
      if (withBreaks && !this.isBlockElement(withBreaks)) {
 | 
			
		||||
        return `<p>${withBreaks}</p>`;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      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(/<script[^>]*>[\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);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										400
									
								
								src/utils/nextcloud.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										400
									
								
								src/utils/nextcloud.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<string>;
 | 
			
		||||
  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<UploadResult> {
 | 
			
		||||
    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<string> {
 | 
			
		||||
    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>(.*?)<\/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<UploadResult> {
 | 
			
		||||
  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();
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user