iterate on contrib
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user