import { Injectable, BadRequestException } from "@nestjs/common"; import { EntryStatus, Visibility } from "@prisma/client"; import archiver from "archiver"; import AdmZip from "adm-zip"; import matter from "gray-matter"; import { Readable } from "stream"; import { PrismaService } from "../../prisma/prisma.service"; import { KnowledgeService } from "../knowledge.service"; import { ExportFormat } from "../dto"; import type { ImportResult } from "../dto"; import type { CreateEntryDto } from "../dto/create-entry.dto"; interface ExportEntry { id: string; slug: string; title: string; content: string; summary: string | null; status: EntryStatus; visibility: Visibility; tags: string[]; createdAt: Date; updatedAt: Date; } /** * Service for handling knowledge entry import/export operations */ @Injectable() export class ImportExportService { constructor( private readonly prisma: PrismaService, private readonly knowledgeService: KnowledgeService ) {} /** * Import entries from uploaded file(s) * Accepts single .md file or .zip containing multiple .md files */ async importEntries( workspaceId: string, userId: string, file: Express.Multer.File ): Promise<{ results: ImportResult[]; totalFiles: number; imported: number; failed: number }> { const results: ImportResult[] = []; try { if (file.mimetype === "text/markdown" || file.originalname.endsWith(".md")) { // Single markdown file const result = await this.importSingleMarkdown( workspaceId, userId, file.originalname, file.buffer.toString("utf-8") ); results.push(result); } else if ( file.mimetype === "application/zip" || file.mimetype === "application/x-zip-compressed" || file.originalname.endsWith(".zip") ) { // Zip file containing multiple markdown files const zipResults = await this.importZipFile(workspaceId, userId, file.buffer); results.push(...zipResults); } else { throw new BadRequestException("Invalid file type. Only .md and .zip files are accepted."); } } catch (error) { throw new BadRequestException( `Failed to import file: ${error instanceof Error ? error.message : "Unknown error"}` ); } const imported = results.filter((r) => r.success).length; const failed = results.filter((r) => !r.success).length; return { results, totalFiles: results.length, imported, failed, }; } /** * Import a single markdown file */ private async importSingleMarkdown( workspaceId: string, userId: string, filename: string, content: string ): Promise { try { // Parse frontmatter const parsed = matter(content); const frontmatter = parsed.data; const markdownContent = parsed.content.trim(); if (!markdownContent) { return { filename, success: false, error: "Empty content", }; } // Build CreateEntryDto from frontmatter and content const parsedStatus = this.parseStatus(frontmatter.status as string | undefined); const parsedVisibility = this.parseVisibility(frontmatter.visibility as string | undefined); const parsedTags = Array.isArray(frontmatter.tags) ? (frontmatter.tags as string[]) : undefined; const createDto: CreateEntryDto = { title: typeof frontmatter.title === "string" ? frontmatter.title : filename.replace(/\.md$/, ""), content: markdownContent, changeNote: "Imported from markdown file", ...(typeof frontmatter.summary === "string" && { summary: frontmatter.summary }), ...(parsedStatus && { status: parsedStatus }), ...(parsedVisibility && { visibility: parsedVisibility }), ...(parsedTags && { tags: parsedTags }), }; // Create the entry const entry = await this.knowledgeService.create(workspaceId, userId, createDto); return { filename, success: true, entryId: entry.id, slug: entry.slug, title: entry.title, }; } catch (error) { return { filename, success: false, error: error instanceof Error ? error.message : "Unknown error", }; } } /** * Import entries from a zip file */ private async importZipFile( workspaceId: string, userId: string, buffer: Buffer ): Promise { const results: ImportResult[] = []; const MAX_FILES = 1000; // Prevent zip bomb attacks const MAX_TOTAL_SIZE = 100 * 1024 * 1024; // 100MB total uncompressed try { const zip = new AdmZip(buffer); const zipEntries = zip.getEntries(); // Security: Check for zip bombs let totalUncompressedSize = 0; let fileCount = 0; for (const entry of zipEntries) { if (!entry.isDirectory) { fileCount++; totalUncompressedSize += entry.header.size; } } if (fileCount > MAX_FILES) { throw new BadRequestException( `Zip file contains too many files (${fileCount.toString()}). Maximum allowed: ${MAX_FILES.toString()}` ); } if (totalUncompressedSize > MAX_TOTAL_SIZE) { throw new BadRequestException( `Zip file is too large when uncompressed (${Math.round(totalUncompressedSize / 1024 / 1024).toString()}MB). Maximum allowed: ${Math.round(MAX_TOTAL_SIZE / 1024 / 1024).toString()}MB` ); } for (const zipEntry of zipEntries) { // Skip directories and non-markdown files if (zipEntry.isDirectory || !zipEntry.entryName.endsWith(".md")) { continue; } // Security: Prevent path traversal attacks const normalizedPath = zipEntry.entryName.replace(/\\/g, "/"); if ( normalizedPath.includes("..") || normalizedPath.startsWith("/") || normalizedPath.includes("//") ) { results.push({ filename: zipEntry.entryName, success: false, error: "Invalid file path detected (potential path traversal)", }); continue; } const content = zipEntry.getData().toString("utf-8"); const result = await this.importSingleMarkdown( workspaceId, userId, zipEntry.entryName, content ); results.push(result); } } catch (error) { throw new BadRequestException( `Failed to extract zip file: ${error instanceof Error ? error.message : "Unknown error"}` ); } return results; } /** * Export entries as a zip file */ async exportEntries( workspaceId: string, format: ExportFormat, entryIds?: string[] ): Promise<{ stream: Readable; filename: string }> { // Fetch entries const entries = await this.fetchEntriesForExport(workspaceId, entryIds); if (entries.length === 0) { throw new BadRequestException("No entries found to export"); } // Create archive const archive = archiver("zip", { zlib: { level: 9 }, }); // Add entries to archive for (const entry of entries) { if (format === ExportFormat.MARKDOWN) { const markdown = this.entryToMarkdown(entry); const filename = `${entry.slug}.md`; archive.append(markdown, { name: filename }); } else { // JSON format const json = JSON.stringify(entry, null, 2); const filename = `${entry.slug}.json`; archive.append(json, { name: filename }); } } // Finalize archive void archive.finalize(); // Generate filename const timestamp = new Date().toISOString().split("T")[0] ?? "unknown"; const filename = `knowledge-export-${timestamp}.zip`; return { stream: archive, filename, }; } /** * Fetch entries for export */ private async fetchEntriesForExport( workspaceId: string, entryIds?: string[] ): Promise { const where: Record = { workspaceId }; if (entryIds && entryIds.length > 0) { where.id = { in: entryIds }; } const entries = await this.prisma.knowledgeEntry.findMany({ where, include: { tags: { include: { tag: true, }, }, }, orderBy: { title: "asc", }, }); return entries.map((entry) => ({ id: entry.id, slug: entry.slug, title: entry.title, content: entry.content, summary: entry.summary, status: entry.status, visibility: entry.visibility, tags: entry.tags.map((et) => et.tag.name), createdAt: entry.createdAt, updatedAt: entry.updatedAt, })); } /** * Convert entry to markdown format with frontmatter */ private entryToMarkdown(entry: ExportEntry): string { const frontmatter: Record = { title: entry.title, status: entry.status, visibility: entry.visibility, }; if (entry.summary) { frontmatter.summary = entry.summary; } if (entry.tags.length > 0) { frontmatter.tags = entry.tags; } frontmatter.createdAt = entry.createdAt.toISOString(); frontmatter.updatedAt = entry.updatedAt.toISOString(); // Build frontmatter string const frontmatterStr = Object.entries(frontmatter) .map(([key, value]) => { if (Array.isArray(value)) { return `${key}:\n - ${value.join("\n - ")}`; } return `${key}: ${String(value)}`; }) .join("\n"); return `---\n${frontmatterStr}\n---\n\n${entry.content}`; } /** * Parse status from frontmatter */ private parseStatus(value: unknown): EntryStatus | undefined { if (!value || typeof value !== "string") return undefined; const statusMap: Record = { DRAFT: EntryStatus.DRAFT, PUBLISHED: EntryStatus.PUBLISHED, ARCHIVED: EntryStatus.ARCHIVED, }; return statusMap[value.toUpperCase()]; } /** * Parse visibility from frontmatter */ private parseVisibility(value: unknown): Visibility | undefined { if (!value || typeof value !== "string") return undefined; const visibilityMap: Record = { PRIVATE: Visibility.PRIVATE, WORKSPACE: Visibility.WORKSPACE, PUBLIC: Visibility.PUBLIC, }; return visibilityMap[value.toUpperCase()]; } }