@tiledesk/tiledesk-server 2.18.4 → 2.18.16

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.
@@ -2,6 +2,7 @@ const express = require('express');
2
2
  const router = express.Router();
3
3
  const { Namespace, UnansweredQuestion } = require('../models/kb_setting');
4
4
  var winston = require('../config/winston');
5
+ var fastCsv = require('fast-csv');
5
6
 
6
7
  // Add a new unanswered question
7
8
  router.post('/', async (req, res) => {
@@ -252,6 +253,76 @@ router.get('/count/:namespace', async (req, res) => {
252
253
  }
253
254
  });
254
255
 
256
+ router.get('/:namespace/export', async (req, res) => {
257
+ try {
258
+ const { namespace } = req.params;
259
+ const id_project = req.projectid;
260
+ const mode = String(req.query.mode || 'csv').toLowerCase();
261
+
262
+ if (mode !== 'csv' && mode !== 'json') {
263
+ return res.status(400).json({
264
+ success: false,
265
+ error: 'Invalid format. Use mode=json or mode=csv'
266
+ });
267
+ }
268
+
269
+ const isValidNamespace = await validateNamespace(id_project, namespace);
270
+ if (!isValidNamespace) {
271
+ return res.status(403).json({
272
+ success: false,
273
+ error: "Not allowed. The namespace does not belong to the current project."
274
+ });
275
+ }
276
+
277
+ const questions = await UnansweredQuestion.find({ id_project, namespace })
278
+ .sort({ createdAt: -1 })
279
+ .lean();
280
+
281
+ const safeFilename = String(namespace).replace(/[^\w.-]+/g, '_') || 'export';
282
+
283
+ if (mode === 'json') {
284
+ const questionsJson = questions.map((q) => {
285
+ const { __v, updatedAt, ...doc } = q;
286
+ return doc;
287
+ });
288
+ const payload = {
289
+ namespace,
290
+ exportedAt: new Date().toISOString(),
291
+ count: questionsJson.length,
292
+ questions: questionsJson
293
+ };
294
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
295
+ res.setHeader('Content-Disposition', `attachment; filename="unanswered-${safeFilename}.json"`);
296
+ return res.send(JSON.stringify(payload, null, 2));
297
+ }
298
+
299
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8');
300
+ res.setHeader('Content-Disposition', `attachment; filename="unanswered-${safeFilename}.csv"`);
301
+
302
+ const csvStream = fastCsv.format({ headers: true });
303
+ csvStream.pipe(res);
304
+
305
+ for (const q of questions) {
306
+ csvStream.write({
307
+ id: String(q._id),
308
+ namespace: q.namespace,
309
+ question: q.question,
310
+ request_id: q.request_id || '',
311
+ createdAt: q.createdAt ? new Date(q.createdAt).toISOString() : ''
312
+ });
313
+ }
314
+ csvStream.end();
315
+ } catch (error) {
316
+ winston.error('Error exporting unanswered questions:', error);
317
+ if (!res.headersSent) {
318
+ res.status(500).json({
319
+ success: false,
320
+ error: "Error exporting unanswered questions"
321
+ });
322
+ }
323
+ }
324
+ });
325
+
255
326
  // Helper function to validate namespace
256
327
  async function validateNamespace(id_project, namespace_id) {
257
328
  try {
@@ -0,0 +1,57 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const winston = require('../config/winston');
4
+ const urlPreviewService = require('../services/urlPreviewService');
5
+
6
+ const PRIVATE_IP = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|0\.0\.0\.0|::1)/;
7
+ const MAX_URLS = 10;
8
+
9
+ function isSafeUrl(rawUrl) {
10
+ try {
11
+ const { protocol, hostname } = new URL(rawUrl);
12
+ if (!['http:', 'https:'].includes(protocol)) return false;
13
+ if (PRIVATE_IP.test(hostname)) return false;
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ // POST /:projectid/url-preview — public endpoint (used by the widget without auth)
21
+ // Body: { "urls": ["https://example.com", "https://another.com"] }
22
+ router.post('/', async (req, res) => {
23
+ const urls = req.body.urls;
24
+
25
+ if (!Array.isArray(urls) || urls.length === 0) {
26
+ return res.status(422).send({ success: false, error: 'urls must be a non-empty array' });
27
+ }
28
+
29
+ if (urls.length > MAX_URLS) {
30
+ return res.status(422).send({ success: false, error: `urls must contain at most ${MAX_URLS} items` });
31
+ }
32
+
33
+ const safeUrls = [];
34
+ const blocked = [];
35
+ for (const url of urls) {
36
+ if (isSafeUrl(url)) {
37
+ safeUrls.push(url);
38
+ } else {
39
+ blocked.push({ url, success: false, error: 'URL not allowed' });
40
+ }
41
+ }
42
+
43
+ try {
44
+ const fetched = safeUrls.length > 0 ? await urlPreviewService.fetchPagesPreviews(safeUrls) : [];
45
+ // Merge results preserving original order
46
+ const resultMap = new Map(fetched.map(r => [r.url, r]));
47
+ const previews = urls.map(url =>
48
+ resultMap.get(url) || blocked.find(b => b.url === url)
49
+ );
50
+ res.status(200).send(previews);
51
+ } catch (err) {
52
+ winston.error('[urlPreview] error', err);
53
+ res.status(500).send({ success: false, error: err.message || 'Internal error' });
54
+ }
55
+ });
56
+
57
+ module.exports = router;
package/routes/webhook.js CHANGED
@@ -6,6 +6,7 @@ const JobManager = require('../utils/jobs-worker-queue-manager/JobManagerV2');
6
6
  const { AiReindexService } = require('../services/aiReindexService');
7
7
  const { Webhook } = require('../models/webhook');
8
8
  const webhookService = require('../services/webhookService');
9
+ const webhookEvent = require('../event/webhookEvent');
9
10
  const errorCodes = require('../errorCodes');
10
11
  const aiManager = require('../services/aiManager');
11
12
  var ObjectId = require('mongoose').Types.ObjectId;
@@ -311,6 +312,7 @@ router.all('/:webhook_id', async (req, res) => {
311
312
  //webhookService.run(webhook, payload)
312
313
  // To delete - End
313
314
  webhookService.run(webhook, payload, dev, redis_client).then((response) => {
315
+ webhookEvent.emit("webhook.triggered", { webhook: webhook, payload: payload });
314
316
  return res.status(200).send(response);
315
317
  }).catch((err) => {
316
318
  if (err.code === errorCodes.WEBHOOK.ERRORS.NO_PRELOADED_DEV_REQUEST) {
@@ -46,6 +46,19 @@ class Scheduler {
46
46
  }
47
47
 
48
48
  }
49
+
50
+ deleteSchedule(data, callback) {
51
+ winston.debug("(deleteScheduler) data: ", data);
52
+ this.jobManager.publishDelete(data, (err, ok) => {
53
+ let response_data = { success: true, message: "Scheduled" };
54
+ if (err) {
55
+ response_data = { success: false, message: "Task not scheduled" };
56
+ }
57
+ if (callback) {
58
+ callback(err, response_data);
59
+ }
60
+ });
61
+ }
49
62
 
50
63
  }
51
64
 
@@ -412,6 +412,36 @@ class AiManager {
412
412
  })
413
413
  }
414
414
 
415
+ async deleteSitemap(sitemapContent, namespace) {
416
+
417
+ const sitemapContentId = sitemapContent._id;
418
+ const relatedContents = await KB.find({ id_project: sitemapContent.id_project, namespace: sitemapContent.namespace, sitemap_origin_id: sitemapContentId }).lean().exec();
419
+
420
+ if (!relatedContents.length) {
421
+ return;
422
+ }
423
+
424
+ const engine = namespace.engine || default_engine;
425
+ const scheduler = new Scheduler({ jobManager: jobManager });
426
+
427
+ await Promise.all(relatedContents.map((kb) => {
428
+ return new Promise((resolve) => {
429
+ const data = {
430
+ id: kb._id,
431
+ namespace: kb.namespace,
432
+ engine: engine
433
+ };
434
+ scheduler.deleteSchedule(data, (err, result) => {
435
+ if (err) {
436
+ winston.error("deleteSitemap: deleteSchedule error for kb " + kb._id, err);
437
+ }
438
+ console.log("deleteSitemap: deleteSchedule result for kb " + kb._id, result);
439
+ resolve(result);
440
+ });
441
+ });
442
+ }));
443
+ }
444
+
415
445
  async saveBulk(operations, kbs, project_id, namespace) {
416
446
 
417
447
  return new Promise((resolve, reject) => {
@@ -0,0 +1,50 @@
1
+ const axios = require('axios');
2
+ const winston = require('../config/winston');
3
+
4
+ class UrlPreviewService {
5
+
6
+ _extractMeta(html, url) {
7
+ const getMeta = (name) => {
8
+ const m = html.match(new RegExp(`<meta[^>]+(?:name|property)=["']${name}["'][^>]+content=["']([^"']+)["']`, 'i'))
9
+ || html.match(new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:name|property)=["']${name}["']`, 'i'));
10
+ return m ? m[1] : null;
11
+ };
12
+ const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
13
+ return {
14
+ url,
15
+ title: getMeta('og:title') || (titleMatch && titleMatch[1].trim()) || null,
16
+ description: getMeta('og:description') || getMeta('description') || null,
17
+ image: getMeta('og:image') || getMeta('twitter:image') || null,
18
+ siteName: getMeta('og:site_name') || null,
19
+ type: getMeta('og:type') || null,
20
+ locale: getMeta('og:locale') || null,
21
+ author: getMeta('author') || getMeta('article:author') || null,
22
+ keywords: getMeta('keywords') || null,
23
+ twitterCard: getMeta('twitter:card') || null,
24
+ twitterTitle: getMeta('twitter:title') || null,
25
+ twitterDescription: getMeta('twitter:description') || null,
26
+ twitterImage: getMeta('twitter:image') || null,
27
+ };
28
+ }
29
+
30
+ async fetchPagePreview(url) {
31
+ const response = await axios.get(url, {
32
+ timeout: 10000,
33
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; TiledeskBot/1.0)' },
34
+ maxContentLength: 2000000,
35
+ });
36
+ return this._extractMeta(response.data, url);
37
+ }
38
+
39
+ async fetchPagesPreviews(urls) {
40
+ const results = await Promise.allSettled(urls.map(url => this.fetchPagePreview(url)));
41
+ return results.map((result, i) => {
42
+ if (result.status === 'fulfilled') {
43
+ return { url: urls[i], success: true, data: result.value };
44
+ }
45
+ return { url: urls[i], success: false, error: result.reason?.message || 'Failed to fetch' };
46
+ });
47
+ }
48
+ }
49
+
50
+ module.exports = new UrlPreviewService();
@@ -21,15 +21,7 @@ class JobManager {
21
21
  var that = this;
22
22
  if (this.info) {console.log("[JobWorker] JobManager publisher started");}
23
23
 
24
- this.queueManager.connect(function(status, err) {
25
-
26
- if (err) {
27
- console.log("[JobWorker] - connectAndStartPublisher - connection error: ", err);
28
- if (callback) {
29
- callback(null, err)
30
- return;
31
- }
32
- }
24
+ this.queueManager.connect(function() {
33
25
  if (that.debug) {console.log("[JobWorker] Queue started");}
34
26
  that.queuePublisherConnected = true;
35
27
 
@@ -49,8 +41,7 @@ class JobManager {
49
41
  }
50
42
 
51
43
  if (callback) {
52
- callback(status, null);
53
- return;
44
+ callback();
54
45
  }
55
46
  });
56
47
  });
@@ -108,10 +99,30 @@ class JobManager {
108
99
  // this.connectAndStartPublisher();
109
100
  // this.sendingJobs.push(packet);
110
101
 
111
- // }
102
+ // }
112
103
 
113
104
  }
114
105
 
106
+ publishDelete(payload, callback) {
107
+
108
+ var packet = { payload: payload };
109
+ const routingKey = this.queueManager.deleteRoutingKey;
110
+
111
+ if (this.queuePublisherConnected == true) {
112
+
113
+ if (this.debug) { console.log("[JobWorker] JobManager publishDelete routingKey: " + routingKey); }
114
+ this.queueManager.sendJson(packet, routingKey, (err, ok) => {
115
+ if (err) {
116
+ console.error("sendJson (delete) error: ", err);
117
+ } else {
118
+ if (this.debug) { console.log("sendJson (delete) ok"); }
119
+ }
120
+ callback(err, ok);
121
+ });
122
+
123
+ }
124
+ }
125
+
115
126
 
116
127
  //Deprecated
117
128
  schedule(fn, payload) {