Skip to content

Commit 8424e14

Browse files
committed
feat: add admin only export feature
1 parent 67c88a4 commit 8424e14

File tree

4 files changed

+173
-3
lines changed

4 files changed

+173
-3
lines changed

package-lock.json

Lines changed: 33 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@types/parquetjs": "^0.10.3",
4242
"@types/sbd": "^1.0.5",
4343
"@types/uuid": "^9.0.8",
44+
"@types/yazl": "^3.3.0",
4445
"@typescript-eslint/eslint-plugin": "^6.x",
4546
"@typescript-eslint/parser": "^6.x",
4647
"bson-objectid": "^2.0.4",
@@ -71,7 +72,8 @@
7172
"unplugin-icons": "^0.16.1",
7273
"vite": "^6.3.5",
7374
"vite-node": "^3.0.9",
74-
"vitest": "^3.1.4"
75+
"vitest": "^3.1.4",
76+
"yazl": "^3.3.1"
7577
},
7678
"type": "module",
7779
"dependencies": {

src/lib/server/api/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import superjson from "superjson";
1616
const prefix = `${base}/api/v2` as unknown as "";
1717

1818
export const app = new Elysia({ prefix })
19-
.mapResponse(({ response }) => {
19+
.mapResponse(({ response, request }) => {
20+
// Skip the /export endpoint
21+
if (request.url.endsWith("/export")) {
22+
return response as unknown as Response;
23+
}
2024
return new Response(superjson.stringify(response), {
2125
headers: {
2226
"Content-Type": "application/json",

src/lib/server/api/routes/groups/misc.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { collections } from "$lib/server/database";
55
import { authCondition } from "$lib/server/auth";
66
import { config } from "$lib/server/config";
77
import { Client } from "@gradio/client";
8+
import yazl from "yazl";
9+
import { downloadFile } from "$lib/server/files/downloadFile";
10+
import mimeTypes from "mime-types";
811

912
export interface FeatureFlags {
1013
searchEnabled: boolean;
@@ -103,4 +106,133 @@ export const misc = new Elysia()
103106
} catch (e) {
104107
throw new Error("Error fetching space API. Is the name correct?");
105108
}
109+
})
110+
.get("/export", async ({ locals }) => {
111+
if (!locals.user) {
112+
throw new Error("Not logged in");
113+
}
114+
115+
if (!locals.isAdmin) {
116+
throw new Error("Not admin");
117+
}
118+
119+
const zipfile = new yazl.ZipFile();
120+
121+
const promises = [
122+
collections.conversations
123+
.find({ ...authCondition(locals) })
124+
.toArray()
125+
.then(async (conversations) => {
126+
const formattedConversations = await Promise.all(
127+
conversations.map(async (conversation) => {
128+
const hashes: string[] = [];
129+
conversation.messages.forEach(async (message) => {
130+
if (message.files) {
131+
message.files.forEach((file) => {
132+
hashes.push(file.value);
133+
});
134+
}
135+
});
136+
const files = await Promise.all(
137+
hashes.map(async (hash) => {
138+
const fileData = await downloadFile(hash, conversation._id);
139+
return fileData;
140+
})
141+
);
142+
143+
const filenames: string[] = [];
144+
files.forEach((file) => {
145+
const extension = mimeTypes.extension(file.mime) || "bin";
146+
const convId = conversation._id.toString();
147+
const fileId = file.name.split("-")[1].slice(0, 8);
148+
const fileName = `file-${convId}-${fileId}.${extension}`;
149+
filenames.push(fileName);
150+
zipfile.addBuffer(Buffer.from(file.value, "base64"), fileName);
151+
});
152+
153+
return {
154+
...conversation,
155+
messages: conversation.messages.map((message) => {
156+
return {
157+
...message,
158+
files: filenames,
159+
updates: undefined,
160+
};
161+
}),
162+
};
163+
})
164+
);
165+
166+
zipfile.addBuffer(
167+
Buffer.from(JSON.stringify(formattedConversations, null, 2)),
168+
"conversations.json"
169+
);
170+
}),
171+
collections.assistants
172+
.find({ createdById: locals.user._id })
173+
.toArray()
174+
.then(async (assistants) => {
175+
const formattedAssistants = await Promise.all(
176+
assistants.map(async (assistant) => {
177+
if (assistant.avatar) {
178+
const fileId = collections.bucket.find({ filename: assistant._id.toString() });
179+
180+
const content = await fileId.next().then(async (file) => {
181+
if (!file?._id) return;
182+
183+
const fileStream = collections.bucket.openDownloadStream(file?._id);
184+
185+
const fileBuffer = await new Promise<Buffer>((resolve, reject) => {
186+
const chunks: Uint8Array[] = [];
187+
fileStream.on("data", (chunk) => chunks.push(chunk));
188+
fileStream.on("error", reject);
189+
fileStream.on("end", () => resolve(Buffer.concat(chunks)));
190+
});
191+
192+
return fileBuffer;
193+
});
194+
195+
if (!content) return;
196+
197+
zipfile.addBuffer(content, `avatar-${assistant._id.toString()}.jpg`);
198+
}
199+
200+
return {
201+
_id: assistant._id.toString(),
202+
name: assistant.name,
203+
createdById: assistant.createdById.toString(),
204+
createdByName: assistant.createdByName,
205+
avatar: `avatar-${assistant._id.toString()}.jpg`,
206+
modelId: assistant.modelId,
207+
preprompt: assistant.preprompt,
208+
description: assistant.description,
209+
dynamicPrompt: assistant.dynamicPrompt,
210+
exampleInputs: assistant.exampleInputs,
211+
rag: assistant.rag,
212+
tools: assistant.tools,
213+
generateSettings: assistant.generateSettings,
214+
createdAt: assistant.createdAt.toISOString(),
215+
updatedAt: assistant.updatedAt.toISOString(),
216+
};
217+
})
218+
);
219+
220+
zipfile.addBuffer(
221+
Buffer.from(JSON.stringify(formattedAssistants, null, 2)),
222+
"assistants.json"
223+
);
224+
}),
225+
];
226+
227+
await Promise.all(promises);
228+
229+
zipfile.end();
230+
231+
// @ts-expect-error - zipfile.outputStream is not typed correctly
232+
return new Response(zipfile.outputStream, {
233+
headers: {
234+
"Content-Type": "application/zip",
235+
"Content-Disposition": 'attachment; filename="export.zip"',
236+
},
237+
});
106238
});

0 commit comments

Comments
 (0)