iterate on contrib
This commit is contained in:
@@ -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`;
|
||||
}
|
||||
@@ -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
332
src/utils/markdown.ts
Normal 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: 
|
||||
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
400
src/utils/nextcloud.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user