Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 65 additions & 5 deletions public/assets/js/module-export-records-index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion public/assets/js/module-export-records-index.js.map

Large diffs are not rendered by default.

62 changes: 58 additions & 4 deletions public/assets/js/src/module-export-records-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -667,7 +667,7 @@ const ModuleExtendedCDRs = {
$('#downloadRecords').on('click', function (e) {
const encodedSearch = encodeURIComponent(ModuleExtendedCDRs.getSearchText());
const url = `${window.location.origin}/pbxcore/api/modules/${className}/downloads?search=${encodedSearch}`;
window.open(url, '_blank');
ModuleExtendedCDRs.authenticatedDownload(url, 'recordings.tar');
});
$('#saveSearchSettings').on('click', function (e) {
ModuleExtendedCDRs.saveSearchSettings();
Expand Down Expand Up @@ -1370,7 +1370,61 @@ const ModuleExtendedCDRs = {

const encodedSearch = encodeURIComponent(ModuleExtendedCDRs.getSearchText());
const url = `${window.location.origin}/pbxcore/api/modules/${className}/exportHistory?reportNameID=${reportNameID}&type=${type}&search=${encodedSearch}&title=`+encodeURIComponent(title);
window.open(url, '_blank');
ModuleExtendedCDRs.authenticatedDownload(url, `report.${type}`);
},

/**
* Downloads a protected PBXCore response with the current access token.
* Session credentials remain enabled for MikoPBX versions predating JWT authentication.
*
* @param {string} url
* @param {string} fallbackFilename
* @returns {Promise<void>}
*/
authenticatedDownload(url, fallbackFilename = 'download') {
const headers = {};
if (typeof TokenManager !== 'undefined' && TokenManager.accessToken) {
headers.Authorization = `Bearer ${TokenManager.accessToken}`;
}

return fetch(url, {
method: 'GET',
headers: headers,
credentials: 'same-origin',
}).then(response => {
if (!response.ok) {
throw new Error(`Download failed with HTTP ${response.status}`);
}

let filename = fallbackFilename;
const disposition = response.headers.get('Content-Disposition');
if (disposition) {
const encodedName = disposition.match(/filename\*=UTF-8''([^;]+)/i);
const quotedName = disposition.match(/filename="?([^";]+)"?/i);
if (encodedName) {
try {
filename = decodeURIComponent(encodedName[1]);
} catch (error) {
console.warn('Unable to decode download filename', error);
}
} else if (quotedName) {
filename = quotedName[1];
}
}

return response.blob().then(blob => ({blob, filename}));
}).then(({blob, filename}) => {
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(blobUrl);
}).catch(error => {
console.error('Authenticated download failed', error);
});
},

getMaxWidth(data, key) {
Expand All @@ -1397,7 +1451,8 @@ const ModuleExtendedCDRs = {
typeRec = 'out';
}
let numbers = ModuleExtendedCDRs.$globalSearch.val();
window.open('/pbxcore/api/modules/'+className+'/downloads?start='+startTime+'&end='+endTime+"&numbers="+encodeURIComponent(numbers)+"&type="+typeRec, '_blank');
const url = '/pbxcore/api/modules/'+className+'/downloads?start='+startTime+'&end='+endTime+"&numbers="+encodeURIComponent(numbers)+"&type="+typeRec;
ModuleExtendedCDRs.authenticatedDownload(url, 'recordings.tar');
},

startDownloadHistory(){
Expand Down Expand Up @@ -1547,4 +1602,3 @@ const ModuleExtendedCDRs = {
$(document).ready(() => {
window[className].initialize();
});

48 changes: 48 additions & 0 deletions tests/AuthenticatedDownloadsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

function assertAuthenticatedDownload(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}

$source = file_get_contents(dirname(__DIR__) . '/public/assets/js/src/module-export-records-index.js');
$compiled = file_get_contents(dirname(__DIR__) . '/public/assets/js/module-export-records-index.js');
assertAuthenticatedDownload(is_string($source), 'Cannot read download JavaScript');
assertAuthenticatedDownload(is_string($compiled), 'Cannot read compiled download JavaScript');

foreach ([
'authenticatedDownload(url',
"headers.Authorization = `Bearer \${TokenManager.accessToken}`",
'response.blob()',
'URL.createObjectURL(blob)',
"response.headers.get('Content-Disposition')",
] as $required) {
assertAuthenticatedDownload(strpos($source, $required) !== false, 'Missing authenticated download behavior: ' . $required);
}

foreach ([
"window.open(url, '_blank')",
"window.open('/pbxcore/api/modules/'+className+'/downloads?",
] as $forbidden) {
assertAuthenticatedDownload(strpos($source, $forbidden) === false, 'Protected download bypasses Bearer authentication');
}

assertAuthenticatedDownload(
substr_count($source, 'ModuleExtendedCDRs.authenticatedDownload(') >= 3,
'XLS/PDF and recording archive downloads must use authenticated transport'
);
assertAuthenticatedDownload(
strpos($compiled, 'Authenticated download failed') !== false
&& strpos($compiled, 'response.blob()') !== false,
'Compiled asset is missing authenticated download behavior'
);
assertAuthenticatedDownload(
preg_match('/\brequire\s*\(/', $compiled) === 0,
'Browser asset must not depend on CommonJS require()'
);

echo "AuthenticatedDownloadsTest: OK\n";
Loading