iterate on contrib

This commit is contained in:
overcuriousity
2025-07-23 21:06:39 +02:00
parent f4acf39ca7
commit 3d42fcef79
12 changed files with 3559 additions and 477 deletions

View File

@@ -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' }

View 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' }
});
}
};

View 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' }
});
}
};

View 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' }
});
}
};