@tiledesk/tiledesk-server 2.14.17 → 2.14.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,545 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const pathlib = require('path');
4
+ const mongoose = require('mongoose');
5
+ const multer = require('multer');
6
+ const passport = require('passport');
7
+ const mime = require('mime-types');
8
+ const path = require('path');
9
+ const sharp = require('sharp');
10
+ const verifyFileContent = require('../middleware/file-type.js');
11
+
12
+ require('../middleware/passport.js')(passport);
13
+ const validtoken = require('../middleware/valid-token.js')
14
+ const roleChecker = require('../middleware/has-role.js');
15
+ const winston = require('../config/winston.js');
16
+ const FileGridFsService = require('../services/fileGridFsService.js');
17
+ const roleConstants = require('../models/roleConstants.js');
18
+ const faq_kb = require('../models/faq_kb');
19
+ const project_user = require('../models/project_user');
20
+
21
+ const fileService = new FileGridFsService("files");
22
+ const fallbackFileService = new FileGridFsService("images");;
23
+
24
+
25
+ let MAX_UPLOAD_FILE_SIZE = process.env.MAX_UPLOAD_FILE_SIZE || 1024000; // 1MB
26
+ let uploadlimits = undefined;
27
+
28
+ if (MAX_UPLOAD_FILE_SIZE) {
29
+ uploadlimits = { fileSize: parseInt(MAX_UPLOAD_FILE_SIZE) } ;
30
+ winston.info("Max upload file size is : " + MAX_UPLOAD_FILE_SIZE);
31
+ } else {
32
+ winston.info("Max upload file size is infinity");
33
+ }
34
+
35
+ /**
36
+ * Default: '2592000' (30 days)
37
+ * Examples:
38
+ * - '30' (30 seconds)
39
+ */
40
+ const chatFileExpirationTime = parseInt(process.env.CHAT_FILE_EXPIRATION_TIME || '2592000', 10);
41
+
42
+ /**
43
+ * Default: ".jpg,.jpeg,.png,.gif,.pdf,.txt"
44
+ * Examples:
45
+ * - '* /*' without spaces (all extension)
46
+ * Deprecated: "application/pdf,image/png,..."
47
+ */
48
+ const default_chat_allowed_extensions = process.env.CHAT_FILES_ALLOW_LIST || ".jpg,.jpeg,.png,.gif,.pdf,.txt";
49
+ const default_assets_allowed_extensions = process.env.ASSETS_FILES_ALLOW_LIST || ".jpg,.jpeg,.png,.gif,.pdf,.txt,.csv,.doc,.docx"; //,.xls,.xlsx,.ppt,.pptx,.zip,.rar
50
+ const images_extensions = [ ".png", ".jpg", ".jpeg", ".gif" ];
51
+
52
+ const fileFilter = (extensionsSource = 'chat') => {
53
+ return (req, file, cb) => {
54
+
55
+ const project = req.project;
56
+ const pu = req.projectuser;
57
+
58
+ let allowed_extensions;
59
+ let allowed_mime_types;
60
+
61
+ if (extensionsSource === 'avatar') {
62
+ // Avatar only accepts image extensions
63
+ allowed_extensions = images_extensions.join(',');
64
+ } else if (extensionsSource === 'assets') {
65
+ allowed_extensions = default_assets_allowed_extensions;
66
+ } else if (pu.roleType === 2 || pu.role === roleConstants.GUEST) {
67
+ allowed_extensions = project?.widget?.allowedUploadExtentions || default_chat_allowed_extensions;
68
+ } else {
69
+ allowed_extensions = project?.settings?.allowed_upload_extentions || default_chat_allowed_extensions;
70
+ }
71
+
72
+ if (allowed_extensions !== "*/*") {
73
+ allowed_mime_types = getMimeTypes(allowed_extensions);
74
+ if (!file.originalname) {
75
+ return cb(new Error("File original name is required"));
76
+ }
77
+ const ext = path.extname(file.originalname).toLowerCase();
78
+
79
+ if (!allowed_extensions.includes(ext)) {
80
+ const error = new Error(`File extension ${ext} is not allowed${extensionsSource === 'avatar' ? ' for avatar' : ''}`);
81
+ error.status = 403;
82
+ return cb(error);
83
+ }
84
+
85
+ const expectedMimeType = mime.lookup(ext);
86
+ if (expectedMimeType && file.mimetype !== expectedMimeType) {
87
+ const error = new Error(`File content does not match mimetype. Detected: ${file.mimetype}, provided: ${expectedMimeType}`);
88
+ error.status = 403;
89
+ return cb(error);
90
+ }
91
+
92
+ return cb(null, true);
93
+ } else {
94
+ return cb(null, true);
95
+ }
96
+ }
97
+ }
98
+
99
+ function getMimeTypes(allowed_extension) {
100
+ const extension = allowed_extension.split(',').map(e => e.trim().toLowerCase());
101
+ const allowedMimeTypes = extension.map(ext => mime.lookup(ext)).filter(Boolean);
102
+ return allowedMimeTypes;
103
+ }
104
+
105
+ const uploadChat = multer({
106
+ storage: fileService.getStorage("files"),
107
+ fileFilter: fileFilter('chat'),
108
+ limits: uploadlimits
109
+ }).single('file');
110
+
111
+ const uploadAssets = multer({
112
+ storage: fileService.getStorageProjectAssets("files"),
113
+ fileFilter: fileFilter('assets'),
114
+ limits: uploadlimits
115
+ }).single('file');
116
+
117
+ const uploadAvatar = multer({
118
+ storage: fileService.getStorageAvatarFiles("files"),
119
+ fileFilter: fileFilter('avatar'),
120
+ limits: uploadlimits
121
+ }).single('file');
122
+
123
+
124
+ // *********************** //
125
+ // ****** Endpoints ****** //
126
+ // *********************** //
127
+
128
+ router.post('/chat', [
129
+ passport.authenticate(['basic', 'jwt'], { session: false }),
130
+ validtoken,
131
+ roleChecker.hasRoleOrTypes('guest', ['bot','subscription'])
132
+ ], async (req, res) => {
133
+
134
+ const expireAt = new Date(Date.now() + chatFileExpirationTime * 1000);
135
+ req.expireAt = expireAt;
136
+ uploadChat(req, res, async (err) => {
137
+ if (err instanceof multer.MulterError) {
138
+ // A Multer error occurred when uploading
139
+ winston.error(`Multer replied with code ${err?.code} and message "${err?.message}"`);
140
+ let status = 400;
141
+ if (err?.code === 'LIMIT_FILE_SIZE') {
142
+ status = 413;
143
+ }
144
+ return res.status(status).send({ success: false, error: err?.message || 'An error occurred while uploading the file', code: err.code });
145
+ } else if (err) {
146
+ // An unknown error occurred when uploading.
147
+ winston.error(`Multer replied with status ${err?.status} and message "${err?.message}"`);
148
+ let status = err?.status || 400;
149
+ return res.status(status).send({ success: false, error: err.message || "An error occurred while uploading the file" })
150
+ }
151
+ try {
152
+ const buffer = await fileService.getFileDataAsBuffer(req.file.filename);
153
+ await verifyFileContent(buffer, req.file.mimetype);
154
+ await mongoose.connection.db.collection('files.chunks').updateMany(
155
+ { files_id: req.file.id },
156
+ { $set: { "metadata.expireAt": req.file.metadata.expireAt }}
157
+ )
158
+ return res.status(201).send({ message: "File uploaded successfully", filename: req.file.filename })
159
+ } catch (err) {
160
+ if (err?.source === "FileContentVerification") {
161
+ let error_message = err?.message || "Content verification failed";
162
+ winston.warn("File content verification failed. Message: ", error_message);
163
+ return res.status(403).send({ success: false, error: error_message })
164
+ }
165
+ winston.error("Error saving file: ", err);
166
+ return res.status(500).send({ success: false, error: "Error updating file chunks" });
167
+ }
168
+ })
169
+
170
+ })
171
+
172
+
173
+ router.post('/assets', [
174
+ passport.authenticate(['basic', 'jwt'], { session: false }),
175
+ validtoken,
176
+ roleChecker.hasRoleOrTypes('admin', ['bot','subscription'])
177
+ ], async (req, res) => {
178
+ // Assets have no retention by default, but can be set via query parameter
179
+ let customExpiration = parseInt(req.query?.expiration, 10);
180
+ if (customExpiration && !isNaN(customExpiration) && customExpiration > 0) {
181
+ req.expireAt = new Date(Date.now() + customExpiration * 1000);
182
+ }
183
+
184
+
185
+ uploadAssets(req, res, async (err) => {
186
+ if (err instanceof multer.MulterError) {
187
+ // A Multer error occurred when uploading
188
+ winston.error(`Multer replied with code ${err?.code} and message "${err?.message}"`);
189
+ let status = 400;
190
+ if (err?.code === 'LIMIT_FILE_SIZE') {
191
+ status = 413;
192
+ }
193
+ return res.status(status).send({ success: false, error: err?.message || 'An error occurred while uploading the file', code: err.code });
194
+ } else if (err) {
195
+ // An unknown error occurred when uploading.
196
+ winston.error(`Multer replied with status ${err?.status} and message "${err?.message}"`);
197
+ let status = err?.status || 400;
198
+ return res.status(status).send({ success: false, error: err.message || "An error occurred while uploading the file" })
199
+ }
200
+
201
+ try {
202
+
203
+ const buffer = await fileService.getFileDataAsBuffer(req.file.filename);
204
+ await verifyFileContent(buffer, req.file.mimetype);
205
+
206
+ if (req.file.metadata && req.file.metadata.expireAt) {
207
+ await mongoose.connection.db.collection('files.chunks').updateMany(
208
+ { files_id: req.file.id },
209
+ { $set: { "metadata.expireAt": req.file.metadata.expireAt }}
210
+ );
211
+ }
212
+
213
+ const ext = path.extname(req.file.originalname).toLowerCase();
214
+ let thumbnail;
215
+
216
+ // Generate thumbnail for images
217
+ if (images_extensions.includes(ext)) {
218
+ const buffer = await fileService.getFileDataAsBuffer(req.file.filename);
219
+ const thumbFilename = req.file.filename.replace(/([^/]+)$/, "thumbnails_200_200-$1");
220
+ const resized = await sharp(buffer).resize(200, 200).toBuffer();
221
+
222
+ const thumbMetadata = req.expireAt ? { metadata: { expireAt: req.expireAt } } : undefined;
223
+ // Use the same contentType as the original file for the thumbnail
224
+ await fileService.createFile(thumbFilename, resized, undefined, req.file.mimetype, thumbMetadata);
225
+
226
+ if (req.expireAt) {
227
+ await mongoose.connection.db.collection('files.chunks').updateMany(
228
+ { files_id: ( await fileService.find(thumbFilename))._id },
229
+ { $set: { "metadata.expireAt": req.expireAt }}
230
+ );
231
+ }
232
+ thumbnail = thumbFilename;
233
+ }
234
+
235
+ return res.status(201).send({
236
+ message: 'File uploaded successfully',
237
+ filename: encodeURIComponent(req.file.filename),
238
+ thumbnail: thumbnail ? encodeURIComponent(thumbnail) : undefined
239
+ })
240
+
241
+ } catch (err) {
242
+ if (err?.source === "FileContentVerification") {
243
+ let error_message = err?.message || "Content verification failed";
244
+ winston.warn("File content verification failed. Message: ", error_message);
245
+ return res.status(403).send({ success: false, error: error_message })
246
+ }
247
+
248
+ winston.error("Error uploading asset", err);
249
+ return res.status(500).send({ success: false, error: "Error uploading asset" });
250
+
251
+ }
252
+ })
253
+ })
254
+
255
+ /**
256
+ * Upload user profile photo or bot avatar
257
+ * Path: uploads/users/{user_id|bot_id}/images/photo.jpg
258
+ * This maintains compatibility with clients that expect fixed paths.
259
+ * Profile photos/avatars have no retention.
260
+ */
261
+ router.post('/users/photo', [
262
+ passport.authenticate(['basic', 'jwt'], { session: false }),
263
+ validtoken,
264
+ roleChecker.hasRoleOrTypes('agent', ['bot','subscription'])
265
+ ], async (req, res) => {
266
+
267
+ uploadAvatar(req, res, async (err) => {
268
+ if (err instanceof multer.MulterError) {
269
+ // A Multer error occurred when uploading
270
+ winston.error(`Multer replied with code ${err?.code} and message "${err?.message}"`);
271
+ let status = 400;
272
+ if (err?.code === 'LIMIT_FILE_SIZE') {
273
+ status = 413;
274
+ }
275
+ return res.status(status).send({ success: false, error: err?.message || 'An error occurred while uploading the file', code: err.code });
276
+ } else if (err) {
277
+ // An unknown error occurred when uploading.
278
+ winston.error(`Multer replied with status ${err?.status} and message "${err?.message}"`);
279
+ let status = err?.status || 400;
280
+ return res.status(status).send({ success: false, error: err.message || "An error occurred while uploading the file" })
281
+ }
282
+
283
+ try {
284
+ winston.debug("/users/photo");
285
+
286
+ if (!req.file) {
287
+ return res.status(400).send({ success: false, error: 'No file uploaded' });
288
+ }
289
+
290
+ let userid = req.user.id;
291
+ let bot_id;
292
+ let entity_id = userid;
293
+
294
+ if (req.query.bot_id) {
295
+ bot_id = req.query.bot_id;
296
+
297
+ let chatbot = await faq_kb.findById(bot_id).catch((err) => {
298
+ winston.error("Error finding bot ", err);
299
+ return res.status(500).send({ success: false, error: "Unable to find chatbot with id " + bot_id });
300
+ });
301
+
302
+ if (!chatbot) {
303
+ return res.status(404).send({ success: false, error: "Chatbot not found" });
304
+ }
305
+
306
+ let id_project = chatbot.id_project;
307
+
308
+ let puser = await project_user.findOne({ id_user: userid, id_project: id_project }).catch((err) => {
309
+ winston.error("Error finding project user: ", err);
310
+ return res.status(500).send({ success: false, error: "Unable to find project user for user " + userid + "in project " + id_project });
311
+ });
312
+
313
+ if (!puser) {
314
+ winston.warn("User " + userid + " doesn't belong to the project " + id_project);
315
+ return res.status(401).send({ success: false, error: "You don't belong to the chatbot's project" });
316
+ }
317
+
318
+ if ((puser.role !== roleConstants.ADMIN) && (puser.role !== roleConstants.OWNER)) {
319
+ winston.warn("User with role " + puser.role + " can't modify the chatbot");
320
+ return res.status(403).send({ success: false, error: "You don't have the role required to modify the chatbot" });
321
+ }
322
+
323
+ entity_id = bot_id;
324
+ }
325
+
326
+ var destinationFolder = 'uploads/users/' + entity_id + "/images/";
327
+ winston.debug("destinationFolder:" + destinationFolder);
328
+
329
+ var thumFilename = destinationFolder + 'thumbnails_200_200-photo.jpg';
330
+
331
+ winston.debug("req.file.filename:" + req.file.filename);
332
+ const buffer = await fileService.getFileDataAsBuffer(req.file.filename);
333
+
334
+ try {
335
+ const resizeImage = await sharp(buffer).resize(200, 200).toBuffer();
336
+ // Use the same contentType as the original file for the thumbnail
337
+ await fileService.createFile(thumFilename, resizeImage, undefined, req.file.mimetype);
338
+ let thumFile = await fileService.find(thumFilename);
339
+ winston.debug("thumFile", thumFile);
340
+
341
+ return res.status(201).json({
342
+ message: 'Image uploaded successfully',
343
+ filename: encodeURIComponent(req.file.filename),
344
+ thumbnail: encodeURIComponent(thumFilename)
345
+ });
346
+ } catch (thumbErr) {
347
+ winston.error("Error generating or creating thumbnail", thumbErr);
348
+ // Still return success for the main file, but log thumbnail error
349
+ return res.status(201).json({
350
+ message: 'Image uploaded successfully',
351
+ filename: encodeURIComponent(req.file.filename),
352
+ thumbnail: undefined
353
+ });
354
+ }
355
+
356
+ } catch (error) {
357
+ winston.error('Error uploading user image.', error);
358
+ return res.status(500).send({ success: false, error: 'Error uploading user image.' });
359
+ }
360
+
361
+ })
362
+ })
363
+
364
+
365
+ router.get("/", [
366
+ passport.authenticate(['basic', 'jwt'], { session: false }),
367
+ validtoken,
368
+ ], async (req, res) => {
369
+ winston.debug('path', req.query.path);
370
+
371
+ if (req.query.as_attachment) {
372
+ res.set({ "Content-Disposition": "attachment; filename=\""+req.query.path+"\"" });
373
+ }
374
+
375
+ let fService = fileService;
376
+ try {
377
+ let file = await fileService.find(req.query.path);
378
+ res.set({ "Content-Length": file.length});
379
+ res.set({ "Content-Type": file.contentType});
380
+ } catch (e) {
381
+ if (e.code == "ENOENT") {
382
+ winston.debug(`File ${req.query.path} not found on primary file service. Fallback to secondary.`)
383
+
384
+ // To instantiate fallbackFileService here where needed you need to wait for the open event.
385
+ // Instance moved on top
386
+ // await new Promise(r => fallbackFileService.conn.once("open", r));
387
+
388
+ try {
389
+ let file = await fallbackFileService.find(req.query.path)
390
+ res.set({ "Content-Length": file.length });
391
+ res.set({ "Content-Type": file.contentType });
392
+ fService = fallbackFileService;
393
+ } catch (e) {
394
+ if (e.code == "ENOENT") {
395
+ winston.debug(`File ${req.query.path} not found on seconday file service.`)
396
+ return res.status(404).send({ success: false, error: 'File not found.' });
397
+ }else {
398
+ winston.error('Error getting file: ', e);
399
+ return res.status(500).send({success: false, error: 'Error getting file.'});
400
+ }
401
+ }
402
+
403
+ } else {
404
+ winston.error('Error getting file', e);
405
+ return res.status(500).send({success: false, error: 'Error getting file.'});
406
+ }
407
+ }
408
+
409
+ fService.getFileDataAsStream(req.query.path).pipe(res);
410
+ });
411
+
412
+ router.get("/download", [
413
+ passport.authenticate(['basic', 'jwt'], { session: false }),
414
+ validtoken,
415
+ ], (req, res) => {
416
+ winston.debug('path', req.query.path);
417
+
418
+ let filename = pathlib.basename(req.query.path);
419
+ winston.debug("filename:"+filename);
420
+
421
+ res.attachment(filename);
422
+ fileService.getFileDataAsStream(req.query.path).pipe(res);
423
+ });
424
+
425
+ /**
426
+ * Delete a file (and its thumbnail if it's an image)
427
+ * Works for both profile photos/avatars and project assets
428
+ *
429
+ * Example:
430
+ * curl -v -X DELETE -u user:pass \
431
+ * http://localhost:3000/filesp?path=uploads%2Fusers%2F65c5f3599faf2d04cd7da528%2Fimages%2Fphoto.jpg
432
+ *
433
+ * curl -v -X DELETE -u user:pass \
434
+ * http://localhost:3000/filesp?path=uploads%2Fprojects%2F65c5f3599faf2d04cd7da528%2Ffiles%2Fuuid%2Flogo.png
435
+ */
436
+ router.delete("/", [
437
+ passport.authenticate(['basic', 'jwt'], { session: false }),
438
+ validtoken,
439
+ ], async (req, res) => {
440
+ try {
441
+ winston.debug("delete file");
442
+
443
+ let filePath = req.query.path;
444
+ if (!filePath) {
445
+ return res.status(400).send({ success: false, error: 'Path parameter is required' });
446
+ }
447
+
448
+ winston.debug("path:" + filePath);
449
+
450
+ let filename = pathlib.basename(filePath);
451
+ winston.debug("filename:" + filename);
452
+
453
+ if (!filename) {
454
+ winston.warn('Error deleting file. No filename specified:' + filePath);
455
+ return res.status(400).send({ success: false, error: 'No filename specified in path' });
456
+ }
457
+
458
+ // Determine which service to use based on path
459
+ // Try primary service first (files bucket)
460
+ let fService = fileService;
461
+ let fileExists = false;
462
+
463
+ try {
464
+ await fileService.find(filePath);
465
+ fileExists = true;
466
+ } catch (e) {
467
+ if (e.code == "ENOENT") {
468
+ winston.debug(`File ${filePath} not found on primary file service. Trying fallback.`);
469
+ try {
470
+ await fallbackFileService.find(filePath);
471
+ fService = fallbackFileService;
472
+ fileExists = true;
473
+ } catch (e2) {
474
+ if (e2.code == "ENOENT") {
475
+ winston.debug(`File ${filePath} not found on fallback file service.`);
476
+ return res.status(404).send({ success: false, error: 'File not found.' });
477
+ } else {
478
+ winston.error('Error checking file on fallback service: ', e2);
479
+ return res.status(500).send({ success: false, error: 'Error checking file existence.' });
480
+ }
481
+ }
482
+ } else {
483
+ winston.error('Error checking file on primary service: ', e);
484
+ return res.status(500).send({ success: false, error: 'Error checking file existence.' });
485
+ }
486
+ }
487
+
488
+ // Delete the main file
489
+ try {
490
+ const deletedFile = await fService.deleteFile(filePath);
491
+ winston.debug("File deleted successfully:", deletedFile.filename);
492
+
493
+ // Check if this is an image and try to delete thumbnail
494
+ // Thumbnail pattern: thumbnails_200_200-{filename}
495
+ // For profile photos: thumbnails_200_200-photo.jpg
496
+ // For assets: thumbnails_200_200-{original_filename}
497
+ const isImage = images_extensions.some(ext => filename.toLowerCase().endsWith(ext));
498
+
499
+ if (isImage) {
500
+ let thumbFilename = 'thumbnails_200_200-' + filename;
501
+ let thumbPath = filePath.replace(filename, thumbFilename);
502
+ winston.debug("thumbPath:" + thumbPath);
503
+
504
+ try {
505
+ // Try to delete thumbnail from the same service
506
+ await fService.deleteFile(thumbPath);
507
+ winston.debug("Thumbnail deleted successfully:" + thumbPath);
508
+ } catch (thumbErr) {
509
+ // Thumbnail might not exist or be in different service, try fallback
510
+ if (thumbErr.code == "ENOENT" || thumbErr.msg == "File not found") {
511
+ winston.debug(`Thumbnail ${thumbPath} not found on ${fService === fileService ? 'primary' : 'fallback'} service. Trying other service.`);
512
+
513
+ const otherService = fService === fileService ? fallbackFileService : fileService;
514
+ try {
515
+ await otherService.deleteFile(thumbPath);
516
+ winston.debug("Thumbnail deleted from fallback service:" + thumbPath);
517
+ } catch (fallbackThumbErr) {
518
+ // Thumbnail doesn't exist, that's ok
519
+ winston.debug(`Thumbnail ${thumbPath} not found on fallback service either. Skipping.`);
520
+ }
521
+ } else {
522
+ winston.error('Error deleting thumbnail:', thumbErr);
523
+ // Don't fail the whole request if thumbnail deletion fails
524
+ }
525
+ }
526
+ }
527
+
528
+ return res.status(200).json({
529
+ message: 'File deleted successfully',
530
+ filename: encodeURIComponent(deletedFile.filename)
531
+ });
532
+
533
+ } catch (deleteErr) {
534
+ winston.error('Error deleting file:', deleteErr);
535
+ return res.status(500).send({ success: false, error: 'Error deleting file.' });
536
+ }
537
+
538
+ } catch (error) {
539
+ winston.error('Error in delete endpoint:', error);
540
+ return res.status(500).send({ success: false, error: 'Error deleting file.' });
541
+ }
542
+ });
543
+
544
+
545
+ module.exports = router;
package/routes/llm.js CHANGED
@@ -105,7 +105,7 @@ router.post('/preview', async (req, res) => {
105
105
  }
106
106
  res.status(200).send(response.data)
107
107
  }).catch((err) => {
108
- if (err.response?.data?.detail[0]) {
108
+ if (err.response?.data?.detail?.[0]) {
109
109
  res.status(400).send({ success: false, error: err.response.data.detail[0]?.msg, detail: err.response.data.detail });
110
110
  } else if (err.response?.data?.detail?.answer) {
111
111
  res.status(400).send({ success: false, error: err.response.data.detail.answer, detail: err.response.data.detail });