import { Elysia } from "elysia"; import { authPlugin } from "../../authPlugin"; import { loginEnabled } from "$lib/server/auth"; import { collections } from "$lib/server/database"; import { authCondition } from "$lib/server/auth"; import { config } from "$lib/server/config"; import yazl from "yazl"; import { downloadFile } from "$lib/server/files/downloadFile"; import mimeTypes from "mime-types"; import { logger } from "$lib/server/logger"; export interface FeatureFlags { enableAssistants: boolean; loginEnabled: boolean; isAdmin: boolean; } export const misc = new Elysia() .use(authPlugin) .get("/public-config", async () => config.getPublicConfig()) .get("/feature-flags", async ({ locals }) => { return { enableAssistants: config.ENABLE_ASSISTANTS === "true", loginEnabled, // login feature is on when OID is configured isAdmin: locals.isAdmin, } satisfies FeatureFlags; }) .get("/export", async ({ locals }) => { if (!locals.user) { throw new Error("Not logged in"); } if (!locals.isAdmin) { throw new Error("Not admin"); } if (config.ENABLE_DATA_EXPORT !== "true") { throw new Error("Data export is not enabled"); } const nExports = await collections.messageEvents.countDocuments({ userId: locals.user._id, type: "export", expiresAt: { $gt: new Date() }, }); if (nExports >= 1) { throw new Error( "You have already exported your data recently. Please wait 1 hour before exporting again." ); } const stats: { nConversations: number; nMessages: number; nFiles: number; nAssistants: number; nAvatars: number; } = { nConversations: 0, nMessages: 0, nFiles: 0, nAssistants: 0, nAvatars: 0, }; const zipfile = new yazl.ZipFile(); const promises = [ collections.conversations .find({ ...authCondition(locals) }) .toArray() .then(async (conversations) => { const formattedConversations = await Promise.all( conversations.map(async (conversation) => { stats.nConversations++; const hashes: string[] = []; conversation.messages.forEach(async (message) => { stats.nMessages++; if (message.files) { message.files.forEach((file) => { hashes.push(file.value); }); } }); const files = await Promise.all( hashes.map(async (hash) => { try { const fileData = await downloadFile(hash, conversation._id); return fileData; } catch { return null; } }) ); const filenames: string[] = []; files.forEach((file) => { if (!file) return; const extension = mimeTypes.extension(file.mime) || null; const convId = conversation._id.toString(); const fileId = file.name.split("-")[1].slice(0, 8); const fileName = `file-${convId}-${fileId}` + (extension ? `.${extension}` : ""); filenames.push(fileName); zipfile.addBuffer(Buffer.from(file.value, "base64"), fileName); stats.nFiles++; }); return { ...conversation, messages: conversation.messages.map((message) => { return { ...message, files: filenames, updates: undefined, }; }), }; }) ); zipfile.addBuffer( Buffer.from(JSON.stringify(formattedConversations, null, 2)), "conversations.json" ); }), collections.assistants .find({ createdById: locals.user._id }) .toArray() .then(async (assistants) => { const formattedAssistants = await Promise.all( assistants.map(async (assistant) => { if (assistant.avatar) { const fileId = collections.bucket.find({ filename: assistant._id.toString() }); const content = await fileId.next().then(async (file) => { if (!file?._id) return; const fileStream = collections.bucket.openDownloadStream(file?._id); const fileBuffer = await new Promise((resolve, reject) => { const chunks: Uint8Array[] = []; fileStream.on("data", (chunk) => chunks.push(chunk)); fileStream.on("error", reject); fileStream.on("end", () => resolve(Buffer.concat(chunks))); }); return fileBuffer; }); if (!content) return; zipfile.addBuffer(content, `avatar-${assistant._id.toString()}.jpg`); stats.nAvatars++; } stats.nAssistants++; return { _id: assistant._id.toString(), name: assistant.name, createdById: assistant.createdById.toString(), createdByName: assistant.createdByName, avatar: `avatar-${assistant._id.toString()}.jpg`, modelId: assistant.modelId, preprompt: assistant.preprompt, description: assistant.description, dynamicPrompt: assistant.dynamicPrompt, exampleInputs: assistant.exampleInputs, generateSettings: assistant.generateSettings, createdAt: assistant.createdAt.toISOString(), updatedAt: assistant.updatedAt.toISOString(), }; }) ); zipfile.addBuffer( Buffer.from(JSON.stringify(formattedAssistants, null, 2)), "assistants.json" ); }), ]; Promise.all(promises).then(async () => { logger.info( { userId: locals.user?._id, ...stats, }, "Exported user data" ); zipfile.end(); if (locals.user?._id) { await collections.messageEvents.insertOne({ userId: locals.user?._id, type: "export", createdAt: new Date(), expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour }); } }); // @ts-expect-error - zipfile.outputStream is not typed correctly return new Response(zipfile.outputStream, { headers: { "Content-Type": "application/zip", "Content-Disposition": 'attachment; filename="export.zip"', }, }); });