iterate on contrib

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

View File

@ -3,6 +3,13 @@ AI_API_ENDPOINT=https://aiendpoint.org
AI_API_KEY=your_apikey_here AI_API_KEY=your_apikey_here
AI_MODEL='ai_model_name_here' AI_MODEL='ai_model_name_here'
# Git Repository
GIT_REPO_URL=https://git.cc24.dev/mstoeck3/cc24-hub.git
GIT_API_ENDPOINT=https://git.cc24.dev/api/v1
GIT_API_TOKEN=
LOCAL_REPO_PATH=/var/git/cc24-hub
GIT_PROVIDER=gitea
# OIDC Configuration # OIDC Configuration
OIDC_ENDPOINT=https://oidc-provider.org OIDC_ENDPOINT=https://oidc-provider.org
OIDC_CLIENT_ID=your_oidc_client_id OIDC_CLIENT_ID=your_oidc_client_id
@ -15,4 +22,21 @@ AUTHENTICATION_NECESSARY=false # Always set this to true in prod
# Application # Application
PUBLIC_BASE_URL=http://localhost:4321 PUBLIC_BASE_URL=http://localhost:4321
NODE_ENV=development # Media Storage
LOCAL_UPLOAD_PATH=./public/uploads
# Nextcloud integration (optional)
NEXTCLOUD_ENDPOINT=
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
NEXTCLOUD_UPLOAD_PATH=/kb-media
NEXTCLOUD_PUBLIC_URL=
# Custom upload limits (optional)
MAX_FILE_SIZE=52428800 # 50MB in bytes
MAX_UPLOADS_PER_HOUR=100
# Development/Production mode
NODE_ENV=development
# Logging level
LOG_LEVEL=info

View File

@ -1,3 +1,4 @@
// src/pages/api/auth/process.ts - Fixed Email Support
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { parse } from 'cookie'; import { parse } from 'cookie';
import { import {
@ -5,7 +6,8 @@ import {
getUserInfo, getUserInfo,
createSession, createSession,
createSessionCookie, createSessionCookie,
logAuthEvent logAuthEvent,
getUserEmail
} from '../../../utils/auth.js'; } from '../../../utils/auth.js';
// Mark as server-rendered // Mark as server-rendered
@ -67,13 +69,17 @@ export const POST: APIRoute = async ({ request }) => {
console.log('Getting user info...'); console.log('Getting user info...');
const userInfo = await getUserInfo(tokens.access_token); const userInfo = await getUserInfo(tokens.access_token);
// Create session // Extract user details
const sessionToken = await createSession(userInfo.sub || userInfo.preferred_username || 'unknown'); 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); const sessionCookie = createSessionCookie(sessionToken);
logAuthEvent('Authentication successful', { logAuthEvent('Authentication successful', {
userId: userInfo.sub || userInfo.preferred_username, userId: userId,
email: userInfo.email email: userEmail
}); });
// Clear auth state cookie // Clear auth state cookie
@ -95,7 +101,7 @@ export const POST: APIRoute = async ({ request }) => {
} catch (error) { } catch (error) {
console.error('Authentication processing failed:', 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 }), { return new Response(JSON.stringify({ success: false }), {
status: 500, status: 500,
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }

View File

@ -0,0 +1,478 @@
// src/pages/api/contribute/health.ts
import type { APIRoute } from 'astro';
import { getSessionFromRequest, verifySession } from '../../../utils/auth.js';
import { GitContributionManager } from '../../../utils/gitContributions.js';
import { promises as fs } from 'fs';
import { execSync } from 'child_process';
import path from 'path';
export const prerender = false;
interface HealthCheck {
component: string;
status: 'healthy' | 'warning' | 'error';
message: string;
details?: any;
lastChecked: string;
}
interface SystemHealth {
overall: 'healthy' | 'warning' | 'error';
checks: HealthCheck[];
summary: {
healthy: number;
warnings: number;
errors: number;
};
timestamp: string;
uptime?: string;
}
class HealthMonitor {
private checks: HealthCheck[] = [];
async runAllChecks(): Promise<SystemHealth> {
this.checks = [];
// Run all health checks
await Promise.allSettled([
this.checkGitRepository(),
this.checkGitConnectivity(),
this.checkDiskSpace(),
this.checkMemoryUsage(),
this.checkDataFiles(),
this.checkAuthSystem(),
this.checkEnvironmentVariables(),
this.checkFilePermissions()
]);
// Calculate overall status
const errors = this.checks.filter(c => c.status === 'error').length;
const warnings = this.checks.filter(c => c.status === 'warning').length;
const healthy = this.checks.filter(c => c.status === 'healthy').length;
let overall: 'healthy' | 'warning' | 'error' = 'healthy';
if (errors > 0) {
overall = 'error';
} else if (warnings > 0) {
overall = 'warning';
}
return {
overall,
checks: this.checks,
summary: { healthy, warnings: warnings, errors },
timestamp: new Date().toISOString(),
uptime: this.getUptime()
};
}
private addCheck(component: string, status: 'healthy' | 'warning' | 'error', message: string, details?: any) {
this.checks.push({
component,
status,
message,
details,
lastChecked: new Date().toISOString()
});
}
private async checkGitRepository(): Promise<void> {
try {
const localRepoPath = process.env.LOCAL_REPO_PATH || '/var/git/cc24-hub';
// Check if repo exists
try {
await fs.access(localRepoPath);
} catch {
this.addCheck('Git Repository', 'error', 'Local git repository not found', { path: localRepoPath });
return;
}
// Check if it's a git repository
try {
execSync('git status', { cwd: localRepoPath, stdio: 'pipe' });
} catch {
this.addCheck('Git Repository', 'error', 'Directory is not a git repository', { path: localRepoPath });
return;
}
// Check repository health
try {
const gitStatus = execSync('git status --porcelain', { cwd: localRepoPath, encoding: 'utf8' });
const uncommittedChanges = gitStatus.trim().split('\n').filter(line => line.trim()).length;
const branchInfo = execSync('git branch --show-current', { cwd: localRepoPath, encoding: 'utf8' }).trim();
const lastCommit = execSync('git log -1 --format="%h %s (%ar)"', { cwd: localRepoPath, encoding: 'utf8' }).trim();
if (uncommittedChanges > 0) {
this.addCheck('Git Repository', 'warning', `Repository has ${uncommittedChanges} uncommitted changes`, {
branch: branchInfo,
lastCommit,
uncommittedChanges
});
} else {
this.addCheck('Git Repository', 'healthy', 'Repository is clean and up to date', {
branch: branchInfo,
lastCommit
});
}
} catch (error) {
this.addCheck('Git Repository', 'warning', 'Could not check repository status', { error: error.message });
}
} catch (error) {
this.addCheck('Git Repository', 'error', 'Failed to check git repository', { error: error.message });
}
}
private async checkGitConnectivity(): Promise<void> {
try {
const gitManager = new GitContributionManager();
const health = await gitManager.checkHealth();
if (health.healthy) {
this.addCheck('Git Connectivity', 'healthy', 'Git API connectivity working');
} else {
this.addCheck('Git Connectivity', 'error', 'Git API connectivity issues', { issues: health.issues });
}
} catch (error) {
this.addCheck('Git Connectivity', 'error', 'Failed to check git connectivity', { error: error.message });
}
}
private async checkDiskSpace(): Promise<void> {
try {
// Get disk usage for the current working directory
const stats = await fs.statfs(process.cwd());
const totalSpace = stats.bavail * stats.bsize; // Available space in bytes
const totalBlocks = stats.blocks * stats.bsize; // Total space in bytes
const usedSpace = totalBlocks - totalSpace;
const usagePercent = Math.round((usedSpace / totalBlocks) * 100);
const freeSpaceGB = Math.round(totalSpace / (1024 * 1024 * 1024) * 100) / 100;
const totalSpaceGB = Math.round(totalBlocks / (1024 * 1024 * 1024) * 100) / 100;
const details = {
freeSpace: `${freeSpaceGB} GB`,
totalSpace: `${totalSpaceGB} GB`,
usagePercent: `${usagePercent}%`
};
if (usagePercent > 90) {
this.addCheck('Disk Space', 'error', `Disk usage critical: ${usagePercent}%`, details);
} else if (usagePercent > 80) {
this.addCheck('Disk Space', 'warning', `Disk usage high: ${usagePercent}%`, details);
} else {
this.addCheck('Disk Space', 'healthy', `Disk usage normal: ${usagePercent}%`, details);
}
} catch (error) {
this.addCheck('Disk Space', 'warning', 'Could not check disk space', { error: error.message });
}
}
private async checkMemoryUsage(): Promise<void> {
try {
const memInfo = process.memoryUsage();
const totalMemMB = Math.round(memInfo.heapTotal / 1024 / 1024 * 100) / 100;
const usedMemMB = Math.round(memInfo.heapUsed / 1024 / 1024 * 100) / 100;
const externalMemMB = Math.round(memInfo.external / 1024 / 1024 * 100) / 100;
const details = {
heapUsed: `${usedMemMB} MB`,
heapTotal: `${totalMemMB} MB`,
external: `${externalMemMB} MB`,
rss: `${Math.round(memInfo.rss / 1024 / 1024 * 100) / 100} MB`
};
if (usedMemMB > 500) {
this.addCheck('Memory Usage', 'warning', `High memory usage: ${usedMemMB} MB`, details);
} else {
this.addCheck('Memory Usage', 'healthy', `Memory usage normal: ${usedMemMB} MB`, details);
}
} catch (error) {
this.addCheck('Memory Usage', 'warning', 'Could not check memory usage', { error: error.message });
}
}
private async checkDataFiles(): Promise<void> {
try {
const dataFiles = [
'src/data/tools.yaml',
'src/content/knowledgebase/'
];
const fileStatuses: Array<{
path: string;
type?: 'file' | 'directory';
fileCount?: number;
size?: string;
lastModified?: string;
error?: string;
}> = [];
for (const filePath of dataFiles) {
try {
const stats = await fs.stat(filePath);
const isDirectory = stats.isDirectory();
if (isDirectory) {
// Count files in directory
const files = await fs.readdir(filePath);
const mdFiles = files.filter(f => f.endsWith('.md')).length;
fileStatuses.push({
path: filePath,
type: 'directory',
fileCount: mdFiles,
lastModified: stats.mtime.toISOString()
});
} else {
// Check file size and modification time
const fileSizeKB = Math.round(stats.size / 1024 * 100) / 100;
fileStatuses.push({
path: filePath,
type: 'file',
size: `${fileSizeKB} KB`,
lastModified: stats.mtime.toISOString()
});
}
} catch (error: any) {
fileStatuses.push({
path: filePath,
error: error?.message || 'Unknown error'
});
}
}
const errors = fileStatuses.filter(f => f.error);
if (errors.length > 0) {
this.addCheck('Data Files', 'error', `${errors.length} data files inaccessible`, { files: fileStatuses });
} else {
this.addCheck('Data Files', 'healthy', 'All data files accessible', { files: fileStatuses });
}
} catch (error) {
this.addCheck('Data Files', 'error', 'Failed to check data files', { error: error.message });
}
}
private async checkAuthSystem(): Promise<void> {
try {
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
if (!authRequired) {
this.addCheck('Authentication', 'healthy', 'Authentication disabled', { mode: 'disabled' });
return;
}
const requiredEnvVars = [
'OIDC_ENDPOINT',
'OIDC_CLIENT_ID',
'OIDC_CLIENT_SECRET',
'PUBLIC_BASE_URL'
];
const missingVars = requiredEnvVars.filter(varName => !process.env[varName]);
if (missingVars.length > 0) {
this.addCheck('Authentication', 'error', 'Missing OIDC configuration', {
missing: missingVars,
mode: 'enabled'
});
return;
}
// Test OIDC endpoint connectivity
try {
const oidcEndpoint = process.env.OIDC_ENDPOINT;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`${oidcEndpoint}/.well-known/openid_configuration`, {
method: 'GET',
signal: controller.signal
});
clearTimeout(timeoutId);
if (response.ok) {
this.addCheck('Authentication', 'healthy', 'OIDC provider accessible', {
endpoint: oidcEndpoint,
mode: 'enabled'
});
} else {
this.addCheck('Authentication', 'warning', 'OIDC provider returned error', {
endpoint: oidcEndpoint,
status: response.status,
mode: 'enabled'
});
}
} catch (error) {
this.addCheck('Authentication', 'error', 'Cannot reach OIDC provider', {
endpoint: process.env.OIDC_ENDPOINT,
error: error.message,
mode: 'enabled'
});
}
} catch (error) {
this.addCheck('Authentication', 'error', 'Failed to check auth system', { error: error.message });
}
}
private async checkEnvironmentVariables(): Promise<void> {
try {
const requiredVars = [
'GIT_REPO_URL',
'GIT_API_ENDPOINT',
'GIT_API_TOKEN',
'LOCAL_REPO_PATH'
];
const optionalVars = [
'GIT_PROVIDER',
'AUTHENTICATION_NECESSARY',
'NODE_ENV'
];
const missingRequired = requiredVars.filter(varName => !process.env[varName]);
const missingOptional = optionalVars.filter(varName => !process.env[varName]);
const details = {
required: {
total: requiredVars.length,
missing: missingRequired.length,
missingVars: missingRequired
},
optional: {
total: optionalVars.length,
missing: missingOptional.length,
missingVars: missingOptional
}
};
if (missingRequired.length > 0) {
this.addCheck('Environment Variables', 'error', `${missingRequired.length} required environment variables missing`, details);
} else if (missingOptional.length > 0) {
this.addCheck('Environment Variables', 'warning', `${missingOptional.length} optional environment variables missing`, details);
} else {
this.addCheck('Environment Variables', 'healthy', 'All environment variables configured', details);
}
} catch (error) {
this.addCheck('Environment Variables', 'error', 'Failed to check environment variables', { error: error.message });
}
}
private async checkFilePermissions(): Promise<void> {
try {
const localRepoPath = process.env.LOCAL_REPO_PATH || '/var/git/cc24-hub';
try {
// Test read permission
await fs.access(localRepoPath, fs.constants.R_OK);
// Test write permission
await fs.access(localRepoPath, fs.constants.W_OK);
this.addCheck('File Permissions', 'healthy', 'Repository has proper read/write permissions', { path: localRepoPath });
} catch (error) {
if (error.code === 'EACCES') {
this.addCheck('File Permissions', 'error', 'Insufficient permissions for repository', {
path: localRepoPath,
error: error.message
});
} else {
this.addCheck('File Permissions', 'error', 'Repository path inaccessible', {
path: localRepoPath,
error: error.message
});
}
}
} catch (error) {
this.addCheck('File Permissions', 'warning', 'Could not check file permissions', { error: error.message });
}
}
private getUptime(): string {
const uptimeSeconds = process.uptime();
const hours = Math.floor(uptimeSeconds / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
const seconds = Math.floor(uptimeSeconds % 60);
return `${hours}h ${minutes}m ${seconds}s`;
}
}
export const GET: APIRoute = async ({ request }) => {
try {
// Check authentication for health endpoint
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
if (authRequired) {
const sessionToken = getSessionFromRequest(request);
if (!sessionToken) {
return new Response(JSON.stringify({ error: 'Authentication required' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const session = await verifySession(sessionToken);
if (!session) {
return new Response(JSON.stringify({ error: 'Invalid session' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
}
// Run health checks
const monitor = new HealthMonitor();
const health = await monitor.runAllChecks();
// Determine HTTP status code based on overall health
let statusCode = 200;
if (health.overall === 'warning') {
statusCode = 200; // Still OK, but with warnings
} else if (health.overall === 'error') {
statusCode = 503; // Service Unavailable
}
return new Response(JSON.stringify(health), {
status: statusCode,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate'
}
});
} catch (error) {
console.error('Health check error:', error);
const errorResponse: SystemHealth = {
overall: 'error',
checks: [{
component: 'Health Monitor',
status: 'error',
message: 'Health check system failure',
details: { error: error.message },
lastChecked: new Date().toISOString()
}],
summary: { healthy: 0, warnings: 0, errors: 1 },
timestamp: new Date().toISOString()
};
return new Response(JSON.stringify(errorResponse), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};

View File

@ -0,0 +1,467 @@
// src/pages/api/contribute/knowledgebase.ts
import type { APIRoute } from 'astro';
import { getSessionFromRequest, verifySession } from '../../../utils/auth.js';
import { GitContributionManager } from '../../../utils/gitContributions.js';
import { z } from 'zod';
import { promises as fs } from 'fs';
import path from 'path';
export const prerender = false;
// Enhanced knowledgebase schema for contributions
const KnowledgebaseContributionSchema = z.object({
toolName: z.string().min(1, 'Tool name is required'),
title: z.string().min(5, 'Title must be at least 5 characters').max(100, 'Title too long'),
description: z.string().min(20, 'Description must be at least 20 characters').max(300, 'Description too long'),
content: z.string().min(50, 'Content must be at least 50 characters'),
difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert'], {
errorMap: () => ({ message: 'Invalid difficulty level' })
}),
categories: z.string().transform(str => {
try {
return JSON.parse(str);
} catch {
return [];
}
}).pipe(z.array(z.string()).default([])),
tags: z.string().transform(str => {
try {
return JSON.parse(str);
} catch {
return [];
}
}).pipe(z.array(z.string()).default([])),
sections: z.string().transform(str => {
try {
return JSON.parse(str);
} catch {
return {};
}
}).pipe(z.record(z.boolean()).default({})),
uploadedFiles: z.string().transform(str => {
try {
return JSON.parse(str);
} catch {
return [];
}
}).pipe(z.array(z.any()).default([]))
});
interface KnowledgebaseContributionData {
type: 'add' | 'edit';
article: {
toolName: string;
title: string;
description: string;
content: string;
difficulty: string;
categories: string[];
tags: string[];
sections: Record<string, boolean>;
uploadedFiles: any[];
};
metadata: {
submitter: string;
reason?: string;
};
}
// Rate limiting (same pattern as tool contributions)
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
const RATE_LIMIT_MAX = 10; // Max 10 submissions per hour
function checkRateLimit(userEmail: string): boolean {
const now = Date.now();
const userLimit = rateLimitStore.get(userEmail);
if (!userLimit || now > userLimit.resetTime) {
rateLimitStore.set(userEmail, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
return true;
}
if (userLimit.count >= RATE_LIMIT_MAX) {
return false;
}
userLimit.count++;
return true;
}
async function validateKnowledgebaseData(article: any): Promise<{ valid: boolean; errors: string[] }> {
const errors: string[] = [];
// Check if tool exists in the database
try {
const { getToolsData } = await import('../../../utils/dataService.js');
const data = await getToolsData();
const toolExists = data.tools.some((tool: any) => tool.name === article.toolName);
if (!toolExists) {
errors.push(`Tool "${article.toolName}" not found in database`);
}
} catch (error) {
errors.push('Failed to validate tool existence');
}
// Validate content quality
if (article.content.trim().split(/\s+/).length < 50) {
errors.push('Article content should be at least 50 words');
}
// Check for required sections based on difficulty
const requiredSections = {
'novice': ['overview'],
'beginner': ['overview'],
'intermediate': ['overview', 'usage_examples'],
'advanced': ['overview', 'usage_examples'],
'expert': ['overview', 'usage_examples', 'advanced_topics']
};
const required = requiredSections[article.difficulty as keyof typeof requiredSections] || [];
const missingSections = required.filter(section => !article.sections[section]);
if (missingSections.length > 0) {
errors.push(`Missing required sections for ${article.difficulty} difficulty: ${missingSections.join(', ')}`);
}
// Validate categories and tags
if (article.categories.length === 0) {
errors.push('At least one category is required');
}
const maxCategories = 5;
const maxTags = 10;
if (article.categories.length > maxCategories) {
errors.push(`Too many categories (max ${maxCategories})`);
}
if (article.tags.length > maxTags) {
errors.push(`Too many tags (max ${maxTags})`);
}
// Validate uploaded files
if (article.uploadedFiles.length > 20) {
errors.push('Too many uploaded files (max 20)');
}
return {
valid: errors.length === 0,
errors
};
}
function generateArticleSlug(title: string, toolName: string): string {
const baseSlug = title.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const toolSlug = toolName.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return `${toolSlug}-${baseSlug}`;
}
function generateMarkdownFrontmatter(article: any): string {
const now = new Date();
const frontmatter = {
title: article.title,
tool_name: article.toolName,
description: article.description,
last_updated: now.toISOString().split('T')[0], // YYYY-MM-DD format
author: 'CC24-Team',
difficulty: article.difficulty,
categories: article.categories,
tags: article.tags,
sections: article.sections,
review_status: 'draft'
};
return `---\n${Object.entries(frontmatter)
.map(([key, value]) => {
if (Array.isArray(value)) {
return `${key}: [${value.map(v => `"${v}"`).join(', ')}]`;
} else if (typeof value === 'object') {
const obj = Object.entries(value)
.map(([k, v]) => ` ${k}: ${v}`)
.join('\n');
return `${key}:\n${obj}`;
} else {
return `${key}: "${value}"`;
}
})
.join('\n')}\n---\n\n`;
}
// Extended GitContributionManager for knowledgebase
class KnowledgebaseGitManager extends GitContributionManager {
async submitKnowledgebaseContribution(data: KnowledgebaseContributionData): Promise<{success: boolean, message: string, prUrl?: string, branchName?: string}> {
const branchName = `kb-${data.type}-${Date.now()}`;
try {
// Create branch
await this.createBranch(branchName);
// Generate file content
const slug = generateArticleSlug(data.article.title, data.article.toolName);
const frontmatter = generateMarkdownFrontmatter(data.article);
const fullContent = frontmatter + data.article.content;
// Write article file
const articlePath = `src/content/knowledgebase/${slug}.md`;
await this.writeFile(articlePath, fullContent);
// Update tools.yaml to add knowledgebase flag
await this.updateToolKnowledgebaseFlag(data.article.toolName);
// Commit changes
const commitMessage = `${data.type === 'add' ? 'Add' : 'Update'} knowledgebase article: ${data.article.title}
Contributed by: ${data.metadata.submitter}
Tool: ${data.article.toolName}
Difficulty: ${data.article.difficulty}
Categories: ${data.article.categories.join(', ')}
${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''}`;
await this.commitChanges(commitMessage);
// Push branch
await this.pushBranch(branchName);
// Create pull request
const prUrl = await this.createPullRequest(
branchName,
`Knowledgebase: ${data.article.title}`,
this.generateKnowledgebasePRDescription(data)
);
return {
success: true,
message: `Knowledgebase article contribution submitted successfully`,
prUrl,
branchName
};
} catch (error) {
// Cleanup on failure
try {
await this.deleteBranch(branchName);
} catch (cleanupError) {
console.error('Failed to cleanup branch:', cleanupError);
}
throw error;
}
}
private async updateToolKnowledgebaseFlag(toolName: string): Promise<void> {
const toolsYamlPath = 'src/data/tools.yaml';
const { load, dump } = await import('js-yaml');
try {
const content = await this.readFile(toolsYamlPath);
const data = load(content) as any;
// Find and update the tool
const tool = data.tools.find((t: any) => t.name === toolName);
if (tool) {
tool.knowledgebase = true;
const updatedContent = dump(data, {
lineWidth: -1,
noRefs: true,
quotingType: '"',
forceQuotes: false
});
await this.writeFile(toolsYamlPath, updatedContent);
}
} catch (error) {
console.warn('Failed to update tools.yaml knowledgebase flag:', error);
// Don't fail the entire contribution for this
}
}
private generateKnowledgebasePRDescription(data: KnowledgebaseContributionData): string {
return `## Knowledgebase Article: ${data.article.title}
**Tool:** ${data.article.toolName}
**Type:** ${data.type === 'add' ? 'New Article' : 'Article Update'}
**Difficulty:** ${data.article.difficulty}
**Submitted by:** ${data.metadata.submitter}
### Article Details
- **Categories:** ${data.article.categories.join(', ')}
- **Tags:** ${data.article.tags.join(', ')}
- **Sections:** ${Object.entries(data.article.sections).filter(([_, enabled]) => enabled).map(([section, _]) => section).join(', ')}
- **Content Length:** ~${data.article.content.split(/\s+/).length} words
### Description
${data.article.description}
${data.metadata.reason ? `### Reason for Contribution\n${data.metadata.reason}\n` : ''}
### Review Checklist
- [ ] Article content is accurate and helpful
- [ ] Language is clear and appropriate for the difficulty level
- [ ] All sections are properly structured
- [ ] Categories and tags are relevant
- [ ] No sensitive or inappropriate content
- [ ] Links and references are valid
- [ ] Media files (if any) are appropriate
### Files Changed
- \`src/content/knowledgebase/${generateArticleSlug(data.article.title, data.article.toolName)}.md\` (${data.type})
- \`src/data/tools.yaml\` (knowledgebase flag update)
---
*This contribution was submitted via the CC24-Hub knowledgebase editor.*`;
}
}
export const POST: APIRoute = async ({ request }) => {
try {
// Check authentication
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
if (authRequired) {
const sessionToken = getSessionFromRequest(request);
if (!sessionToken) {
return new Response(JSON.stringify({ error: 'Authentication required' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const session = await verifySession(sessionToken);
if (!session) {
return new Response(JSON.stringify({ error: 'Invalid session' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const userEmail = session.email;
// Rate limiting
if (!checkRateLimit(userEmail)) {
return new Response(JSON.stringify({
error: 'Rate limit exceeded. Please wait before submitting again.'
}), {
status: 429,
headers: { 'Content-Type': 'application/json' }
});
}
// Parse form data
const formData = await request.formData();
const rawData = Object.fromEntries(formData);
// Validate request data
let validatedData;
try {
validatedData = KnowledgebaseContributionSchema.parse(rawData);
} catch (error) {
if (error instanceof z.ZodError) {
const errorMessages = error.errors.map(err =>
`${err.path.join('.')}: ${err.message}`
);
return new Response(JSON.stringify({
success: false,
error: 'Validation failed',
details: errorMessages
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response(JSON.stringify({
success: false,
error: 'Invalid request data'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// Additional knowledgebase-specific validation
const kbValidation = await validateKnowledgebaseData(validatedData);
if (!kbValidation.valid) {
return new Response(JSON.stringify({
success: false,
error: 'Knowledgebase validation failed',
details: kbValidation.errors
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// Prepare contribution data
const contributionData: KnowledgebaseContributionData = {
type: 'add', // For now, only support adding new articles
article: validatedData,
metadata: {
submitter: userEmail,
reason: rawData.reason as string || undefined
}
};
// Submit contribution via Git
const gitManager = new KnowledgebaseGitManager();
const result = await gitManager.submitKnowledgebaseContribution(contributionData);
if (result.success) {
// Log successful contribution
console.log(`[KB CONTRIBUTION] "${validatedData.title}" for ${validatedData.toolName} by ${userEmail} - PR: ${result.prUrl}`);
return new Response(JSON.stringify({
success: true,
message: result.message,
prUrl: result.prUrl,
branchName: result.branchName
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} else {
// Log failed contribution
console.error(`[KB CONTRIBUTION FAILED] "${validatedData.title}" by ${userEmail}: ${result.message}`);
return new Response(JSON.stringify({
success: false,
error: result.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
} else {
return new Response(JSON.stringify({
error: 'Authentication is disabled'
}), {
status: 501,
headers: { 'Content-Type': 'application/json' }
});
}
} catch (error) {
console.error('Knowledgebase contribution API error:', error);
return new Response(JSON.stringify({
success: false,
error: 'Internal server error'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};

View File

@ -0,0 +1,361 @@
// src/pages/api/upload/media.ts
import type { APIRoute } from 'astro';
import { getSessionFromRequest, verifySession } from '../../../utils/auth.js';
import { NextcloudUploader, isNextcloudConfigured } from '../../../utils/nextcloud.js';
import { promises as fs } from 'fs';
import path from 'path';
import crypto from 'crypto';
export const prerender = false;
interface UploadResult {
success: boolean;
url?: string;
filename?: string;
size?: number;
error?: string;
storage?: 'nextcloud' | 'local';
}
// Configuration
const UPLOAD_CONFIG = {
maxFileSize: 50 * 1024 * 1024, // 50MB
allowedTypes: new Set([
// Images
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
// Videos
'video/mp4', 'video/webm', 'video/ogg', 'video/avi', 'video/mov',
// Documents
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain', 'text/csv', 'application/json'
]),
localUploadPath: process.env.LOCAL_UPLOAD_PATH || './public/uploads',
publicBaseUrl: process.env.PUBLIC_BASE_URL || 'http://localhost:4321'
};
// Rate limiting for uploads
const uploadRateLimit = new Map<string, { count: number; resetTime: number }>();
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
const RATE_LIMIT_MAX = 100; // Max 100 uploads per hour per user
function checkUploadRateLimit(userEmail: string): boolean {
const now = Date.now();
const userLimit = uploadRateLimit.get(userEmail);
if (!userLimit || now > userLimit.resetTime) {
uploadRateLimit.set(userEmail, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
return true;
}
if (userLimit.count >= RATE_LIMIT_MAX) {
return false;
}
userLimit.count++;
return true;
}
function validateFile(file: File): { valid: boolean; error?: string } {
// Check file size
if (file.size > UPLOAD_CONFIG.maxFileSize) {
return {
valid: false,
error: `File too large (max ${Math.round(UPLOAD_CONFIG.maxFileSize / 1024 / 1024)}MB)`
};
}
// Check file type
if (!UPLOAD_CONFIG.allowedTypes.has(file.type)) {
return {
valid: false,
error: `File type not allowed: ${file.type}`
};
}
// Check filename
if (!file.name || file.name.trim().length === 0) {
return {
valid: false,
error: 'Invalid filename'
};
}
return { valid: true };
}
function sanitizeFilename(filename: string): string {
// Remove or replace unsafe characters
return filename
.replace(/[^a-zA-Z0-9._-]/g, '_') // Replace unsafe chars with underscore
.replace(/_{2,}/g, '_') // Replace multiple underscores with single
.replace(/^_|_$/g, '') // Remove leading/trailing underscores
.toLowerCase()
.substring(0, 100); // Limit length
}
function generateUniqueFilename(originalName: string): string {
const timestamp = Date.now();
const randomId = crypto.randomBytes(4).toString('hex');
const ext = path.extname(originalName);
const base = path.basename(originalName, ext);
const sanitizedBase = sanitizeFilename(base);
return `${timestamp}_${randomId}_${sanitizedBase}${ext}`;
}
async function uploadToLocal(file: File, category: string): Promise<UploadResult> {
try {
// Ensure upload directory exists
const categoryDir = path.join(UPLOAD_CONFIG.localUploadPath, sanitizeFilename(category));
await fs.mkdir(categoryDir, { recursive: true });
// Generate unique filename
const uniqueFilename = generateUniqueFilename(file.name);
const filePath = path.join(categoryDir, uniqueFilename);
// Convert file to buffer and write
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
await fs.writeFile(filePath, buffer);
// Generate public URL
const relativePath = path.posix.join('/uploads', sanitizeFilename(category), uniqueFilename);
const publicUrl = `${UPLOAD_CONFIG.publicBaseUrl}${relativePath}`;
return {
success: true,
url: publicUrl,
filename: uniqueFilename,
size: file.size,
storage: 'local'
};
} catch (error) {
console.error('Local upload error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Local upload failed',
storage: 'local'
};
}
}
async function uploadToNextcloud(file: File, category: string): Promise<UploadResult> {
try {
const uploader = new NextcloudUploader();
const result = await uploader.uploadFile(file, category);
return {
...result,
storage: 'nextcloud'
};
} catch (error) {
console.error('Nextcloud upload error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Nextcloud upload failed',
storage: 'nextcloud'
};
}
}
export const POST: APIRoute = async ({ request }) => {
try {
// Check authentication
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
let userEmail = 'anonymous';
if (authRequired) {
const sessionToken = getSessionFromRequest(request);
if (!sessionToken) {
return new Response(JSON.stringify({ error: 'Authentication required' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const session = await verifySession(sessionToken);
if (!session) {
return new Response(JSON.stringify({ error: 'Invalid session' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
userEmail = session.email;
}
// Rate limiting
if (!checkUploadRateLimit(userEmail)) {
return new Response(JSON.stringify({
error: 'Upload rate limit exceeded. Please wait before uploading more files.'
}), {
status: 429,
headers: { 'Content-Type': 'application/json' }
});
}
// Parse form data
const formData = await request.formData();
const file = formData.get('file') as File;
const type = formData.get('type') as string || 'general';
if (!file) {
return new Response(JSON.stringify({
error: 'No file provided'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// Validate file
const validation = validateFile(file);
if (!validation.valid) {
return new Response(JSON.stringify({
error: validation.error
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// Determine upload strategy
const useNextcloud = isNextcloudConfigured();
let result: UploadResult;
if (useNextcloud) {
// Try Nextcloud first, fallback to local
result = await uploadToNextcloud(file, type);
if (!result.success) {
console.warn('Nextcloud upload failed, falling back to local storage:', result.error);
result = await uploadToLocal(file, type);
}
} else {
// Use local storage
result = await uploadToLocal(file, type);
}
if (result.success) {
// Log successful upload
console.log(`[MEDIA UPLOAD] ${file.name} (${file.size} bytes) by ${userEmail} -> ${result.storage}: ${result.url}`);
return new Response(JSON.stringify(result), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} else {
// Log failed upload
console.error(`[MEDIA UPLOAD FAILED] ${file.name} by ${userEmail}: ${result.error}`);
return new Response(JSON.stringify(result), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
} catch (error) {
console.error('Media upload API error:', error);
return new Response(JSON.stringify({
success: false,
error: 'Internal server error'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};
// GET endpoint for upload status/info
export const GET: APIRoute = async ({ request }) => {
try {
// Check authentication
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
if (authRequired) {
const sessionToken = getSessionFromRequest(request);
if (!sessionToken) {
return new Response(JSON.stringify({ error: 'Authentication required' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const session = await verifySession(sessionToken);
if (!session) {
return new Response(JSON.stringify({ error: 'Invalid session' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
}
// Return upload configuration and status
const nextcloudConfigured = isNextcloudConfigured();
// Check local upload directory
let localStorageAvailable = false;
try {
await fs.access(UPLOAD_CONFIG.localUploadPath);
localStorageAvailable = true;
} catch {
try {
await fs.mkdir(UPLOAD_CONFIG.localUploadPath, { recursive: true });
localStorageAvailable = true;
} catch (error) {
console.warn('Local upload directory not accessible:', error);
}
}
const status = {
storage: {
nextcloud: {
configured: nextcloudConfigured,
primary: nextcloudConfigured
},
local: {
available: localStorageAvailable,
fallback: nextcloudConfigured,
primary: !nextcloudConfigured
}
},
limits: {
maxFileSize: UPLOAD_CONFIG.maxFileSize,
maxFileSizeMB: Math.round(UPLOAD_CONFIG.maxFileSize / 1024 / 1024),
allowedTypes: Array.from(UPLOAD_CONFIG.allowedTypes),
rateLimit: {
maxPerHour: RATE_LIMIT_MAX,
windowMs: RATE_LIMIT_WINDOW
}
},
paths: {
uploadEndpoint: '/api/upload/media',
localPath: localStorageAvailable ? '/uploads' : null
}
};
return new Response(JSON.stringify(status), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('Media upload status error:', error);
return new Response(JSON.stringify({
error: 'Failed to get upload status'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};

View File

@ -1,4 +1,5 @@
--- ---
// src/pages/auth/callback.astro - Fixed with Email
// Since server-side URL parameters aren't working, // Since server-side URL parameters aren't working,
// we'll handle this client-side and POST to the API // we'll handle this client-side and POST to the API
--- ---
@ -6,49 +7,118 @@
<html> <html>
<head> <head>
<title>Processing Authentication...</title> <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> </head>
<body> <body>
<div style="text-align: center; padding: 4rem; font-family: sans-serif;"> <div class="container">
<div class="spinner"></div>
<h2>Processing authentication...</h2> <h2>Processing authentication...</h2>
<p>Please wait while we complete your login.</p> <p>Please wait while we complete your login.</p>
<div id="error-message" style="display: none;" class="error"></div>
</div> </div>
<script> <script>
// Get URL parameters from client-side (function() {
const urlParams = new URLSearchParams(window.location.search); // Get URL parameters from client-side
const code = urlParams.get('code'); const urlParams = new URLSearchParams(window.location.search);
const state = urlParams.get('state'); const code = urlParams.get('code');
const error = urlParams.get('error'); const state = urlParams.get('state');
const error = urlParams.get('error');
console.log('Client-side callback params:', { code: !!code, state: !!state, error });
console.log('Client-side callback params:', { code: !!code, state: !!state, error });
if (error) {
window.location.href = '/?auth=error'; const errorDiv = document.getElementById('error-message') as HTMLElement;
} else if (code && state) {
// Send the parameters to our API endpoint if (error) {
fetch('/api/auth/process', { if (errorDiv) {
method: 'POST', errorDiv.textContent = `Authentication error: ${error}`;
headers: { errorDiv.style.display = 'block';
'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';
} }
}) setTimeout(() => {
.catch(error => { window.location.href = '/?auth=error';
console.error('Authentication processing failed:', error); }, 3000);
window.location.href = '/?auth=error'; } else if (code && state) {
}); // Send the parameters to our API endpoint
} else { fetch('/api/auth/process', {
console.error('Missing code or state parameters'); method: 'POST',
window.location.href = '/?auth=error'; 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> </script>
</body> </body>
</html> </html>

View File

@ -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 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 // Check authentication
const authContext = await getAuthContext(Astro); const authRequired = import.meta.env.AUTHENTICATION_NECESSARY !== 'false';
const authRedirect = requireAuth(authContext, Astro.url.toString()); let isAuthenticated = false;
if (authRedirect) return authRedirect; 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"> <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. 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. All contributions are reviewed before being merged into the main database.
</p> </p>
{userEmail && (
<p style="margin-top: 1rem; opacity: 0.8; font-size: 0.9rem;">
Logged in as: <strong>{userEmail}</strong>
</p>
)}
</div> </div>
<!-- Contribution Options --> <!-- Contribution Options -->
@ -126,6 +148,12 @@ if (authRedirect) return authRedirect;
</svg> </svg>
Report Issue Report Issue
</a> </a>
<a href="/api/contribute/health" class="btn btn-secondary" target="_blank">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
System Health
</a>
</div> </div>
</div> </div>
</div> </div>
@ -143,16 +171,18 @@ if (authRedirect) return authRedirect;
<li>Use clear, professional language</li> <li>Use clear, professional language</li>
<li>Include relevant tags and categorization</li> <li>Include relevant tags and categorization</li>
<li>Verify all URLs and links work correctly</li> <li>Verify all URLs and links work correctly</li>
<li>Test installation and configuration steps</li>
</ul> </ul>
</div> </div>
<div> <div>
<h4 style="margin-bottom: 0.75rem; color: var(--color-accent);">Review Process</h4> <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;"> <ul style="margin: 0; padding-left: 1.5rem; line-height: 1.6;">
<li>All contributions create pull requests</li> <li>All contributions are submitted as pull requests</li>
<li>Maintainers review within 48-72 hours</li> <li>Automated validation checks run on submissions</li>
<li>Feedback provided for requested changes</li> <li>Manual review by CC24 team members</li>
<li>Approved changes merged automatically</li> <li>Feedback provided through PR comments</li>
<li>Merge after approval and testing</li>
</ul> </ul>
</div> </div>
@ -160,34 +190,116 @@ if (authRedirect) return authRedirect;
<h4 style="margin-bottom: 0.75rem; color: var(--color-warning);">Best Practices</h4> <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;"> <ul style="margin: 0; padding-left: 1.5rem; line-height: 1.6;">
<li>Search existing entries before adding duplicates</li> <li>Search existing entries before adding duplicates</li>
<li>Include rationale for new additions</li> <li>Use consistent naming and categorization</li>
<li>Follow existing categorization patterns</li> <li>Provide detailed descriptions and use cases</li>
<li>Test tools/methods before recommending</li> <li>Include screenshots for complex procedures</li>
<li>Credit original sources and authors</li>
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
<!-- Statistics --> <!-- System Status -->
<div style="text-align: center; padding: 1.5rem; background-color: var(--color-bg-secondary); border-radius: 0.75rem;"> <div class="card" style="background-color: var(--color-bg-secondary);">
<p class="text-muted" style="margin: 0; font-size: 0.9375rem;"> <h3 style="margin-bottom: 1rem; color: var(--color-text);">System Status</h3>
<strong>Community Contributions:</strong> Help us maintain the most comprehensive DFIR resource available. <div id="system-status" style="display: flex; align-items: center; gap: 1rem;">
<br> <div style="width: 12px; height: 12px; background-color: var(--color-text-secondary); border-radius: 50%; animation: pulse 2s infinite;"></div>
Your contributions are credited and help the entire forensics community. <span style="color: var(--color-text-secondary);">Checking system health...</span>
</p> </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> </div>
</section> </section>
</BaseLayout>
<style> <style>
.card:hover { .card:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: var(--shadow-lg); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
}
@media (width <= 768px) {
div[style*="grid-template-columns: 2fr 1fr"] {
grid-template-columns: 1fr !important;
} }
}
</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>

View 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">
&lt;/&gt;
</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 = `![${selectedText || 'alt text'}](image-url)`;
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 = `![${name}](${url})`;
} 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>

View File

@ -1,3 +1,4 @@
// src/utils/auth.ts - Enhanced with Email Support
import { SignJWT, jwtVerify, type JWTPayload } from 'jose'; import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
import { serialize, parse } from 'cookie'; import { serialize, parse } from 'cookie';
import { config } from 'dotenv'; import { config } from 'dotenv';
@ -14,21 +15,34 @@ function getEnv(key: string): string {
return value; return value;
} }
const SECRET_KEY = new TextEncoder().encode(getEnv('AUTH_SECRET')); const SECRET_KEY = new TextEncoder().encode(
process.env.AUTH_SECRET ||
process.env.OIDC_CLIENT_SECRET ||
'cc24-hub-default-secret-key-change-in-production'
);
const SESSION_DURATION = 6 * 60 * 60; // 6 hours in seconds const SESSION_DURATION = 6 * 60 * 60; // 6 hours in seconds
export interface SessionData { export interface SessionData {
userId: string; userId: string;
email: string;
authenticated: boolean; authenticated: boolean;
exp: number; exp: number;
} }
// Create a signed JWT session token export interface UserInfo {
export async function createSession(userId: string): Promise<string> { sub?: string;
preferred_username?: string;
email?: string;
name?: string;
}
// Create a signed JWT session token with email
export async function createSession(userId: string, email: string): Promise<string> {
const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION; const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION;
return await new SignJWT({ return await new SignJWT({
userId, userId,
email,
authenticated: true, authenticated: true,
exp exp
}) })
@ -45,11 +59,13 @@ export async function verifySession(token: string): Promise<SessionData | null>
// Validate payload structure and cast properly // Validate payload structure and cast properly
if ( if (
typeof payload.userId === 'string' && typeof payload.userId === 'string' &&
typeof payload.email === 'string' &&
typeof payload.authenticated === 'boolean' && typeof payload.authenticated === 'boolean' &&
typeof payload.exp === 'number' typeof payload.exp === 'number'
) { ) {
return { return {
userId: payload.userId, userId: payload.userId,
email: payload.email,
authenticated: payload.authenticated, authenticated: payload.authenticated,
exp: payload.exp exp: payload.exp
}; };
@ -147,7 +163,7 @@ export async function exchangeCodeForTokens(code: string): Promise<any> {
} }
// Get user info from OIDC provider // Get user info from OIDC provider
export async function getUserInfo(accessToken: string): Promise<any> { export async function getUserInfo(accessToken: string): Promise<UserInfo> {
const oidcEndpoint = getEnv('OIDC_ENDPOINT'); const oidcEndpoint = getEnv('OIDC_ENDPOINT');
const response = await fetch(`${oidcEndpoint}/apps/oidc/userinfo`, { const response = await fetch(`${oidcEndpoint}/apps/oidc/userinfo`, {
@ -174,4 +190,10 @@ export function generateState(): string {
export function logAuthEvent(event: string, details?: any) { export function logAuthEvent(event: string, details?: any) {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
console.log(`[AUTH ${timestamp}] ${event}`, details ? JSON.stringify(details) : ''); console.log(`[AUTH ${timestamp}] ${event}`, details ? JSON.stringify(details) : '');
}
// Helper function to safely get email from user info
export function getUserEmail(userInfo: UserInfo): string {
return userInfo.email ||
`${userInfo.preferred_username || userInfo.sub || 'unknown'}@cc24.dev`;
} }

View File

@ -1,10 +1,10 @@
// src/utils/gitContributions.ts // src/utils/gitContributions.ts - Enhanced for Phase 3
import { execSync, spawn } from 'child_process'; import { execSync, spawn } from 'child_process';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { load, dump } from 'js-yaml'; import { load, dump } from 'js-yaml';
import path from 'path'; import path from 'path';
interface ContributionData { export interface ContributionData {
type: 'add' | 'edit'; type: 'add' | 'edit';
tool: { tool: {
name: string; name: string;
@ -31,7 +31,7 @@ interface ContributionData {
}; };
} }
interface GitOperationResult { export interface GitOperationResult {
success: boolean; success: boolean;
message: string; message: string;
prUrl?: string; prUrl?: string;
@ -48,8 +48,8 @@ interface GitConfig {
repoName: string; repoName: string;
} }
class GitContributionManager { export class GitContributionManager {
private config: GitConfig; protected config: GitConfig;
private activeBranches = new Set<string>(); private activeBranches = new Set<string>();
constructor() { constructor() {
@ -78,380 +78,137 @@ class GitContributionManager {
if (!match) { if (!match) {
throw new Error('Invalid repository URL format'); throw new Error('Invalid repository URL format');
} }
return { owner: match[1], name: match[2] };
return {
owner: match[1],
name: match[2]
};
} catch (error) { } catch (error) {
throw new Error(`Failed to parse repository URL: ${url}`); throw new Error(`Failed to parse repository URL: ${url}`);
} }
} }
async submitContribution(data: ContributionData): Promise<GitOperationResult> { // Enhanced git operations for Phase 3
const branchName = this.generateBranchName(data);
// Check if branch is already being processed
if (this.activeBranches.has(branchName)) {
return {
success: false,
message: 'A contribution with similar details is already being processed'
};
}
/**
* Create a new branch
*/
protected async createBranch(branchName: string): Promise<void> {
try { try {
this.activeBranches.add(branchName); // Ensure we're on main and up to date
execSync('git checkout main', { cwd: this.config.localRepoPath, stdio: 'pipe' });
// Ensure repository is in clean state execSync('git pull origin main', { cwd: this.config.localRepoPath, stdio: 'pipe' });
await this.ensureCleanRepo();
// Create and checkout new branch // Create and checkout new branch
await this.createBranch(branchName); execSync(`git checkout -b "${branchName}"`, { cwd: this.config.localRepoPath, stdio: 'pipe' });
// Modify tools.yaml this.activeBranches.add(branchName);
await this.modifyToolsYaml(data);
// Commit changes
await this.commitChanges(data, branchName);
// Push branch
await this.pushBranch(branchName);
// Create pull request
const prUrl = await this.createPullRequest(data, branchName);
return {
success: true,
message: 'Contribution submitted successfully',
prUrl,
branchName
};
} catch (error) {
console.error('Git contribution failed:', error);
// Attempt cleanup
await this.cleanup(branchName);
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error occurred'
};
} finally {
this.activeBranches.delete(branchName);
}
}
private generateBranchName(data: ContributionData): string {
const timestamp = Date.now();
const toolSlug = data.tool.name.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return `contrib-${data.type}-${toolSlug}-${timestamp}`;
}
private async executeGitCommand(command: string, options: { cwd?: string; timeout?: number } = {}): Promise<string> {
return new Promise((resolve, reject) => {
const { cwd = this.config.localRepoPath, timeout = 30000 } = options;
const child = spawn('sh', ['-c', command], {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
const timeoutId = setTimeout(() => {
child.kill('SIGTERM');
reject(new Error(`Git command timed out: ${command}`));
}, timeout);
child.on('close', (code) => {
clearTimeout(timeoutId);
if (code === 0) {
resolve(stdout.trim());
} else {
reject(new Error(`Git command failed (${code}): ${command}\n${stderr}`));
}
});
child.on('error', (error) => {
clearTimeout(timeoutId);
reject(new Error(`Failed to execute git command: ${error.message}`));
});
});
}
private async ensureCleanRepo(): Promise<void> {
try {
// Fetch latest changes
await this.executeGitCommand('git fetch origin');
// Reset to main branch
await this.executeGitCommand('git checkout main');
await this.executeGitCommand('git reset --hard origin/main');
// Clean untracked files
await this.executeGitCommand('git clean -fd');
} catch (error) {
throw new Error(`Failed to clean repository: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private async createBranch(branchName: string): Promise<void> {
try {
await this.executeGitCommand(`git checkout -b ${branchName}`);
} catch (error) { } catch (error) {
throw new Error(`Failed to create branch ${branchName}: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(`Failed to create branch ${branchName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
} }
private async modifyToolsYaml(data: ContributionData): Promise<void> { /**
* Write file to repository
*/
protected async writeFile(filePath: string, content: string): Promise<void> {
try { try {
const yamlPath = path.join(this.config.localRepoPath, 'src/data/tools.yaml'); const fullPath = path.join(this.config.localRepoPath, filePath);
const originalContent = await fs.readFile(yamlPath, 'utf8'); const dirPath = path.dirname(fullPath);
if (data.type === 'add') { // Ensure directory exists
// For adding, append to the tools section await fs.mkdir(dirPath, { recursive: true });
const newToolYaml = this.generateToolYaml(data.tool);
const updatedContent = this.insertNewTool(originalContent, newToolYaml); // Write file
await fs.writeFile(yamlPath, updatedContent, 'utf8'); await fs.writeFile(fullPath, content, 'utf8');
} else {
// For editing, we still need to parse and regenerate (unfortunately)
// But let's at least preserve the overall structure
const yamlData = load(originalContent) as any;
const existingIndex = yamlData.tools.findIndex((tool: any) => tool.name === data.tool.name);
if (existingIndex === -1) {
throw new Error(`Tool "${data.tool.name}" not found for editing`);
}
yamlData.tools[existingIndex] = this.normalizeToolObject(data.tool);
// Use consistent YAML formatting
const newYamlContent = dump(yamlData, {
lineWidth: 120,
noRefs: true,
sortKeys: false,
forceQuotes: false,
flowLevel: -1,
styles: {
'!!null': 'canonical'
}
});
await fs.writeFile(yamlPath, newYamlContent, 'utf8');
}
} catch (error) { } catch (error) {
throw new Error(`Failed to modify tools.yaml: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(`Failed to write file ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
} }
private normalizeToolObject(tool: any): any { /**
const normalized = { ...tool }; * Read file from repository
*/
// Convert empty strings and undefined to null protected async readFile(filePath: string): Promise<string> {
Object.keys(normalized).forEach(key => {
if (normalized[key] === '' || normalized[key] === undefined) {
normalized[key] = null;
}
});
// Ensure arrays are preserved as arrays (even if empty)
['domains', 'phases', 'platforms', 'tags', 'related_concepts'].forEach(key => {
if (!Array.isArray(normalized[key])) {
normalized[key] = [];
}
});
return normalized;
}
private generateToolYaml(tool: any): string {
const normalized = this.normalizeToolObject(tool);
let yaml = ` - name: ${normalized.name}\n`;
if (normalized.icon) yaml += ` icon: ${normalized.icon}\n`;
yaml += ` type: ${normalized.type}\n`;
// Handle description with proper formatting for long text
if (normalized.description) {
if (normalized.description.length > 80) {
yaml += ` description: >-\n`;
const words = normalized.description.split(' ');
let line = ' ';
for (const word of words) {
if ((line + word).length > 80 && line.length > 6) {
yaml += line.trimEnd() + '\n';
line = ' ' + word + ' ';
} else {
line += word + ' ';
}
}
yaml += line.trimEnd() + '\n';
} else {
yaml += ` description: ${normalized.description}\n`;
}
}
// Arrays
['domains', 'phases', 'platforms'].forEach(key => {
if (normalized[key] && normalized[key].length > 0) {
yaml += ` ${key}:\n`;
normalized[key].forEach((item: string) => {
yaml += ` - ${item}\n`;
});
} else {
yaml += ` ${key}: []\n`;
}
});
// Add other fields
if (normalized['domain-agnostic-software']) {
yaml += ` domain-agnostic-software: ${JSON.stringify(normalized['domain-agnostic-software'])}\n`;
} else {
yaml += ` domain-agnostic-software: null\n`;
}
yaml += ` skillLevel: ${normalized.skillLevel}\n`;
yaml += ` accessType: ${normalized.accessType || 'null'}\n`;
// Handle URL with proper formatting for long URLs
if (normalized.url) {
if (normalized.url.length > 80) {
yaml += ` url: >-\n ${normalized.url}\n`;
} else {
yaml += ` url: ${normalized.url}\n`;
}
}
yaml += ` projectUrl: ${normalized.projectUrl || 'null'}\n`;
yaml += ` license: ${normalized.license || 'null'}\n`;
yaml += ` knowledgebase: ${normalized.knowledgebase || 'null'}\n`;
// Related concepts
if (normalized.related_concepts && normalized.related_concepts.length > 0) {
yaml += ` related_concepts:\n`;
normalized.related_concepts.forEach((concept: string) => {
yaml += ` - ${concept}\n`;
});
} else {
yaml += ` related_concepts: null\n`;
}
// Tags
if (normalized.tags && normalized.tags.length > 0) {
yaml += ` tags:\n`;
normalized.tags.forEach((tag: string) => {
yaml += ` - ${tag}\n`;
});
} else {
yaml += ` tags: []\n`;
}
if (normalized.statusUrl) {
yaml += ` statusUrl: ${normalized.statusUrl}\n`;
}
return yaml;
}
private insertNewTool(originalContent: string, newToolYaml: string): string {
// Find the end of the tools section (before domains:)
const domainsIndex = originalContent.indexOf('\ndomains:');
if (domainsIndex === -1) {
// If no domains section, just append to end with proper spacing
return originalContent.trimEnd() + '\n\n' + newToolYaml.trimEnd() + '\n';
}
// Insert before the domains section with proper newline spacing
const beforeDomains = originalContent.slice(0, domainsIndex).trimEnd();
const afterDomains = originalContent.slice(domainsIndex);
return beforeDomains + '\n\n' + newToolYaml.trimEnd() + afterDomains;
}
private async commitChanges(data: ContributionData, branchName: string): Promise<void> {
try { try {
// Configure git user for this commit const fullPath = path.join(this.config.localRepoPath, filePath);
await this.executeGitCommand('git config user.name "CC24-Hub Contributors"'); return await fs.readFile(fullPath, 'utf8');
await this.executeGitCommand('git config user.email "contributors@cc24.dev"'); } catch (error) {
throw new Error(`Failed to read file ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Commit changes with message
*/
protected async commitChanges(message: string): Promise<void> {
try {
// Add all changes
execSync('git add .', { cwd: this.config.localRepoPath, stdio: 'pipe' });
// Stage changes // Check if there are any changes to commit
await this.executeGitCommand('git add src/data/tools.yaml'); try {
execSync('git diff --cached --exit-code', { cwd: this.config.localRepoPath, stdio: 'pipe' });
// If we get here, there are no changes
throw new Error('No changes to commit');
} catch (error) {
// This is expected - it means there are changes to commit
}
// Create commit message // Set git config if not already set
const action = data.type === 'add' ? 'Add' : 'Update'; try {
const commitMessage = `${action} ${data.tool.type}: ${data.tool.name} execSync('git config user.email "contributions@cc24-hub.local"', { cwd: this.config.localRepoPath, stdio: 'pipe' });
execSync('git config user.name "CC24-Hub Contributions"', { cwd: this.config.localRepoPath, stdio: 'pipe' });
Submitted by: ${data.metadata.submitter} } catch {
${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''} // Config might already be set
}
Branch: ${branchName}`;
// Commit changes
await this.executeGitCommand(`git commit -m "${commitMessage}"`); execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: this.config.localRepoPath, stdio: 'pipe' });
} catch (error) { } catch (error) {
throw new Error(`Failed to commit changes: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(`Failed to commit changes: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
} }
private async pushBranch(branchName: string): Promise<void> { /**
* Push branch to remote
*/
protected async pushBranch(branchName: string): Promise<void> {
try { try {
await this.executeGitCommand(`git push origin ${branchName}`); execSync(`git push -u origin "${branchName}"`, { cwd: this.config.localRepoPath, stdio: 'pipe' });
} catch (error) { } catch (error) {
throw new Error(`Failed to push branch ${branchName}: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(`Failed to push branch ${branchName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
} }
private async createPullRequest(data: ContributionData, branchName: string): Promise<string> { /**
const action = data.type === 'add' ? 'Add' : 'Update'; * Delete branch (cleanup)
const title = `${action} ${data.tool.type}: ${data.tool.name}`; */
protected async deleteBranch(branchName: string): Promise<void> {
const body = `## Contribution Details try {
// Switch to main first
**Type**: ${data.tool.type} execSync('git checkout main', { cwd: this.config.localRepoPath, stdio: 'pipe' });
**Action**: ${action}
**Submitted by**: ${data.metadata.submitter} // Delete local branch
execSync(`git branch -D "${branchName}"`, { cwd: this.config.localRepoPath, stdio: 'pipe' });
### Tool Information
- **Name**: ${data.tool.name} // Delete remote branch if it exists
- **Description**: ${data.tool.description} try {
- **Domains**: ${data.tool.domains.join(', ')} execSync(`git push origin --delete "${branchName}"`, { cwd: this.config.localRepoPath, stdio: 'pipe' });
- **Phases**: ${data.tool.phases.join(', ')} } catch {
- **Skill Level**: ${data.tool.skillLevel} // Branch might not exist on remote yet
- **License**: ${data.tool.license || 'N/A'} }
- **URL**: ${data.tool.url}
this.activeBranches.delete(branchName);
${data.metadata.reason ? `### Reason for Contribution\n${data.metadata.reason}` : ''}
} catch (error) {
### Review Checklist console.warn(`Failed to cleanup branch ${branchName}:`, error);
- [ ] Tool information is accurate and complete }
- [ ] Description is clear and informative }
- [ ] Domains and phases are correctly assigned
- [ ] Tags are relevant and consistent
- [ ] License information is correct
- [ ] URLs are valid and accessible
---
*This contribution was submitted via the CC24-Hub web interface.*`;
/**
* Create pull request
*/
protected async createPullRequest(branchName: string, title: string, body: string): Promise<string> {
try { try {
let apiUrl: string; let apiUrl: string;
let requestBody: any; let requestBody: any;
@ -528,86 +285,181 @@ ${data.metadata.reason ? `### Reason for Contribution\n${data.metadata.reason}`
} }
} }
private async cleanup(branchName: string): Promise<void> { // Original tool contribution methods (unchanged)
async submitContribution(data: ContributionData): Promise<GitOperationResult> {
const branchName = `tool-${data.type}-${Date.now()}`;
try { try {
// Switch back to main and delete the failed branch // Create branch
await this.executeGitCommand('git checkout main', { timeout: 10000 }); await this.createBranch(branchName);
await this.executeGitCommand(`git branch -D ${branchName}`, { timeout: 10000 });
// Try to delete remote branch if it exists // Load current tools.yaml
try { const toolsYamlPath = 'src/data/tools.yaml';
await this.executeGitCommand(`git push origin --delete ${branchName}`, { timeout: 10000 }); const content = await this.readFile(toolsYamlPath);
} catch (error) { const yamlData = load(content) as any;
// Ignore errors when deleting remote branch (might not exist)
console.warn(`Could not delete remote branch ${branchName}:`, error); if (!yamlData.tools) {
yamlData.tools = [];
} }
// Apply changes
if (data.type === 'add') {
// Check if tool already exists
const existing = yamlData.tools.find((t: any) => t.name === data.tool.name);
if (existing) {
throw new Error(`Tool "${data.tool.name}" already exists`);
}
yamlData.tools.push(data.tool);
} else if (data.type === 'edit') {
const index = yamlData.tools.findIndex((t: any) => t.name === data.tool.name);
if (index === -1) {
throw new Error(`Tool "${data.tool.name}" not found`);
}
yamlData.tools[index] = { ...yamlData.tools[index], ...data.tool };
}
// Sort tools alphabetically
yamlData.tools.sort((a: any, b: any) => a.name.localeCompare(b.name));
// Generate updated YAML
const updatedYaml = dump(yamlData, {
lineWidth: -1,
noRefs: true,
quotingType: '"',
forceQuotes: false
});
// Write updated file
await this.writeFile(toolsYamlPath, updatedYaml);
// Commit changes
const commitMessage = `${data.type === 'add' ? 'Add' : 'Update'} tool: ${data.tool.name}
Contributed by: ${data.metadata.submitter}
Type: ${data.tool.type}
${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''}`;
await this.commitChanges(commitMessage);
// Push branch
await this.pushBranch(branchName);
// Create pull request
const prUrl = await this.createPullRequest(
branchName,
`${data.type === 'add' ? 'Add' : 'Update'} tool: ${data.tool.name}`,
this.generatePRDescription(data)
);
return {
success: true,
message: `Tool contribution submitted successfully`,
prUrl,
branchName
};
} catch (error) { } catch (error) {
console.error(`Cleanup failed for branch ${branchName}:`, error); // Cleanup on failure
try {
await this.deleteBranch(branchName);
} catch (cleanupError) {
console.error('Failed to cleanup branch:', cleanupError);
}
throw error;
} }
} }
async checkHealth(): Promise<{ healthy: boolean; issues?: string[] }> { private generatePRDescription(data: ContributionData): string {
return `## Tool ${data.type === 'add' ? 'Addition' : 'Update'}: ${data.tool.name}
**Type:** ${data.tool.type}
**Submitted by:** ${data.metadata.submitter}
### Tool Details
- **Description:** ${data.tool.description}
- **Domains:** ${data.tool.domains.join(', ')}
- **Phases:** ${data.tool.phases.join(', ')}
- **Skill Level:** ${data.tool.skillLevel}
- **License:** ${data.tool.license || 'Not specified'}
- **URL:** ${data.tool.url}
${data.metadata.reason ? `### Reason for Contribution\n${data.metadata.reason}` : ''}
### Review Checklist
- [ ] Tool information is accurate and complete
- [ ] Description is clear and informative
- [ ] Domains and phases are correctly assigned
- [ ] Tags are relevant and consistent
- [ ] License information is correct
- [ ] URLs are valid and accessible
---
*This contribution was submitted via the CC24-Hub web interface.*`;
}
async checkHealth(): Promise<{healthy: boolean, issues?: string[]}> {
const issues: string[] = []; const issues: string[] = [];
try { try {
// Check if local repo exists and is a git repository // Check if local repo exists and is accessible
const repoExists = await fs.access(this.config.localRepoPath).then(() => true).catch(() => false); try {
if (!repoExists) { await fs.access(this.config.localRepoPath);
issues.push(`Local repository path does not exist: ${this.config.localRepoPath}`); } catch {
return { healthy: false, issues }; issues.push('Local repository path not accessible');
} }
const gitDirExists = await fs.access(path.join(this.config.localRepoPath, '.git')).then(() => true).catch(() => false);
if (!gitDirExists) {
issues.push('Local path is not a git repository');
return { healthy: false, issues };
}
// Check git status // Check git status
try { try {
await this.executeGitCommand('git status --porcelain', { timeout: 5000 }); execSync('git status', { cwd: this.config.localRepoPath, stdio: 'pipe' });
} catch (error) { } catch {
issues.push(`Git status check failed: ${error instanceof Error ? error.message : 'Unknown error'}`); issues.push('Local repository is not a valid git repository');
} }
// Check remote connectivity // Test API connectivity
try { try {
await this.executeGitCommand('git ls-remote origin HEAD', { timeout: 10000 }); let testUrl: string;
} catch (error) { switch (this.config.provider) {
issues.push(`Remote connectivity check failed: ${error instanceof Error ? error.message : 'Unknown error'}`); case 'gitea':
} testUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}`;
break;
// Check API connectivity case 'github':
try { testUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}`;
const response = await fetch(this.config.apiEndpoint, { break;
headers: { 'Authorization': `Bearer ${this.config.apiToken}` }, case 'gitlab':
signal: AbortSignal.timeout(5000) testUrl = `${this.config.apiEndpoint}/projects/${encodeURIComponent(this.config.repoOwner + '/' + this.config.repoName)}`;
break;
default:
throw new Error('Unknown provider');
}
const response = await fetch(testUrl, {
headers: {
'Authorization': `Bearer ${this.config.apiToken}`
}
}); });
if (!response.ok && response.status !== 404) { // 404 is expected for base API endpoint if (!response.ok) {
issues.push(`API connectivity check failed: HTTP ${response.status}`); issues.push(`API connectivity failed: ${response.status}`);
} }
} catch (error) { } catch (error) {
issues.push(`API connectivity check failed: ${error instanceof Error ? error.message : 'Unknown error'}`); issues.push(`API test failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
// Check write permissions return {
try { healthy: issues.length === 0,
const testFile = path.join(this.config.localRepoPath, '.write-test'); issues: issues.length > 0 ? issues : undefined
await fs.writeFile(testFile, 'test'); };
await fs.unlink(testFile);
} catch (error) {
issues.push(`Write permission check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
return { healthy: issues.length === 0, issues: issues.length > 0 ? issues : undefined };
} catch (error) { } catch (error) {
issues.push(`Health check failed: ${error instanceof Error ? error.message : 'Unknown error'}`); return {
return { healthy: false, issues }; healthy: false,
issues: [`Health check failed: ${error instanceof Error ? error.message : 'Unknown error'}`]
};
} }
} }
} }
export { GitContributionManager, type ContributionData, type GitOperationResult };

332
src/utils/markdown.ts Normal file
View File

@ -0,0 +1,332 @@
// src/utils/markdown.ts
// Simple markdown parser for client-side preview functionality
// Note: For production, consider using a proper markdown library like marked or markdown-it
export interface MarkdownParseOptions {
sanitize?: boolean;
breaks?: boolean;
linkTarget?: string;
}
export class SimpleMarkdownParser {
private options: MarkdownParseOptions;
constructor(options: MarkdownParseOptions = {}) {
this.options = {
sanitize: true,
breaks: true,
linkTarget: '_blank',
...options
};
}
/**
* Parse markdown to HTML
*/
parse(markdown: string): string {
if (!markdown || markdown.trim().length === 0) {
return '';
}
let html = markdown;
// Handle code blocks first (to prevent processing content inside them)
html = this.parseCodeBlocks(html);
// Parse headers
html = this.parseHeaders(html);
// Parse bold and italic
html = this.parseEmphasis(html);
// Parse links and images
html = this.parseLinksAndImages(html);
// Parse inline code
html = this.parseInlineCode(html);
// Parse lists
html = this.parseLists(html);
// Parse blockquotes
html = this.parseBlockquotes(html);
// Parse horizontal rules
html = this.parseHorizontalRules(html);
// Parse line breaks and paragraphs
html = this.parseLineBreaks(html);
// Sanitize if needed
if (this.options.sanitize) {
html = this.sanitizeHtml(html);
}
return html.trim();
}
private parseCodeBlocks(html: string): string {
// Replace code blocks with placeholders to protect them
const codeBlocks: string[] = [];
// Match ```code``` blocks
html = html.replace(/```([\s\S]*?)```/g, (match, code) => {
const index = codeBlocks.length;
const lang = code.split('\n')[0].trim();
const content = code.includes('\n') ? code.substring(code.indexOf('\n') + 1) : code;
codeBlocks.push(`<pre><code class="language-${this.escapeHtml(lang)}">${this.escapeHtml(content.trim())}</code></pre>`);
return `__CODEBLOCK_${index}__`;
});
// Restore code blocks at the end
codeBlocks.forEach((block, index) => {
html = html.replace(`__CODEBLOCK_${index}__`, block);
});
return html;
}
private parseHeaders(html: string): string {
// H1-H6 headers
for (let i = 6; i >= 1; i--) {
const headerRegex = new RegExp(`^#{${i}}\\s+(.+)$`, 'gm');
html = html.replace(headerRegex, `<h${i}>$1</h${i}>`);
}
return html;
}
private parseEmphasis(html: string): string {
// Bold: **text** or __text__
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__(.*?)__/g, '<strong>$1</strong>');
// Italic: *text* or _text_
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
html = html.replace(/_(.*?)_/g, '<em>$1</em>');
return html;
}
private parseLinksAndImages(html: string): string {
const linkTarget = this.options.linkTarget ? ` target="${this.options.linkTarget}" rel="noopener noreferrer"` : '';
// Images: ![alt](src)
html = html.replace(/!\[([^\]]*)\]\(([^)]*)\)/g,
'<img src="$2" alt="$1" style="max-width: 100%; height: auto; border-radius: 0.25rem; margin: 0.5rem 0;" />');
// Links: [text](url)
html = html.replace(/\[([^\]]*)\]\(([^)]*)\)/g,
`<a href="$2"${linkTarget}>$1</a>`);
return html;
}
private parseInlineCode(html: string): string {
// Inline code: `code`
html = html.replace(/`([^`]*)`/g, '<code>$1</code>');
return html;
}
private parseLists(html: string): string {
// Unordered lists
html = html.replace(/^[\s]*[-*+]\s+(.+)$/gm, '<li>$1</li>');
// Ordered lists
html = html.replace(/^[\s]*\d+\.\s+(.+)$/gm, '<li>$1</li>');
// Wrap consecutive list items in ul/ol
html = html.replace(/(<li>.*<\/li>)/s, (match) => {
// Simple approach: assume unordered list
return `<ul>${match}</ul>`;
});
return html;
}
private parseBlockquotes(html: string): string {
// Blockquotes: > text
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
// Merge consecutive blockquotes
html = html.replace(/(<\/blockquote>)\s*(<blockquote>)/g, ' ');
return html;
}
private parseHorizontalRules(html: string): string {
// Horizontal rules: --- or ***
html = html.replace(/^[-*]{3,}$/gm, '<hr>');
return html;
}
private parseLineBreaks(html: string): string {
if (!this.options.breaks) {
return html;
}
// Split into paragraphs (double line breaks)
const paragraphs = html.split(/\n\s*\n/);
const processedParagraphs = paragraphs.map(paragraph => {
const trimmed = paragraph.trim();
// Skip if already wrapped in HTML tag
if (trimmed.startsWith('<') && trimmed.endsWith('>')) {
return trimmed;
}
// Single line breaks become <br>
const withBreaks = trimmed.replace(/\n/g, '<br>');
// Wrap in paragraph if not empty and not already a block element
if (withBreaks && !this.isBlockElement(withBreaks)) {
return `<p>${withBreaks}</p>`;
}
return withBreaks;
});
return processedParagraphs.filter(p => p.trim()).join('\n\n');
}
private isBlockElement(html: string): boolean {
const blockTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'div', 'ul', 'ol', 'li', 'blockquote', 'pre', 'hr'];
return blockTags.some(tag => html.startsWith(`<${tag}`));
}
private sanitizeHtml(html: string): string {
// Very basic HTML sanitization - for production use a proper library
const allowedTags = [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'strong', 'em', 'code', 'pre',
'a', 'img', 'ul', 'ol', 'li', 'blockquote', 'hr'
];
// Remove script tags and event handlers
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
html = html.replace(/\bon\w+\s*=\s*"[^"]*"/gi, '');
html = html.replace(/\bon\w+\s*=\s*'[^']*'/gi, '');
html = html.replace(/javascript:/gi, '');
// This is a very basic sanitizer - for production use a proper library like DOMPurify
return html;
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Extract plain text from markdown (for word/character counting)
*/
extractText(markdown: string): string {
// Remove markdown syntax and return plain text
let text = markdown;
// Remove code blocks
text = text.replace(/```[\s\S]*?```/g, '');
// Remove inline code
text = text.replace(/`[^`]*`/g, '');
// Remove images
text = text.replace(/!\[[^\]]*\]\([^)]*\)/g, '');
// Remove links but keep text
text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1');
// Remove headers
text = text.replace(/^#{1,6}\s+/gm, '');
// Remove emphasis
text = text.replace(/\*\*(.*?)\*\*/g, '$1');
text = text.replace(/\*(.*?)\*/g, '$1');
text = text.replace(/__(.*?)__/g, '$1');
text = text.replace(/_(.*?)_/g, '$1');
// Remove blockquotes
text = text.replace(/^>\s+/gm, '');
// Remove list markers
text = text.replace(/^[\s]*[-*+]\s+/gm, '');
text = text.replace(/^[\s]*\d+\.\s+/gm, '');
// Remove horizontal rules
text = text.replace(/^[-*]{3,}$/gm, '');
// Clean up whitespace
text = text.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim();
return text;
}
/**
* Count words in markdown text
*/
countWords(markdown: string): number {
const plainText = this.extractText(markdown);
if (!plainText.trim()) return 0;
return plainText.trim().split(/\s+/).length;
}
/**
* Count characters in markdown text
*/
countCharacters(markdown: string): number {
return this.extractText(markdown).length;
}
/**
* Generate table of contents from headers
*/
generateTOC(markdown: string): Array<{level: number, text: string, anchor: string}> {
const headers: Array<{level: number, text: string, anchor: string}> = [];
const lines = markdown.split('\n');
lines.forEach(line => {
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length;
const text = headerMatch[2].trim();
const anchor = text.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
headers.push({ level, text, anchor });
}
});
return headers;
}
}
// Convenience functions for global use
export function parseMarkdown(markdown: string, options?: MarkdownParseOptions): string {
const parser = new SimpleMarkdownParser(options);
return parser.parse(markdown);
}
export function extractTextFromMarkdown(markdown: string): string {
const parser = new SimpleMarkdownParser();
return parser.extractText(markdown);
}
export function countWordsInMarkdown(markdown: string): number {
const parser = new SimpleMarkdownParser();
return parser.countWords(markdown);
}
export function countCharactersInMarkdown(markdown: string): number {
const parser = new SimpleMarkdownParser();
return parser.countCharacters(markdown);
}
export function generateMarkdownTOC(markdown: string): Array<{level: number, text: string, anchor: string}> {
const parser = new SimpleMarkdownParser();
return parser.generateTOC(markdown);
}

400
src/utils/nextcloud.ts Normal file
View File

@ -0,0 +1,400 @@
// src/utils/nextcloud.ts
import { promises as fs } from 'fs';
import path from 'path';
import crypto from 'crypto';
interface NextcloudConfig {
endpoint: string;
username: string;
password: string;
uploadPath: string;
publicBaseUrl: string;
}
interface UploadResult {
success: boolean;
url?: string;
filename?: string;
error?: string;
size?: number;
}
interface FileValidation {
valid: boolean;
error?: string;
sanitizedName?: string;
}
export class NextcloudUploader {
private config: NextcloudConfig;
private allowedTypes: Set<string>;
private maxFileSize: number; // in bytes
constructor() {
this.config = {
endpoint: process.env.NEXTCLOUD_ENDPOINT || '',
username: process.env.NEXTCLOUD_USERNAME || '',
password: process.env.NEXTCLOUD_PASSWORD || '',
uploadPath: process.env.NEXTCLOUD_UPLOAD_PATH || '/kb-media',
publicBaseUrl: process.env.NEXTCLOUD_PUBLIC_URL || ''
};
// Allowed file types for knowledge base
this.allowedTypes = new Set([
// Images
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
// Videos
'video/mp4', 'video/webm', 'video/ogg', 'video/avi', 'video/mov',
// Documents
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
// Text files
'text/plain', 'text/csv', 'application/json',
// Archives (for tool downloads)
'application/zip', 'application/x-tar', 'application/gzip'
]);
this.maxFileSize = 50 * 1024 * 1024; // 50MB
}
/**
* Check if Nextcloud upload is properly configured
*/
isConfigured(): boolean {
return !!(this.config.endpoint &&
this.config.username &&
this.config.password &&
this.config.publicBaseUrl);
}
/**
* Validate file before upload
*/
private validateFile(file: File): FileValidation {
// Check file size
if (file.size > this.maxFileSize) {
return {
valid: false,
error: `File too large (max ${Math.round(this.maxFileSize / 1024 / 1024)}MB)`
};
}
// Check file type
if (!this.allowedTypes.has(file.type)) {
return {
valid: false,
error: `File type not allowed: ${file.type}`
};
}
// Sanitize filename
const sanitizedName = this.sanitizeFilename(file.name);
if (!sanitizedName) {
return {
valid: false,
error: 'Invalid filename'
};
}
return {
valid: true,
sanitizedName
};
}
/**
* Sanitize filename for safe storage
*/
private sanitizeFilename(filename: string): string {
// Remove or replace unsafe characters
const sanitized = filename
.replace(/[^a-zA-Z0-9._-]/g, '_') // Replace unsafe chars with underscore
.replace(/_{2,}/g, '_') // Replace multiple underscores with single
.replace(/^_|_$/g, '') // Remove leading/trailing underscores
.toLowerCase();
// Ensure reasonable length
if (sanitized.length > 100) {
const ext = path.extname(sanitized);
const base = path.basename(sanitized, ext).substring(0, 90);
return base + ext;
}
return sanitized;
}
/**
* Generate unique filename to prevent conflicts
*/
private generateUniqueFilename(originalName: string): string {
const timestamp = Date.now();
const randomId = crypto.randomBytes(4).toString('hex');
const ext = path.extname(originalName);
const base = path.basename(originalName, ext);
return `${timestamp}_${randomId}_${base}${ext}`;
}
/**
* Upload file to Nextcloud
*/
async uploadFile(file: File, category: string = 'general'): Promise<UploadResult> {
try {
if (!this.isConfigured()) {
return {
success: false,
error: 'Nextcloud not configured'
};
}
// Validate file
const validation = this.validateFile(file);
if (!validation.valid) {
return {
success: false,
error: validation.error
};
}
// Generate unique filename
const uniqueFilename = this.generateUniqueFilename(validation.sanitizedName!);
// Create category-based path
const categoryPath = this.sanitizeFilename(category);
const remotePath = `${this.config.uploadPath}/${categoryPath}/${uniqueFilename}`;
// Convert file to buffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Upload to Nextcloud via WebDAV
const uploadUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`;
const response = await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`,
'Content-Type': file.type,
'Content-Length': buffer.length.toString()
},
body: buffer
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
// Generate public URL
const publicUrl = await this.createPublicLink(remotePath);
return {
success: true,
url: publicUrl,
filename: uniqueFilename,
size: file.size
};
} catch (error) {
console.error('Nextcloud upload error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Upload failed'
};
}
}
/**
* Create a public share link for the uploaded file
*/
private async createPublicLink(remotePath: string): Promise<string> {
try {
// Use Nextcloud's share API to create public link
const shareUrl = `${this.config.endpoint}/ocs/v2.php/apps/files_sharing/api/v1/shares`;
const formData = new FormData();
formData.append('path', remotePath);
formData.append('shareType', '3'); // Public link
formData.append('permissions', '1'); // Read only
const response = await fetch(shareUrl, {
method: 'POST',
headers: {
'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`,
'OCS-APIRequest': 'true'
},
body: formData
});
if (!response.ok) {
throw new Error('Failed to create public link');
}
const text = await response.text();
// Parse XML response to extract share URL
const urlMatch = text.match(/<url>(.*?)<\/url>/);
if (urlMatch) {
return urlMatch[1];
}
// Fallback to direct URL construction
return `${this.config.publicBaseUrl}${remotePath}`;
} catch (error) {
console.warn('Failed to create public link, using direct URL:', error);
// Fallback to direct URL
return `${this.config.publicBaseUrl}${remotePath}`;
}
}
/**
* Delete file from Nextcloud
*/
async deleteFile(remotePath: string): Promise<{ success: boolean; error?: string }> {
try {
if (!this.isConfigured()) {
return { success: false, error: 'Nextcloud not configured' };
}
const deleteUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`;
const response = await fetch(deleteUrl, {
method: 'DELETE',
headers: {
'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`
}
});
if (response.ok || response.status === 404) {
return { success: true };
}
throw new Error(`Delete failed: ${response.status} ${response.statusText}`);
} catch (error) {
console.error('Nextcloud delete error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Delete failed'
};
}
}
/**
* Check Nextcloud connectivity and authentication
*/
async testConnection(): Promise<{ success: boolean; error?: string; details?: any }> {
try {
if (!this.isConfigured()) {
return {
success: false,
error: 'Nextcloud not configured',
details: {
hasEndpoint: !!this.config.endpoint,
hasUsername: !!this.config.username,
hasPassword: !!this.config.password,
hasPublicUrl: !!this.config.publicBaseUrl
}
};
}
// Test with a simple WebDAV request
const testUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}/`;
const response = await fetch(testUrl, {
method: 'PROPFIND',
headers: {
'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`,
'Depth': '0'
}
});
if (response.ok) {
return {
success: true,
details: {
endpoint: this.config.endpoint,
username: this.config.username,
uploadPath: this.config.uploadPath
}
};
}
throw new Error(`Connection failed: ${response.status} ${response.statusText}`);
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Connection test failed'
};
}
}
/**
* Get file information from Nextcloud
*/
async getFileInfo(remotePath: string): Promise<{ success: boolean; info?: any; error?: string }> {
try {
const propfindUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`;
const response = await fetch(propfindUrl, {
method: 'PROPFIND',
headers: {
'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`,
'Depth': '0'
}
});
if (response.ok) {
const text = await response.text();
// Parse basic file info from WebDAV response
return {
success: true,
info: {
path: remotePath,
exists: true,
response: text.substring(0, 200) + '...' // Truncated for safety
}
};
}
if (response.status === 404) {
return {
success: true,
info: {
path: remotePath,
exists: false
}
};
}
throw new Error(`Failed to get file info: ${response.status}`);
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get file info'
};
}
}
}
// Convenience functions for easy usage
export async function uploadToNextcloud(file: File, category: string = 'general'): Promise<UploadResult> {
const uploader = new NextcloudUploader();
return await uploader.uploadFile(file, category);
}
export async function testNextcloudConnection(): Promise<{ success: boolean; error?: string; details?: any }> {
const uploader = new NextcloudUploader();
return await uploader.testConnection();
}
export function isNextcloudConfigured(): boolean {
const uploader = new NextcloudUploader();
return uploader.isConfigured();
}