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
23 changes: 23 additions & 0 deletions Lib/DownloadHeaderPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Modules\ModuleExtendedCDRs\Lib;

final class DownloadHeaderPolicy
{
public static function attachment(string $filename): string
{
$clean = str_replace(["\r", "\n", '"', "\0"], '', basename($filename));
if ($clean === '') {
$clean = 'download';
}

$fallback = preg_replace('/[^A-Za-z0-9._-]+/', '_', $clean);
if (!is_string($fallback) || $fallback === '') {
$fallback = 'download';
}

return 'attachment; filename="' . $fallback . '"; filename*=UTF-8\'\'' . rawurlencode($clean);
}
}
6 changes: 4 additions & 2 deletions Lib/ExtendedCDRsConf.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,11 @@ public function moduleRestAPICallback(array $request): PBXApiResult
*/
public function getPBXCoreRESTAdditionalRoutes(): array
{
// Route element 5 is noAuth in MikoPBX Core. All CDR metadata and
// recording endpoints intentionally require authentication.
return [
[ApiController::class, 'downloads', '/pbxcore/api/modules/ModuleExtendedCDRs/downloads', 'get', '/', false],
[ApiController::class, 'exportHistory', '/pbxcore/api/modules/ModuleExtendedCDRs/exportHistory', 'get', '/', true],
[ApiController::class, 'exportHistory', '/pbxcore/api/modules/ModuleExtendedCDRs/exportHistory', 'get', '/', false],
[ApiController::class, 'exportHistoryDetail', '/pbxcore/api/modules/ModuleExtendedCDRs/exportHistoryDetail', 'get', '/', false],
[ApiController::class, 'recordsAction', '/pbxcore/api/modules/ModuleExtendedCDRs/records', 'get', '/', false],
[ApiController::class, 'exportOutgoingEmployeeCalls', '/pbxcore/api/modules/ModuleExtendedCDRs/exportOutgoingEmployeeCalls', 'get', '/', false],
Expand Down Expand Up @@ -147,4 +149,4 @@ public function createCronTasks(array &$tasks): void
$tasks[] = "$m $h $dateMonth * $dayWeek $phpPath $this->moduleDir/bin/report2email.php '$settings->id' > /dev/null 2>&1".PHP_EOL;
}
}
}
}
143 changes: 143 additions & 0 deletions Lib/RecordingArchiveBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

declare(strict_types=1);

namespace Modules\ModuleExtendedCDRs\Lib;

final class RecordingArchiveBuilder
{
private const DEFAULT_MAX_RECORDS = 2000;
private const DEFAULT_MAX_BYTES = 2147483648;
private const DEFAULT_MAX_CANDIDATES = 5000;
private const MIN_FREE_BYTES = 67108864;

private RecordingPathPolicy $policy;
private string $tempRoot;
private int $maxRecords;
private int $maxBytes;
private int $maxCandidates;

public function __construct(
RecordingPathPolicy $policy,
string $tempRoot,
int $maxRecords = self::DEFAULT_MAX_RECORDS,
int $maxBytes = self::DEFAULT_MAX_BYTES,
int $maxCandidates = self::DEFAULT_MAX_CANDIDATES
) {
if ($maxRecords < 1 || $maxBytes < 1 || $maxCandidates < 1) {
throw new \InvalidArgumentException('Archive limits must be positive');
}
$this->policy = $policy;
$this->tempRoot = rtrim($tempRoot, DIRECTORY_SEPARATOR);
$this->maxRecords = $maxRecords;
$this->maxBytes = $maxBytes;
$this->maxCandidates = $maxCandidates;
}

/**
* @param array<int,array{path:string,name:string}> $records
*/
public function build(array $records): RecordingArchiveResult
{
$this->ensureTempRoot();
$archivePath = $this->tempRoot . DIRECTORY_SEPARATOR . bin2hex(random_bytes(16)) . '.tar';
$accepted = 0;
$skipped = 0;
$acceptedBytes = 0;
$inspected = 0;
$usedNames = [];

try {
$archive = new \PharData($archivePath);
foreach ($records as $record) {
$inspected++;
if ($inspected > $this->maxCandidates) {
throw new \RuntimeException('archive_too_large');
}
if (!isset($record['path'], $record['name'])
|| !is_string($record['path']) || !is_string($record['name'])) {
$skipped++;
continue;
}

$allowed = $this->policy->validate($record['path']);
if (!$allowed->isAllowed() || $allowed->path() === null) {
$skipped++;
continue;
}

$fileSize = filesize($allowed->path());
if ($fileSize === false) {
$skipped++;
continue;
}
if ($accepted >= $this->maxRecords || $acceptedBytes + $fileSize > $this->maxBytes) {
throw new \RuntimeException('archive_too_large');
}
$freeBytes = disk_free_space($this->tempRoot);
if ($freeBytes !== false && $fileSize + self::MIN_FREE_BYTES > $freeBytes) {
throw new \RuntimeException('archive_too_large');
}

$extension = strtolower((string) pathinfo((string) $allowed->downloadName(), PATHINFO_EXTENSION));
$entryName = $this->uniqueEntryName($record['name'], $extension, $usedNames);
$archive->addFile($allowed->path(), $entryName);
$accepted++;
$acceptedBytes += $fileSize;
}

unset($archive);
if ($accepted === 0) {
@unlink($archivePath);
throw new \RuntimeException('archive_has_no_valid_entries');
}

return new RecordingArchiveResult($archivePath, $accepted, $skipped);
} catch (\RuntimeException $exception) {
@unlink($archivePath);
if (in_array($exception->getMessage(), ['archive_has_no_valid_entries', 'archive_too_large'], true)) {
throw $exception;
}
throw new \RuntimeException('archive_build_failed', 0, $exception);
} catch (\Throwable $exception) {
@unlink($archivePath);
throw new \RuntimeException('archive_build_failed', 0, $exception);
}
}

private function ensureTempRoot(): void
{
if (!is_dir($this->tempRoot) && !mkdir($this->tempRoot, 0700, true) && !is_dir($this->tempRoot)) {
throw new \RuntimeException('archive_build_failed');
}
@chmod($this->tempRoot, 0700);
}

/**
* @param array<string,bool> $usedNames
*/
private function uniqueEntryName(string $displayName, string $extension, array &$usedNames): string
{
$stem = preg_replace('/[^\pL\pN._-]+/u', '-', $displayName);
if (!is_string($stem)) {
$stem = '';
}
$stem = trim($stem, ".-_ \t\n\r\0\x0B");
if ($stem === '') {
$stem = 'recording';
}
// POSIX ustar entry names are limited to 100 bytes. Leave room for
// duplicate suffixes and the validated extension.
$stem = function_exists('mb_strcut') ? mb_strcut($stem, 0, 80, 'UTF-8') : substr($stem, 0, 80);

$candidate = $stem . '.' . $extension;
$suffix = 2;
while (isset($usedNames[$candidate])) {
$candidate = $stem . '-' . $suffix . '.' . $extension;
$suffix++;
}
$usedNames[$candidate] = true;

return $candidate;
}
}
34 changes: 34 additions & 0 deletions Lib/RecordingArchiveResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Modules\ModuleExtendedCDRs\Lib;

final class RecordingArchiveResult
{
private string $path;
private int $acceptedCount;
private int $skippedCount;

public function __construct(string $path, int $acceptedCount, int $skippedCount)
{
$this->path = $path;
$this->acceptedCount = $acceptedCount;
$this->skippedCount = $skippedCount;
}

public function path(): string
{
return $this->path;
}

public function acceptedCount(): int
{
return $this->acceptedCount;
}

public function skippedCount(): int
{
return $this->skippedCount;
}
}
63 changes: 63 additions & 0 deletions Lib/RecordingPathPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace Modules\ModuleExtendedCDRs\Lib;

final class RecordingPathPolicy
{
private string $recordingRoot;

/** @var array<string,string> */
private array $mimeTypes;

/**
* @param array<string,string> $mimeTypes
*/
public function __construct(string $recordingRoot, array $mimeTypes = [])
{
$realRoot = realpath($recordingRoot);
if ($realRoot === false || !is_dir($realRoot)) {
throw new \InvalidArgumentException('Recording root does not exist');
}

$this->recordingRoot = rtrim($realRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$this->mimeTypes = $mimeTypes ?: [
'mp3' => 'audio/mpeg',
'wav' => 'audio/wav',
'webm' => 'audio/webm',
];
}

public function validate(string $candidate): RecordingPathResult
{
if ($candidate === '' || strpos($candidate, "\0") !== false
|| preg_match('~^[a-z][a-z0-9+.-]*://~i', $candidate) === 1) {
return RecordingPathResult::rejected('invalid_recording_path', 404);
}

$realCandidate = realpath($candidate);
if ($realCandidate === false || !is_file($realCandidate)) {
return RecordingPathResult::rejected('missing_recording', 404);
}

if (strpos($realCandidate . DIRECTORY_SEPARATOR, $this->recordingRoot) !== 0) {
return RecordingPathResult::rejected('outside_recording_root', 404);
}

$extension = strtolower((string) pathinfo($realCandidate, PATHINFO_EXTENSION));
if (!array_key_exists($extension, $this->mimeTypes)) {
return RecordingPathResult::rejected('unsupported_media_type', 415);
}

if (!is_readable($realCandidate)) {
return RecordingPathResult::rejected('missing_recording', 404);
}

return RecordingPathResult::allowed(
$realCandidate,
$this->mimeTypes[$extension],
basename($realCandidate)
);
}
}
71 changes: 71 additions & 0 deletions Lib/RecordingPathResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

namespace Modules\ModuleExtendedCDRs\Lib;

final class RecordingPathResult
{
private bool $allowed;
private string $reason;
private int $status;
private ?string $path;
private ?string $mimeType;
private ?string $downloadName;

private function __construct(
bool $allowed,
string $reason,
int $status,
?string $path,
?string $mimeType,
?string $downloadName
) {
$this->allowed = $allowed;
$this->reason = $reason;
$this->status = $status;
$this->path = $path;
$this->mimeType = $mimeType;
$this->downloadName = $downloadName;
}

public static function allowed(string $path, string $mimeType, string $downloadName): self
{
return new self(true, 'allowed', 200, $path, $mimeType, $downloadName);
}

public static function rejected(string $reason, int $status): self
{
return new self(false, $reason, $status, null, null, null);
}

public function isAllowed(): bool
{
return $this->allowed;
}

public function reason(): string
{
return $this->reason;
}

public function status(): int
{
return $this->status;
}

public function path(): ?string
{
return $this->path;
}

public function mimeType(): ?string
{
return $this->mimeType;
}

public function downloadName(): ?string
{
return $this->downloadName;
}
}
23 changes: 23 additions & 0 deletions Lib/ReportSearchPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Modules\ModuleExtendedCDRs\Lib;

final class ReportSearchPolicy
{
public static function isValid(string $search): bool
{
if ($search === '') {
return false;
}

$decoded = json_decode($search, true);
if (!is_array($decoded)) {
return false;
}

$dateRange = $decoded['dateRangeSelector'] ?? null;
return is_string($dateRange) && trim($dateRange) !== '';
}
}
Loading
Loading