nsarrazin commited on
Commit
9047413
·
1 Parent(s): 4d901d0

feat: improve export feature

Browse files

- add once per hour rate limit
- remove await blocks, start streaming zip right away
- add stats to logging

src/lib/server/api/routes/groups/misc.ts CHANGED
@@ -8,6 +8,7 @@ import { Client } from "@gradio/client";
8
  import yazl from "yazl";
9
  import { downloadFile } from "$lib/server/files/downloadFile";
10
  import mimeTypes from "mime-types";
 
11
 
12
  export interface FeatureFlags {
13
  searchEnabled: boolean;
@@ -120,6 +121,32 @@ export const misc = new Elysia()
120
  throw new Error("Data export is not enabled");
121
  }
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  const zipfile = new yazl.ZipFile();
124
 
125
  const promises = [
@@ -129,8 +156,10 @@ export const misc = new Elysia()
129
  .then(async (conversations) => {
130
  const formattedConversations = await Promise.all(
131
  conversations.map(async (conversation) => {
 
132
  const hashes: string[] = [];
133
  conversation.messages.forEach(async (message) => {
 
134
  if (message.files) {
135
  message.files.forEach((file) => {
136
  hashes.push(file.value);
@@ -152,12 +181,13 @@ export const misc = new Elysia()
152
  files.forEach((file) => {
153
  if (!file) return;
154
 
155
- const extension = mimeTypes.extension(file.mime) || "bin";
156
  const convId = conversation._id.toString();
157
  const fileId = file.name.split("-")[1].slice(0, 8);
158
- const fileName = `file-${convId}-${fileId}.${extension}`;
159
  filenames.push(fileName);
160
  zipfile.addBuffer(Buffer.from(file.value, "base64"), fileName);
 
161
  });
162
 
163
  return {
@@ -212,8 +242,11 @@ export const misc = new Elysia()
212
  if (!content) return;
213
 
214
  zipfile.addBuffer(content, `avatar-${assistant._id.toString()}.jpg`);
 
215
  }
216
 
 
 
217
  return {
218
  _id: assistant._id.toString(),
219
  name: assistant.name,
@@ -241,9 +274,24 @@ export const misc = new Elysia()
241
  }),
242
  ];
243
 
244
- await Promise.all(promises);
245
-
246
- zipfile.end();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
 
248
  // @ts-expect-error - zipfile.outputStream is not typed correctly
249
  return new Response(zipfile.outputStream, {
 
8
  import yazl from "yazl";
9
  import { downloadFile } from "$lib/server/files/downloadFile";
10
  import mimeTypes from "mime-types";
11
+ import { logger } from "$lib/server/logger";
12
 
13
  export interface FeatureFlags {
14
  searchEnabled: boolean;
 
121
  throw new Error("Data export is not enabled");
122
  }
123
 
124
+ const nExports = await collections.messageEvents.countDocuments({
125
+ userId: locals.user._id,
126
+ type: "export",
127
+ expiresAt: { $gt: new Date() },
128
+ });
129
+
130
+ if (nExports >= 1) {
131
+ throw new Error(
132
+ "You have already exported your data recently. Please wait 1 hour before exporting again."
133
+ );
134
+ }
135
+
136
+ const stats: {
137
+ nConversations: number;
138
+ nMessages: number;
139
+ nAssistants: number;
140
+ nAvatars: number;
141
+ nFiles: number;
142
+ } = {
143
+ nConversations: 0,
144
+ nMessages: 0,
145
+ nFiles: 0,
146
+ nAssistants: 0,
147
+ nAvatars: 0,
148
+ };
149
+
150
  const zipfile = new yazl.ZipFile();
151
 
152
  const promises = [
 
156
  .then(async (conversations) => {
157
  const formattedConversations = await Promise.all(
158
  conversations.map(async (conversation) => {
159
+ stats.nConversations++;
160
  const hashes: string[] = [];
161
  conversation.messages.forEach(async (message) => {
162
+ stats.nMessages++;
163
  if (message.files) {
164
  message.files.forEach((file) => {
165
  hashes.push(file.value);
 
181
  files.forEach((file) => {
182
  if (!file) return;
183
 
184
+ const extension = mimeTypes.extension(file.mime) || null;
185
  const convId = conversation._id.toString();
186
  const fileId = file.name.split("-")[1].slice(0, 8);
187
+ const fileName = `file-${convId}-${fileId}` + (extension ? `.${extension}` : "");
188
  filenames.push(fileName);
189
  zipfile.addBuffer(Buffer.from(file.value, "base64"), fileName);
190
+ stats.nFiles++;
191
  });
192
 
193
  return {
 
242
  if (!content) return;
243
 
244
  zipfile.addBuffer(content, `avatar-${assistant._id.toString()}.jpg`);
245
+ stats.nAvatars++;
246
  }
247
 
248
+ stats.nAssistants++;
249
+
250
  return {
251
  _id: assistant._id.toString(),
252
  name: assistant.name,
 
274
  }),
275
  ];
276
 
277
+ Promise.all(promises).then(async () => {
278
+ logger.info(
279
+ {
280
+ userId: locals.user?._id,
281
+ ...stats,
282
+ },
283
+ "Exported user data"
284
+ );
285
+ zipfile.end();
286
+ if (locals.user?._id) {
287
+ await collections.messageEvents.insertOne({
288
+ userId: locals.user?._id,
289
+ type: "export",
290
+ createdAt: new Date(),
291
+ expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour
292
+ });
293
+ }
294
+ });
295
 
296
  // @ts-expect-error - zipfile.outputStream is not typed correctly
297
  return new Response(zipfile.outputStream, {
src/lib/server/database.ts CHANGED
@@ -242,7 +242,7 @@ export class Database {
242
  // No unicity because due to renames & outdated info from oauth provider, there may be the same username on different users
243
  users.createIndex({ username: 1 }).catch((e) => logger.error(e));
244
  messageEvents
245
- .createIndex({ createdAt: 1 }, { expireAfterSeconds: 60 })
246
  .catch((e) => logger.error(e));
247
  sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }).catch((e) => logger.error(e));
248
  sessions.createIndex({ sessionId: 1 }, { unique: true }).catch((e) => logger.error(e));
 
242
  // No unicity because due to renames & outdated info from oauth provider, there may be the same username on different users
243
  users.createIndex({ username: 1 }).catch((e) => logger.error(e));
244
  messageEvents
245
+ .createIndex({ expiresAt: 1 }, { expireAfterSeconds: 1 })
246
  .catch((e) => logger.error(e));
247
  sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }).catch((e) => logger.error(e));
248
  sessions.createIndex({ sessionId: 1 }, { unique: true }).catch((e) => logger.error(e));
src/lib/types/MessageEvent.ts CHANGED
@@ -5,4 +5,6 @@ import type { User } from "./User";
5
  export interface MessageEvent extends Pick<Timestamps, "createdAt"> {
6
  userId: User["_id"] | Session["sessionId"];
7
  ip?: string;
 
 
8
  }
 
5
  export interface MessageEvent extends Pick<Timestamps, "createdAt"> {
6
  userId: User["_id"] | Session["sessionId"];
7
  ip?: string;
8
+ expiresAt: Date;
9
+ type: "message" | "export";
10
  }
src/routes/conversation/[id]/+server.ts CHANGED
@@ -74,8 +74,10 @@ export async function POST({ request, locals, params, getClientAddress }) {
74
 
75
  // register the event for ratelimiting
76
  await collections.messageEvents.insertOne({
 
77
  userId,
78
  createdAt: new Date(),
 
79
  ip: getClientAddress(),
80
  });
81
 
@@ -103,17 +105,18 @@ export async function POST({ request, locals, params, getClientAddress }) {
103
  error(429, "Exceeded number of messages before login");
104
  }
105
  }
106
-
107
  if (usageLimits?.messagesPerMinute) {
108
  // check if the user is rate limited
109
  const nEvents = Math.max(
110
  await collections.messageEvents.countDocuments({
111
  userId,
112
- createdAt: { $gte: new Date(Date.now() - 60_000) },
 
113
  }),
114
  await collections.messageEvents.countDocuments({
115
  ip: getClientAddress(),
116
- createdAt: { $gte: new Date(Date.now() - 60_000) },
 
117
  })
118
  );
119
  if (nEvents > usageLimits.messagesPerMinute) {
 
74
 
75
  // register the event for ratelimiting
76
  await collections.messageEvents.insertOne({
77
+ type: "message",
78
  userId,
79
  createdAt: new Date(),
80
+ expiresAt: new Date(Date.now() + 60_000),
81
  ip: getClientAddress(),
82
  });
83
 
 
105
  error(429, "Exceeded number of messages before login");
106
  }
107
  }
 
108
  if (usageLimits?.messagesPerMinute) {
109
  // check if the user is rate limited
110
  const nEvents = Math.max(
111
  await collections.messageEvents.countDocuments({
112
  userId,
113
+ type: "message",
114
+ expiresAt: { $gt: new Date() },
115
  }),
116
  await collections.messageEvents.countDocuments({
117
  ip: getClientAddress(),
118
+ type: "message",
119
+ expiresAt: { $gt: new Date() },
120
  })
121
  );
122
  if (nEvents > usageLimits.messagesPerMinute) {