import express from "express"; import multer from "multer"; import {requireLogin} from "../middleware/auth.js"; import {asyncHandler} from "../middleware/asyncHandler.js"; import {validateWorkspaceImport} from "../services/workspaceImportService.js"; import {createWorkspaceArchive, parseWorkspaceArchive, MAX_ARCHIVE_BYTES} from "../services/workspaceArchive.js"; export function createWorkspaceTransferRouter({repositories, chatService, attachmentService}) { const router = express.Router(); router.use(requireLogin); const receiveArchive = multer({storage: multer.memoryStorage(), limits: {fileSize: MAX_ARCHIVE_BYTES, files: 1, parts: 2}}).single("archive"); const readArchive = (req, res, next) => receiveArchive(req, res, error => { if (error) return res.status(error.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({error: "Invalid or oversized workspace archive"}); try { if (!req.file) throw new Error("Choose a workspace archive"); req.archive = parseWorkspaceArchive(req.file.buffer); next(); } catch (failure) { res.status(400).json({error: failure.message}); } }); const archiveImages = (workspace, files) => { const images = new Map(); for (const chat of workspace.chats) for (const message of chat.messages) for (const attachment of message.attachments) { if (images.has(attachment.id)) continue; const matches = [...files.entries()].filter(([name]) => name.startsWith(`images/${attachment.id}.`)); if (matches.length !== 1) throw new Error(`Image ${attachment.id} is missing from archive`); const [name, buffer] = matches[0]; images.set(attachment.id, {buffer, size: buffer.length, originalname: name.split("/").at(-1)}); } return images; }; router.get("/exports/chats/:id", (req, res) => { const chatId = Number(req.params.id), format = String(req.query.format || "txt").toLowerCase(); const chat = repositories.getChat(chatId, req.session.user); if (!chat) return res.status(404).json({error: "Chat not found"}); const messages = repositories.listChatMessages(chatId).map(chatService.formatChatMessage); if (format === "json") return res.json({chat, messages}); const markdown = format === "md"; const body = markdown ? [`# ${chat.title}`, "", ...messages.flatMap(message => [`## ${message.role === "user" ? "You" : "Assistant"}`, "", message.content, ""])].join("\n") : [`Chat: ${chat.title}`, "", ...messages.flatMap(message => [`${message.role === "user" ? "You" : "Assistant"}:`, message.content, ""])].join("\n"); res.type(markdown ? "text/markdown" : "text/plain"); return res.send(body); }); router.get("/exports/workspace", (req, res) => res.json({workspace: repositories.exportWorkspace(req.session.user)})); router.get("/exports/workspace/archive", asyncHandler(async (req, res) => { try { const workspace = repositories.exportWorkspace(req.session.user), images = new Map(); for (const chat of workspace.chats) for (const message of chat.messages) for (const attachment of message.attachments) { if (images.has(attachment.id)) continue; const row = attachmentService.owned(attachment.id, req.session.user); images.set(attachment.id, [`images/${row.id}.${row.mime_type.split("/")[1]}`, await attachmentService.storage.read(row.storage_key)]); } const archive = createWorkspaceArchive(workspace, [...images.values()]); res.set({"Content-Type": "application/gzip", "Content-Disposition": 'attachment; filename="workspace-backup.tar.gz"', "Cache-Control": "private, no-store"}); return res.send(archive); } catch (error) { return res.status(error.message.includes("250 MB") ? 413 : 500).json({error: "Workspace archive could not be created"}); } })); router.post("/imports/workspace/archive/preview", readArchive, (req, res) => { const validation = validateWorkspaceImport(req.archive.workspace, repositories.exportWorkspace(req.session.user)); if (validation.ok) try { archiveImages(validation.workspace, req.archive.images); } catch (error) { validation.ok = false; validation.errors.push(error.message); } return res.status(validation.ok ? 200 : 400).json({ok: validation.ok, errors: validation.errors, warnings: validation.warnings, preview: validation.preview}); }); router.post("/imports/workspace/archive", readArchive, asyncHandler(async (req, res) => { const validation = validateWorkspaceImport(req.archive.workspace, repositories.exportWorkspace(req.session.user)); if (!validation.ok) return res.status(400).json({error: "Workspace import validation failed", errors: validation.errors}); if (validation.preview.duplicates.length && req.body?.allowDuplicates !== "true") return res.status(409).json({error: "Workspace import contains duplicate records", warnings: validation.warnings, preview: validation.preview}); let images; try { images = archiveImages(validation.workspace, req.archive.images); } catch (error) { return res.status(400).json({error: error.message}); } const restored = []; try { const ids = new Map(); for (const [oldId, file] of images) { const uploaded = await attachmentService.upload(file, req.session.user, {forImport: true}); restored.push(uploaded.id); ids.set(oldId, uploaded.id); } for (const chat of validation.workspace.chats) for (const message of chat.messages) message.attachments = message.attachments.map(attachment => ({id: ids.get(attachment.id)})); return res.json({imported: repositories.importWorkspace(req.session.user, validation.workspace), preview: validation.preview}); } catch (error) { for (const id of restored) await attachmentService.remove(id, req.session.user).catch(() => {}); return res.status(error.status || 400).json({error: error.message || "Workspace archive import failed"}); } })); router.post("/imports/workspace/preview", (req, res) => { const validation = validateWorkspaceImport(req.body?.workspace, repositories.exportWorkspace(req.session.user)); return res.status(validation.ok ? 200 : 400).json({ok: validation.ok, errors: validation.errors, warnings: validation.warnings, preview: validation.preview}); }); router.post("/imports/workspace", (req, res) => { const validation = validateWorkspaceImport(req.body?.workspace, repositories.exportWorkspace(req.session.user)); if (!validation.ok) return res.status(400).json({error: "Workspace import validation failed", errors: validation.errors, warnings: validation.warnings, preview: validation.preview}); if (validation.preview.duplicates.length && !req.body?.allowDuplicates) return res.status(409).json({error: "Workspace import contains duplicate records", errors: [], warnings: validation.warnings, preview: validation.preview}); return res.json({imported: repositories.importWorkspace(req.session.user, validation.workspace), preview: validation.preview}); }); return router; }