iterate on contrib

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

View File

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

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

332
src/utils/markdown.ts Normal file
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();
}