fix bugs in contrib system, remove health endpoint
This commit is contained in:
parent
3c55742dfa
commit
0ac31484d5
@ -7,8 +7,7 @@
|
|||||||
"start": "astro dev",
|
"start": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro",
|
"astro": "astro"
|
||||||
"check:health": "curl -f http://localhost:4321/health || exit 1"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^9.3.0",
|
"@astrojs/node": "^9.3.0",
|
||||||
|
@ -1,478 +0,0 @@
|
|||||||
// src/pages/api/contribute/health.ts
|
|
||||||
import type { APIRoute } from 'astro';
|
|
||||||
import { getSessionFromRequest, verifySession } from '../../../utils/auth.js';
|
|
||||||
import { GitContributionManager } from '../../../utils/gitContributions.js';
|
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
import { execSync } from 'child_process';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
export const prerender = false;
|
|
||||||
|
|
||||||
interface HealthCheck {
|
|
||||||
component: string;
|
|
||||||
status: 'healthy' | 'warning' | 'error';
|
|
||||||
message: string;
|
|
||||||
details?: any;
|
|
||||||
lastChecked: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SystemHealth {
|
|
||||||
overall: 'healthy' | 'warning' | 'error';
|
|
||||||
checks: HealthCheck[];
|
|
||||||
summary: {
|
|
||||||
healthy: number;
|
|
||||||
warnings: number;
|
|
||||||
errors: number;
|
|
||||||
};
|
|
||||||
timestamp: string;
|
|
||||||
uptime?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class HealthMonitor {
|
|
||||||
private checks: HealthCheck[] = [];
|
|
||||||
|
|
||||||
async runAllChecks(): Promise<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' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
@ -3,61 +3,39 @@ import type { APIRoute } from 'astro';
|
|||||||
import { getSessionFromRequest, verifySession } from '../../../utils/auth.js';
|
import { getSessionFromRequest, verifySession } from '../../../utils/auth.js';
|
||||||
import { GitContributionManager } from '../../../utils/gitContributions.js';
|
import { GitContributionManager } from '../../../utils/gitContributions.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
// Enhanced knowledgebase schema for contributions
|
// Simplified schema for document-based contributions
|
||||||
const KnowledgebaseContributionSchema = z.object({
|
const KnowledgebaseContributionSchema = z.object({
|
||||||
toolName: z.string().min(1, 'Tool name is required'),
|
toolName: z.string().min(1),
|
||||||
title: z.string().min(5, 'Title must be at least 5 characters').max(100, 'Title too long'),
|
title: z.string().min(1),
|
||||||
description: z.string().min(20, 'Description must be at least 20 characters').max(300, 'Description too long'),
|
description: z.string().min(1),
|
||||||
content: z.string().min(50, 'Content must be at least 50 characters'),
|
content: z.string().default(''),
|
||||||
difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert'], {
|
externalLink: z.string().url().optional().or(z.literal('')),
|
||||||
errorMap: () => ({ message: 'Invalid difficulty level' })
|
difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert']),
|
||||||
}),
|
|
||||||
categories: z.string().transform(str => {
|
categories: z.string().transform(str => {
|
||||||
try {
|
try { return JSON.parse(str); } catch { return []; }
|
||||||
return JSON.parse(str);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}).pipe(z.array(z.string()).default([])),
|
}).pipe(z.array(z.string()).default([])),
|
||||||
tags: z.string().transform(str => {
|
tags: z.string().transform(str => {
|
||||||
try {
|
try { return JSON.parse(str); } catch { return []; }
|
||||||
return JSON.parse(str);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}).pipe(z.array(z.string()).default([])),
|
}).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 => {
|
uploadedFiles: z.string().transform(str => {
|
||||||
try {
|
try { return JSON.parse(str); } catch { return []; }
|
||||||
return JSON.parse(str);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}).pipe(z.array(z.any()).default([]))
|
}).pipe(z.array(z.any()).default([]))
|
||||||
});
|
});
|
||||||
|
|
||||||
interface KnowledgebaseContributionData {
|
interface KnowledgebaseContributionData {
|
||||||
type: 'add' | 'edit';
|
type: 'add';
|
||||||
article: {
|
article: {
|
||||||
toolName: string;
|
toolName: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
externalLink?: string;
|
||||||
difficulty: string;
|
difficulty: string;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
sections: Record<string, boolean>;
|
|
||||||
uploadedFiles: any[];
|
uploadedFiles: any[];
|
||||||
};
|
};
|
||||||
metadata: {
|
metadata: {
|
||||||
@ -66,10 +44,10 @@ interface KnowledgebaseContributionData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limiting (same pattern as tool contributions)
|
// Rate limiting
|
||||||
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
||||||
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
|
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
|
||||||
const RATE_LIMIT_MAX = 10; // Max 10 submissions per hour
|
const RATE_LIMIT_MAX = 5; // Max 5 submissions per hour per user
|
||||||
|
|
||||||
function checkRateLimit(userEmail: string): boolean {
|
function checkRateLimit(userEmail: string): boolean {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@ -88,68 +66,17 @@ function checkRateLimit(userEmail: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateKnowledgebaseData(article: any): Promise<{ valid: boolean; errors: string[] }> {
|
function validateKnowledgebaseData(data: any): { valid: boolean; errors?: string[] } {
|
||||||
const errors: string[] = [];
|
// Only check that they provided SOMETHING
|
||||||
|
const hasContent = data.content?.trim().length > 0;
|
||||||
|
const hasLink = data.externalLink?.trim().length > 0;
|
||||||
|
const hasFiles = data.uploadedFiles?.length > 0;
|
||||||
|
|
||||||
// Check if tool exists in the database
|
if (!hasContent && !hasLink && !hasFiles) {
|
||||||
try {
|
return { valid: false, errors: ['Must provide content, link, or files'] };
|
||||||
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
|
return { valid: true }; // That's it - maximum freedom
|
||||||
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 {
|
function generateArticleSlug(title: string, toolName: string): string {
|
||||||
@ -168,41 +95,75 @@ function generateArticleSlug(title: string, toolName: string): string {
|
|||||||
return `${toolSlug}-${baseSlug}`;
|
return `${toolSlug}-${baseSlug}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateMarkdownFrontmatter(article: any): string {
|
function generateMarkdownContent(article: any): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
// Generate frontmatter
|
||||||
const frontmatter = {
|
const frontmatter = {
|
||||||
title: article.title,
|
title: article.title,
|
||||||
tool_name: article.toolName,
|
tool_name: article.toolName,
|
||||||
description: article.description,
|
description: article.description,
|
||||||
last_updated: now.toISOString().split('T')[0], // YYYY-MM-DD format
|
last_updated: now.toISOString().split('T')[0],
|
||||||
author: 'CC24-Team',
|
author: 'CC24-Team',
|
||||||
difficulty: article.difficulty,
|
difficulty: article.difficulty,
|
||||||
categories: article.categories,
|
categories: article.categories,
|
||||||
tags: article.tags,
|
tags: article.tags,
|
||||||
sections: article.sections,
|
review_status: 'pending_review'
|
||||||
review_status: 'draft'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return `---\n${Object.entries(frontmatter)
|
const frontmatterYaml = Object.entries(frontmatter)
|
||||||
.map(([key, value]) => {
|
.map(([key, value]) => {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return `${key}: [${value.map(v => `"${v}"`).join(', ')}]`;
|
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 {
|
} else {
|
||||||
return `${key}: "${value}"`;
|
return `${key}: "${value}"`;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.join('\n')}\n---\n\n`;
|
.join('\n');
|
||||||
|
|
||||||
|
// Generate content sections
|
||||||
|
let content = `---\n${frontmatterYaml}\n---\n\n`;
|
||||||
|
content += `# ${article.title}\n\n`;
|
||||||
|
content += `${article.description}\n\n`;
|
||||||
|
|
||||||
|
// Add user content if provided
|
||||||
|
if (article.content && article.content.trim().length > 0) {
|
||||||
|
content += `## Content\n\n${article.content}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add external link if provided
|
||||||
|
if (article.externalLink && article.externalLink.trim().length > 0) {
|
||||||
|
content += `## External Resources\n\n`;
|
||||||
|
content += `- [External Documentation](${article.externalLink})\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add uploaded files section
|
||||||
|
if (article.uploadedFiles && article.uploadedFiles.length > 0) {
|
||||||
|
content += `## Uploaded Files\n\n`;
|
||||||
|
article.uploadedFiles.forEach((file: any) => {
|
||||||
|
const fileType = file.name.toLowerCase();
|
||||||
|
let icon = '📎';
|
||||||
|
if (fileType.includes('.pdf')) icon = '📄';
|
||||||
|
else if (fileType.match(/\.(png|jpg|jpeg|gif|webp)$/)) icon = '🖼️';
|
||||||
|
else if (fileType.match(/\.(mp4|webm|mov|avi)$/)) icon = '🎥';
|
||||||
|
else if (fileType.match(/\.(doc|docx)$/)) icon = '📝';
|
||||||
|
else if (fileType.match(/\.(zip|tar|gz)$/)) icon = '📦';
|
||||||
|
|
||||||
|
content += `- ${icon} [${file.name}](${file.url})\n`;
|
||||||
|
});
|
||||||
|
content += '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
content += `---\n\n`;
|
||||||
|
content += `*This article was contributed via the CC24-Hub knowledge base submission system.*\n`;
|
||||||
|
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extended GitContributionManager for knowledgebase
|
// Extended GitContributionManager for knowledgebase
|
||||||
class KnowledgebaseGitManager extends GitContributionManager {
|
class KnowledgebaseGitManager extends GitContributionManager {
|
||||||
async submitKnowledgebaseContribution(data: KnowledgebaseContributionData): Promise<{success: boolean, message: string, prUrl?: string, branchName?: string}> {
|
async submitKnowledgebaseContribution(data: KnowledgebaseContributionData): Promise<{success: boolean, message: string, prUrl?: string, branchName?: string}> {
|
||||||
const branchName = `kb-${data.type}-${Date.now()}`;
|
const branchName = `kb-add-${Date.now()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create branch
|
// Create branch
|
||||||
@ -210,23 +171,22 @@ class KnowledgebaseGitManager extends GitContributionManager {
|
|||||||
|
|
||||||
// Generate file content
|
// Generate file content
|
||||||
const slug = generateArticleSlug(data.article.title, data.article.toolName);
|
const slug = generateArticleSlug(data.article.title, data.article.toolName);
|
||||||
const frontmatter = generateMarkdownFrontmatter(data.article);
|
const markdownContent = generateMarkdownContent(data.article);
|
||||||
const fullContent = frontmatter + data.article.content;
|
|
||||||
|
|
||||||
// Write article file
|
// Write article file
|
||||||
const articlePath = `src/content/knowledgebase/${slug}.md`;
|
const articlePath = `src/content/knowledgebase/${slug}.md`;
|
||||||
await this.writeFile(articlePath, fullContent);
|
await this.writeFile(articlePath, markdownContent);
|
||||||
|
|
||||||
// Update tools.yaml to add knowledgebase flag
|
|
||||||
await this.updateToolKnowledgebaseFlag(data.article.toolName);
|
|
||||||
|
|
||||||
// Commit changes
|
// Commit changes
|
||||||
const commitMessage = `${data.type === 'add' ? 'Add' : 'Update'} knowledgebase article: ${data.article.title}
|
const commitMessage = `Add knowledgebase contribution: ${data.article.title}
|
||||||
|
|
||||||
Contributed by: ${data.metadata.submitter}
|
Contributed by: ${data.metadata.submitter}
|
||||||
Tool: ${data.article.toolName}
|
Tool: ${data.article.toolName}
|
||||||
|
Type: Document-based contribution
|
||||||
Difficulty: ${data.article.difficulty}
|
Difficulty: ${data.article.difficulty}
|
||||||
Categories: ${data.article.categories.join(', ')}
|
Categories: ${data.article.categories.join(', ')}
|
||||||
|
Files: ${data.article.uploadedFiles.length} uploaded
|
||||||
|
${data.article.externalLink ? `External Link: ${data.article.externalLink}` : ''}
|
||||||
|
|
||||||
${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''}`;
|
${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''}`;
|
||||||
|
|
||||||
@ -238,13 +198,13 @@ ${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''}`;
|
|||||||
// Create pull request
|
// Create pull request
|
||||||
const prUrl = await this.createPullRequest(
|
const prUrl = await this.createPullRequest(
|
||||||
branchName,
|
branchName,
|
||||||
`Knowledgebase: ${data.article.title}`,
|
`Add KB Article: ${data.article.title}`,
|
||||||
this.generateKnowledgebasePRDescription(data)
|
this.generateKnowledgebasePRDescription(data)
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Knowledgebase article contribution submitted successfully`,
|
message: 'Knowledge base article submitted successfully',
|
||||||
prUrl,
|
prUrl,
|
||||||
branchName
|
branchName
|
||||||
};
|
};
|
||||||
@ -261,68 +221,49 @@ ${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''}`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
private generateKnowledgebasePRDescription(data: KnowledgebaseContributionData): string {
|
||||||
return `## Knowledgebase Article: ${data.article.title}
|
return `## Knowledge Base Article: ${data.article.title}
|
||||||
|
|
||||||
**Tool:** ${data.article.toolName}
|
**Tool:** ${data.article.toolName}
|
||||||
**Type:** ${data.type === 'add' ? 'New Article' : 'Article Update'}
|
|
||||||
**Difficulty:** ${data.article.difficulty}
|
|
||||||
**Submitted by:** ${data.metadata.submitter}
|
**Submitted by:** ${data.metadata.submitter}
|
||||||
|
**Difficulty:** ${data.article.difficulty}
|
||||||
|
|
||||||
### Article Details
|
### Article Details
|
||||||
- **Categories:** ${data.article.categories.join(', ')}
|
- **Description:** ${data.article.description}
|
||||||
- **Tags:** ${data.article.tags.join(', ')}
|
- **Categories:** ${data.article.categories.length > 0 ? data.article.categories.join(', ') : 'None'}
|
||||||
- **Sections:** ${Object.entries(data.article.sections).filter(([_, enabled]) => enabled).map(([section, _]) => section).join(', ')}
|
- **Tags:** ${data.article.tags.length > 0 ? data.article.tags.join(', ') : 'None'}
|
||||||
- **Content Length:** ~${data.article.content.split(/\s+/).length} words
|
- **Content:** ${data.article.content && data.article.content.trim().length > 0 ? `~${data.article.content.split(/\s+/).length} words` : 'Document/link-based'}
|
||||||
|
- **Uploaded Files:** ${data.article.uploadedFiles.length} files
|
||||||
|
${data.article.externalLink ? `- **External Link:** ${data.article.externalLink}` : ''}
|
||||||
|
|
||||||
### Description
|
${data.metadata.reason ? `### Reason for Contribution
|
||||||
${data.article.description}
|
${data.metadata.reason}
|
||||||
|
|
||||||
${data.metadata.reason ? `### Reason for Contribution\n${data.metadata.reason}\n` : ''}
|
` : ''}### Content Overview
|
||||||
|
${data.article.content && data.article.content.trim().length > 0 ? `
|
||||||
|
**Provided Content:**
|
||||||
|
${data.article.content.substring(0, 200)}${data.article.content.length > 200 ? '...' : ''}
|
||||||
|
` : ''}
|
||||||
|
${data.article.uploadedFiles.length > 0 ? `
|
||||||
|
**Uploaded Files:**
|
||||||
|
${data.article.uploadedFiles.map((file: any) => `- ${file.name} (${file.url})`).join('\n')}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
### Review Checklist
|
### Review Checklist
|
||||||
- [ ] Article content is accurate and helpful
|
- [ ] Article content is accurate and helpful
|
||||||
- [ ] Language is clear and appropriate for the difficulty level
|
- [ ] All uploaded files are accessible and appropriate
|
||||||
- [ ] All sections are properly structured
|
- [ ] External links are valid and safe
|
||||||
- [ ] Categories and tags are relevant
|
- [ ] Categories and tags are relevant
|
||||||
|
- [ ] Difficulty level is appropriate
|
||||||
|
- [ ] Content is well-organized and clear
|
||||||
- [ ] No sensitive or inappropriate content
|
- [ ] No sensitive or inappropriate content
|
||||||
- [ ] Links and references are valid
|
- [ ] Proper attribution for external sources
|
||||||
- [ ] Media files (if any) are appropriate
|
|
||||||
|
|
||||||
### Files Changed
|
### Files Changed
|
||||||
- \`src/content/knowledgebase/${generateArticleSlug(data.article.title, data.article.toolName)}.md\` (${data.type})
|
- \`src/content/knowledgebase/${generateArticleSlug(data.article.title, data.article.toolName)}.md\` (new article)
|
||||||
- \`src/data/tools.yaml\` (knowledgebase flag update)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
*This contribution was submitted via the CC24-Hub knowledgebase editor.*`;
|
*This contribution was submitted via the CC24-Hub document-based knowledge base system.*`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -392,12 +333,12 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional knowledgebase-specific validation
|
// Additional validation
|
||||||
const kbValidation = await validateKnowledgebaseData(validatedData);
|
const kbValidation = validateKnowledgebaseData(validatedData);
|
||||||
if (!kbValidation.valid) {
|
if (!kbValidation.valid) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Knowledgebase validation failed',
|
error: 'Content validation failed',
|
||||||
details: kbValidation.errors
|
details: kbValidation.errors
|
||||||
}), {
|
}), {
|
||||||
status: 400,
|
status: 400,
|
||||||
@ -407,7 +348,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
// Prepare contribution data
|
// Prepare contribution data
|
||||||
const contributionData: KnowledgebaseContributionData = {
|
const contributionData: KnowledgebaseContributionData = {
|
||||||
type: 'add', // For now, only support adding new articles
|
type: 'add',
|
||||||
article: validatedData,
|
article: validatedData,
|
||||||
metadata: {
|
metadata: {
|
||||||
submitter: userEmail,
|
submitter: userEmail,
|
||||||
@ -420,7 +361,6 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
const result = await gitManager.submitKnowledgebaseContribution(contributionData);
|
const result = await gitManager.submitKnowledgebaseContribution(contributionData);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Log successful contribution
|
|
||||||
console.log(`[KB CONTRIBUTION] "${validatedData.title}" for ${validatedData.toolName} by ${userEmail} - PR: ${result.prUrl}`);
|
console.log(`[KB CONTRIBUTION] "${validatedData.title}" for ${validatedData.toolName} by ${userEmail} - PR: ${result.prUrl}`);
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
@ -433,7 +373,6 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Log failed contribution
|
|
||||||
console.error(`[KB CONTRIBUTION FAILED] "${validatedData.title}" by ${userEmail}: ${result.message}`);
|
console.error(`[KB CONTRIBUTION FAILED] "${validatedData.title}" by ${userEmail}: ${result.message}`);
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
|
@ -347,47 +347,3 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Health check endpoint
|
|
||||||
export const GET: APIRoute = async ({ request }) => {
|
|
||||||
try {
|
|
||||||
// Simple authentication check for health endpoint
|
|
||||||
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
|
|
||||||
|
|
||||||
if (authRequired) {
|
|
||||||
const sessionToken = getSessionFromRequest(request);
|
|
||||||
if (!sessionToken) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Authentication required' }), {
|
|
||||||
status: 401,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await verifySession(sessionToken);
|
|
||||||
if (!session) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Invalid session' }), {
|
|
||||||
status: 401,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const gitManager = new GitContributionManager();
|
|
||||||
const health = await gitManager.checkHealth();
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(health), {
|
|
||||||
status: health.healthy ? 200 : 503,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Health check error:', error);
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
healthy: false,
|
|
||||||
issues: ['Health check failed']
|
|
||||||
}), {
|
|
||||||
status: 503,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
@ -2,6 +2,7 @@
|
|||||||
// src/pages/contribute/index.astro - Updated for Phase 3
|
// src/pages/contribute/index.astro - Updated for Phase 3
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import { getSessionFromRequest, verifySession } from '../../utils/auth.js';
|
import { getSessionFromRequest, verifySession } from '../../utils/auth.js';
|
||||||
|
import { getAuthContext, requireAuth } from '../../utils/serverAuth.js';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
@ -20,9 +21,9 @@ if (authRequired) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
const authContext = await getAuthContext(Astro);
|
||||||
return Astro.redirect('/auth/login');
|
const authRedirect = requireAuth(authContext, Astro.url.toString());
|
||||||
}
|
if (authRedirect) return authRedirect;
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -148,12 +149,6 @@ if (authRequired) {
|
|||||||
</svg>
|
</svg>
|
||||||
Report Issue
|
Report Issue
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -199,95 +194,7 @@ if (authRequired) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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>
|
<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
|
// Add hover effects for cards
|
||||||
document.querySelectorAll('.card[onclick]').forEach((card) => {
|
document.querySelectorAll('.card[onclick]').forEach((card) => {
|
||||||
const cardEl = card as HTMLElement;
|
const cardEl = card as HTMLElement;
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -432,66 +432,4 @@ This contribution contains the raw tool data for manual review and integration.`
|
|||||||
---
|
---
|
||||||
*This contribution was submitted via the CC24-Hub web interface and contains only the raw tool data for manual integration.*`;
|
*This contribution was submitted via the CC24-Hub web interface and contains only the raw tool data for manual integration.*`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkHealth(): Promise<{healthy: boolean, issues?: string[]}> {
|
|
||||||
const issues: string[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if local repo exists and is accessible
|
|
||||||
try {
|
|
||||||
await fs.access(this.config.localRepoPath);
|
|
||||||
} catch {
|
|
||||||
issues.push('Local repository path not accessible');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check git status
|
|
||||||
try {
|
|
||||||
execSync('git status', { cwd: this.config.localRepoPath, stdio: 'pipe' });
|
|
||||||
} catch {
|
|
||||||
issues.push('Local repository is not a valid git repository');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test API connectivity
|
|
||||||
try {
|
|
||||||
let testUrl: string;
|
|
||||||
switch (this.config.provider) {
|
|
||||||
case 'gitea':
|
|
||||||
testUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}`;
|
|
||||||
break;
|
|
||||||
case 'github':
|
|
||||||
testUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}`;
|
|
||||||
break;
|
|
||||||
case 'gitlab':
|
|
||||||
testUrl = `${this.config.apiEndpoint}/projects/${encodeURIComponent(this.config.repoOwner + '/' + this.config.repoName)}`;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error('Unknown provider');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(testUrl, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${this.config.apiToken}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
issues.push(`API connectivity failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
issues.push(`API test failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
healthy: issues.length === 0,
|
|
||||||
issues: issues.length > 0 ? issues : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
healthy: false,
|
|
||||||
issues: [`Health check failed: ${error instanceof Error ? error.message : 'Unknown error'}`]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -168,6 +168,10 @@ export class NextcloudUploader {
|
|||||||
const categoryPath = this.sanitizeFilename(category);
|
const categoryPath = this.sanitizeFilename(category);
|
||||||
const remotePath = `${this.config.uploadPath}/${categoryPath}/${uniqueFilename}`;
|
const remotePath = `${this.config.uploadPath}/${categoryPath}/${uniqueFilename}`;
|
||||||
|
|
||||||
|
// **FIX: Ensure directory exists before upload**
|
||||||
|
const dirPath = `${this.config.uploadPath}/${categoryPath}`;
|
||||||
|
await this.ensureDirectoryExists(dirPath);
|
||||||
|
|
||||||
// Convert file to buffer
|
// Convert file to buffer
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
@ -208,6 +212,36 @@ export class NextcloudUploader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensureDirectoryExists(dirPath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Split path and create each directory level
|
||||||
|
const parts = dirPath.split('/').filter(part => part);
|
||||||
|
let currentPath = '';
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
currentPath += '/' + part;
|
||||||
|
|
||||||
|
const mkcolUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${currentPath}`;
|
||||||
|
|
||||||
|
const response = await fetch(mkcolUrl, {
|
||||||
|
method: 'MKCOL',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 201 = created, 405 = already exists, both are fine
|
||||||
|
if (response.status !== 201 && response.status !== 405) {
|
||||||
|
console.warn(`Directory creation failed: ${response.status} for ${currentPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to ensure directory exists:', error);
|
||||||
|
// Don't fail upload for directory creation issues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a public share link for the uploaded file
|
* Create a public share link for the uploaded file
|
||||||
*/
|
*/
|
||||||
|
Loading…
x
Reference in New Issue
Block a user