@peopl-health/nexus 3.3.10 → 3.3.11
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,9 @@ const axios = require('axios');
|
|
|
2
2
|
const { v4: uuidv4 } = require('uuid');
|
|
3
3
|
|
|
4
4
|
const runtimeConfig = require('../config/runtimeConfig');
|
|
5
|
+
const { isAllowedUrl, redactUrl } = require('../utils/sanitizerUtils');
|
|
6
|
+
|
|
7
|
+
const TWILIO_CDN_DOMAINS = ['api.twilio.com', 'mms.twiliocdn.com', 'media.twiliocdn.com'];
|
|
5
8
|
|
|
6
9
|
function convertTwilioToInternalFormat(twilioMessage) {
|
|
7
10
|
const from = twilioMessage.From || '';
|
|
@@ -23,29 +26,59 @@ function convertTwilioToInternalFormat(twilioMessage) {
|
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
async function downloadMediaFromTwilio(mediaUrl, logger, maxRetries = 5) {
|
|
29
|
+
if (!isAllowedUrl(mediaUrl, TWILIO_CDN_DOMAINS)) {
|
|
30
|
+
throw new Error('Invalid media URL domain');
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
const accountSid = runtimeConfig.get('TWILIO_ACCOUNT_SID');
|
|
27
34
|
const authToken = runtimeConfig.get('TWILIO_AUTH_TOKEN');
|
|
28
35
|
const authorization = `Basic ${Buffer.from(`${accountSid}:${authToken}`).toString('base64')}`;
|
|
29
36
|
|
|
30
37
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
31
38
|
try {
|
|
32
|
-
logger.debug('[TwilioMedia] Download attempt', { attempt, maxRetries });
|
|
39
|
+
logger.debug('[TwilioMedia] Download attempt', { attempt, maxRetries, url: redactUrl(mediaUrl) });
|
|
33
40
|
|
|
34
|
-
const
|
|
41
|
+
const redirectResponse = await axios.get(mediaUrl, {
|
|
35
42
|
headers: {
|
|
36
43
|
'Authorization': authorization,
|
|
37
44
|
'User-Agent': 'Nexus-Media-Processor/1.0'
|
|
38
45
|
},
|
|
46
|
+
maxRedirects: 0,
|
|
47
|
+
validateStatus: status => status >= 200 && status < 400,
|
|
48
|
+
responseType: 'arraybuffer',
|
|
49
|
+
timeout: 30000
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (redirectResponse.status === 200) {
|
|
53
|
+
return Buffer.from(redirectResponse.data);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const cdnUrl = redirectResponse.headers.location;
|
|
57
|
+
if (!cdnUrl) {
|
|
58
|
+
throw new Error('No redirect URL in response');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!isAllowedUrl(cdnUrl, TWILIO_CDN_DOMAINS)) {
|
|
62
|
+
throw new Error('Redirect URL not in allowed domains');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
logger.debug('[TwilioMedia] Following redirect', { url: redactUrl(cdnUrl) });
|
|
66
|
+
|
|
67
|
+
const response = await axios.get(cdnUrl, {
|
|
68
|
+
headers: { 'User-Agent': 'Nexus-Media-Processor/1.0' },
|
|
39
69
|
responseType: 'arraybuffer',
|
|
40
70
|
timeout: 30000
|
|
41
71
|
});
|
|
42
72
|
return Buffer.from(response.data);
|
|
43
73
|
} catch (error) {
|
|
44
74
|
const is404 = error.response?.status === 404;
|
|
75
|
+
const isDnsError = error.code === 'ENOTFOUND';
|
|
45
76
|
const isLastAttempt = attempt === maxRetries;
|
|
46
77
|
|
|
47
|
-
if (is404 && !isLastAttempt) {
|
|
48
|
-
|
|
78
|
+
if ((is404 || isDnsError) && !isLastAttempt) {
|
|
79
|
+
const delay = Math.pow(2, attempt - 1) * 1000;
|
|
80
|
+
logger.warn('[TwilioMedia] Download failed, retrying', { error: error.message, attempt, delay });
|
|
81
|
+
await new Promise(r => setTimeout(r, delay));
|
|
49
82
|
continue;
|
|
50
83
|
}
|
|
51
84
|
|
|
@@ -54,7 +87,7 @@ async function downloadMediaFromTwilio(mediaUrl, logger, maxRetries = 5) {
|
|
|
54
87
|
return null;
|
|
55
88
|
}
|
|
56
89
|
|
|
57
|
-
logger.error('[TwilioMedia] Download failed', { error: error.message,
|
|
90
|
+
logger.error('[TwilioMedia] Download failed', { error: error.message, attempt });
|
|
58
91
|
throw error;
|
|
59
92
|
}
|
|
60
93
|
}
|
|
@@ -52,11 +52,33 @@ function validateRequired(params, fields) {
|
|
|
52
52
|
return missing.length ? missing : null;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
function isAllowedUrl(url, allowedDomains) {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = new URL(url);
|
|
58
|
+
return allowedDomains.some(domain => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`));
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function redactUrl(url) {
|
|
65
|
+
try {
|
|
66
|
+
const parsed = new URL(url);
|
|
67
|
+
const pathParts = parsed.pathname.split('/');
|
|
68
|
+
const id = pathParts.find(p => /^[A-Z]{2}[a-f0-9]{32}$/i.test(p)) || pathParts.pop();
|
|
69
|
+
return `${parsed.hostname}/.../${id}`;
|
|
70
|
+
} catch {
|
|
71
|
+
return '[invalid-url]';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
55
75
|
module.exports = {
|
|
56
76
|
sanitizeFilename,
|
|
57
77
|
sanitizeMediaFilename,
|
|
58
78
|
sanitizeLogMetadata,
|
|
59
79
|
maskSensitiveValue,
|
|
60
80
|
cleanObject,
|
|
61
|
-
validateRequired
|
|
81
|
+
validateRequired,
|
|
82
|
+
isAllowedUrl,
|
|
83
|
+
redactUrl
|
|
62
84
|
};
|