Recording Security & HTML5 Player for ViciDial
Apache mod_rewrite Protection + Dark-Themed HTML5 Audio Player with PHP Streaming Backend
| Difficulty | Beginner-Intermediate |
| Time to Complete | 1-2 hours |
| Prerequisites | ViciDial server with Apache, PHP 7.4+, mod_rewrite enabled |
| Tested On | openSUSE 15.x (ViciBox), CentOS 7, Apache 2.4, PHP 8.x |
Table of Contents
- Introduction
- The Problem: ViciDial Recordings Are Wide Open
- Architecture Overview
- Prerequisites
- Step 1: Verify Your Current Exposure
- Step 2: Create the Recording Player Directory
- Step 3: Apache Configuration with mod_rewrite
- Step 4: PHP Streaming Proxy (stream.php)
- Step 5: HTML5 Player with Dark Theme (play.php)
- Step 6: Enhanced Player with Waveform Visualization
- Step 7: Testing and Verification
- Step 8: Integration with ViciDial
- Security Considerations
- Troubleshooting
- What's Next
Introduction
Every ViciDial installation stores call recordings as MP3 files on the web server. By default, those files are served through a simple Apache Alias directive with directory indexing enabled. Anyone who knows (or guesses) the URL pattern can browse and download every recording your system has ever made -- customer conversations, credit card numbers read aloud, medical information, internal discussions. All of it, with zero authentication.
This tutorial shows you how to lock down recording access using Apache's mod_rewrite to intercept direct file requests and redirect them through a PHP-based streaming proxy. Non-whitelisted visitors see recordings through a custom HTML5 audio player that supports playback but prevents easy downloading. Whitelisted IPs (your CRM, admin panels, internal tools) continue to access files directly with no disruption.
The result is a dark-themed, modern audio player that parses ViciDial's recording filenames to display call metadata (date, time, caller ID), streams audio with full seeking support, and enforces download restrictions at both the Apache and application level.
What problems does this solve?
- Open directory listing: Default ViciDial exposes a browsable index of every recording at
/RECORDINGS/MP3/ - Unrestricted downloads: Anyone with the URL can download recordings in bulk
- No access control: There is no authentication layer between the internet and your recordings
- Compliance risk: GDPR, PCI-DSS, HIPAA, and similar regulations require access controls on recorded conversations
- No audit trail: Default Apache serving provides no logging of who accessed which recording
The Problem: ViciDial Recordings Are Wide Open
A stock ViciDial installation creates this Apache configuration (typically in /etc/apache2/conf.d/ or /etc/httpd/conf.d/):
Alias /RECORDINGS/ "/var/spool/asterisk/monitorDONE/"
<Directory "/var/spool/asterisk/monitorDONE">
Options Indexes MultiViews FollowSymLinks
AllowOverride None
Require all granted
</Directory>
This means:
- Browsable index: Visit
http://YOUR_SERVER_IP/RECORDINGS/MP3/and you get a full directory listing of every MP3 recording - Direct download: Any file is downloadable by URL --
http://YOUR_SERVER_IP/RECORDINGS/MP3/20260313120000_441234567890_449876543210-all.mp3 - No authentication:
Require all grantedmeans the entire internet has access - Filename = metadata: ViciDial recording filenames follow the pattern
YYYYMMDDHHMMSS_DID_CallerID-all.mp3, so the filenames themselves leak the date, time, destination number, and caller's phone number
If your ViciDial server is internet-facing (and most are, since agents connect remotely), your recordings are currently accessible to anyone who can reach port 80 or 443.
Real-world impact
A typical call center generates 500-5,000 recordings per day. Over a year, that is 180,000 to 1.8 million recordings sitting in an open directory. Even if no one has discovered your /RECORDINGS/ path yet, automated scanners and search engine crawlers can find it. One leaked URL in a browser history, a CRM export, or a shared link and the entire archive is exposed.
Architecture Overview
┌──────────────────────────────────────────────────┐
│ ViciDial Server │
│ │
External User │ Apache (mod_rewrite) │
─────────────► │ ┌──────────────────────────┐ │
GET /RECORDINGS/ │ │ Is IP whitelisted? │ │
MP3/file.mp3 │ │ │ │
│ │ YES ──► Serve file │ │
│ │ directly │ │
│ │ │ │
│ │ NO ───► 302 Redirect │ │
│ │ to play.php │ │
│ └──────────┬───────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ play.php (HTML5 Player) │ │
│ │ - Dark themed UI │ │
│ │ - File metadata display │ │
│ │ - controlsList=nodownload│ │
│ │ - IP-based download gate │ │
│ └──────────┬───────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ stream.php (PHP Proxy) │ /var/spool/ │
│ │ - Range request support ├─► asterisk/ │
│ │ - Inline Content-Disp. │ monitorDONE/ │
│ │ - Input validation │ MP3/ │
│ │ - Path traversal block │ │
│ └──────────────────────────┘ │
└──────────────────────────────────────────────────┘
Three layers of protection:
- Apache mod_rewrite -- Intercepts direct file requests at the web server level and redirects non-whitelisted IPs to the player
- PHP streaming proxy -- Validates filenames, prevents path traversal, and streams audio with
Content-Disposition: inline(play, not download) - HTML5 player -- Uses
controlsList="nodownload"to hide the browser's download button and blocks right-click context menus
No single layer is bulletproof (a determined user can always save a stream), but the combination makes casual downloading difficult and provides a controlled access point where you can add logging, authentication, or rate limiting later.
Prerequisites
Before you begin, confirm:
- Apache with
mod_rewriteinstalled and enabled - PHP 7.4 or later (8.x recommended), running as an Apache module or via PHP-FPM
- ViciDial with recordings stored in the default location (
/var/spool/asterisk/monitorDONE/MP3/) - Root or sudo access to the server
- SSH access to the server
Verify mod_rewrite is loaded
# For Apache 2.4 (openSUSE / ViciBox)
grep -r "mod_rewrite" /etc/apache2/sysconfig.d/ 2>/dev/null
# For Apache 2.4 (CentOS / RHEL)
httpd -M 2>/dev/null | grep rewrite
# For any system
apachectl -M 2>/dev/null | grep rewrite
You should see:
rewrite_module (shared)
If mod_rewrite is not loaded:
# openSUSE / ViciBox
a2enmod rewrite
# CentOS / RHEL
# Add to /etc/httpd/conf.modules.d/00-base.conf:
LoadModule rewrite_module modules/mod_rewrite.so
# Debian / Ubuntu
a2enmod rewrite
systemctl restart apache2
Step 1: Verify Your Current Exposure
Before making changes, confirm the problem exists on your server.
Check the current Apache recording config
# Find the recording alias configuration
grep -r "RECORDINGS\|monitorDONE" /etc/apache2/conf.d/ /etc/httpd/conf.d/ 2>/dev/null
Test from an external machine
# From any machine outside your network:
curl -s -o /dev/null -w "%{http_code}" http://YOUR_SERVER_IP/RECORDINGS/MP3/
# 200 = directory listing is accessible (BAD)
# 403 = directory listing disabled but files may still be accessible
# 404 = path doesn't exist (check your Alias configuration)
If you get a 200 response, your recordings directory is browsable. Even a 403 only means the index page is hidden -- individual files are still downloadable if someone knows the filename.
Check how many recordings are exposed
ls -1 /var/spool/asterisk/monitorDONE/MP3/ | wc -l
du -sh /var/spool/asterisk/monitorDONE/MP3/
On a busy call center, you will likely see tens of thousands of files and tens of gigabytes of data.
Step 2: Create the Recording Player Directory
Create a directory for the player application. On ViciBox/openSUSE, the web root is typically /srv/www/htdocs/. On CentOS/RHEL, it is /var/www/html/.
# Adjust the path for your distribution
WEB_ROOT="/srv/www/htdocs" # ViciBox / openSUSE
# WEB_ROOT="/var/www/html" # CentOS / RHEL / Debian / Ubuntu
mkdir -p "${WEB_ROOT}/recordings-player"
We will create two files in this directory:
| File | Purpose |
|---|---|
play.php |
HTML5 audio player with dark theme, metadata display, and download restrictions |
stream.php |
PHP proxy that validates requests and streams audio with range support |
Step 3: Apache Configuration with mod_rewrite
This is the core security layer. Create or modify the Apache configuration file for recordings.
Backup the existing configuration
# Find and backup the current config
CONFIG_FILE="/etc/apache2/conf.d/vicirecord.conf" # ViciBox
# CONFIG_FILE="/etc/httpd/conf.d/vicirecord.conf" # CentOS/RHEL
cp "$CONFIG_FILE" "${CONFIG_FILE}.bak.$(date +%Y%m%d)"
Write the new configuration
Replace the contents of your recording configuration file:
# ViciDial Recording Access Configuration
# Secured with mod_rewrite - non-whitelisted IPs redirected to HTML5 player
#
# How it works:
# 1. Request comes in for /RECORDINGS/MP3/somefile.mp3
# 2. Apache checks the client IP against the whitelist (RewriteCond)
# 3. If IP is whitelisted: serve the file directly (default behavior)
# 4. If IP is NOT whitelisted: 302 redirect to play.php
# Map /RECORDINGS/ URL to the on-disk recording directory
Alias /RECORDINGS/ "/var/spool/asterisk/monitorDONE/"
# --- Recording Player Application ---
# This directory holds play.php and stream.php
<Directory "/srv/www/htdocs/recordings-player">
Options -Indexes
AllowOverride None
Require all granted
</Directory>
# --- Recording Files Directory ---
<Directory "/var/spool/asterisk/monitorDONE">
Options Indexes MultiViews FollowSymLinks
AllowOverride None
Require all granted
# ================================================
# mod_rewrite: Redirect non-whitelisted IPs to player
# ================================================
RewriteEngine On
# --- IP WHITELIST ---
# These IPs can access MP3 files directly (bypass the player).
# Typically: the server itself, your CRM, monitoring tools,
# admin offices, VPN exit IPs.
#
# IMPORTANT: Escape dots with backslash in RewriteCond patterns.
# Each line is a negative match -- if the IP does NOT match,
# processing continues to the next condition. ALL conditions
# must be true (IP must NOT match ANY whitelist entry) for the
# redirect to fire.
RewriteCond %{REMOTE_ADDR} !^127\.0\.0\.1$
RewriteCond %{REMOTE_ADDR} !^::1$
RewriteCond %{REMOTE_ADDR} !^YOUR_SERVER_IP_ESCAPED$
RewriteCond %{REMOTE_ADDR} !^YOUR_CRM_IP_ESCAPED$
RewriteCond %{REMOTE_ADDR} !^YOUR_MONITORING_IP_ESCAPED$
# Add more IPs as needed:
# RewriteCond %{REMOTE_ADDR} !^10\.0\.0\.100$
# RewriteCond %{REMOTE_ADDR} !^192\.168\.1\.50$
# Redirect MP3 requests to the HTML5 player
# [L] = Last rule (stop processing)
# [R=302] = Temporary redirect (use 301 for permanent)
RewriteRule ^MP3/(.+\.mp3)$ /recordings-player/play.php?file=$1 [L,R=302]
# SECURITY: Disable PHP execution in the recordings directory
# Prevents uploaded/injected PHP files from running
php_admin_value engine Off
</Directory>
Understanding the RewriteCond logic
The RewriteCond directives use a logical AND by default. Each condition says "if the remote address does NOT match this IP." When chained together, ALL conditions must be true for the rewrite rule to execute. In other words:
- If the client IP matches any whitelisted IP, one of the conditions will fail, and the redirect will not happen (file is served directly)
- If the client IP matches none of the whitelisted IPs, all conditions pass, and the redirect will happen
This is an allowlist pattern: you explicitly list IPs that get direct access, and everyone else is redirected.
Replace placeholder IPs
Replace the placeholder values with your actual IPs. Remember to escape dots:
| Placeholder | Replace with | Example |
|---|---|---|
YOUR_SERVER_IP_ESCAPED |
The ViciDial server's own IP | 49\.12\.132\.214 |
YOUR_CRM_IP_ESCAPED |
Your CRM or admin system IP | 10\.0\.0\.50 |
YOUR_MONITORING_IP_ESCAPED |
Monitoring/Grafana server IP | 46\.62\.237\.5 |
Apply the configuration
# Test the Apache configuration for syntax errors
apachectl configtest
# or: httpd -t
# If syntax is OK, reload Apache (graceful = no dropped connections)
apachectl graceful
# or: systemctl reload apache2
# or: systemctl reload httpd
Step 4: PHP Streaming Proxy (stream.php)
The streaming proxy is the bridge between the HTML5 player and the recording files on disk. It validates the requested filename, prevents path traversal attacks, and streams audio with proper HTTP headers for seeking support.
Create stream.php in your recordings-player directory:
<?php
/**
* Recording Stream Proxy
*
* Streams recording files to the HTML5 audio player with:
* - Filename validation (regex whitelist)
* - Path traversal prevention (basename extraction)
* - HTTP Range request support (enables seeking in the player)
* - Inline Content-Disposition (play, don't download)
* - Cache headers (reduce server load on replays)
*
* Usage: stream.php?file=20260203083923_442039962952_447545815324-all.mp3
*/
// --- Configuration ---
$basePath = '/var/spool/asterisk/monitorDONE/MP3/';
// --- Input Validation ---
// basename() strips any directory traversal (../ or absolute paths)
$file = basename($_GET['file'] ?? '');
// Validate against ViciDial's recording filename pattern:
// Starts with digits, contains word chars/hyphens/dots, ends with -all.mp3 or -all.wav
if (empty($file) || !preg_match('/^\d[\w\-+*., ]*-all\.(mp3|wav)$/i', $file)) {
http_response_code(400);
exit('Invalid file format');
}
$path = $basePath . $file;
if (!file_exists($path)) {
http_response_code(404);
exit('Recording not found');
}
$size = filesize($path);
// --- HTTP Range Request Support ---
// Browsers use Range requests to enable seeking in audio/video.
// Without this, the user cannot skip ahead in the recording.
$start = 0;
$end = $size - 1;
if (isset($_SERVER['HTTP_RANGE'])) {
$range = $_SERVER['HTTP_RANGE'];
if (preg_match('/bytes=(\d+)-(\d*)/', $range, $matches)) {
$start = intval($matches[1]);
if (!empty($matches[2])) {
$end = intval($matches[2]);
}
}
// 206 Partial Content tells the browser we're serving a range
http_response_code(206);
header("Content-Range: bytes $start-$end/$size");
}
// --- Response Headers ---
header('Content-Type: audio/mpeg');
// "inline" tells the browser to play, not download
header('Content-Disposition: inline; filename="' . $file . '"');
// "bytes" tells the browser that range requests are supported
header('Accept-Ranges: bytes');
header('Content-Length: ' . ($end - $start + 1));
// Cache for 1 hour -- reduces server load when replaying
header('Cache-Control: public, max-age=3600');
// --- Stream the File ---
$fp = fopen($path, 'rb');
fseek($fp, $start);
$remaining = $end - $start + 1;
$bufferSize = 8192; // 8 KB chunks
while ($remaining > 0 && !feof($fp)) {
$readSize = min($bufferSize, $remaining);
echo fread($fp, $readSize);
$remaining -= $readSize;
flush();
}
fclose($fp);
Key design decisions
Why basename() instead of just regex? Defense in depth. The regex validates the filename pattern, but basename() is the nuclear option for path traversal. Even if someone finds a regex bypass, basename() will strip any ../ sequences. Both checks together make it extremely difficult to read files outside the intended directory.
Why support Range requests? Without HTTP_RANGE support, the browser cannot seek in the audio. The user would have to listen from the beginning every time. Range requests allow the browser to request specific byte ranges, enabling the seek bar in the HTML5 <audio> element to work properly.
Why Content-Disposition: inline? The inline disposition tells the browser to play the file in-page rather than triggering a download dialog. Combined with controlsList="nodownload" on the <audio> element, this makes casual downloading inconvenient.
Why buffer in 8 KB chunks? Reading the entire file into memory with readfile() works but uses memory proportional to the file size. For a 50 MB WAV recording, that is 50 MB of PHP memory per concurrent listener. Chunked streaming keeps memory usage constant at 8 KB regardless of file size.
Step 5: HTML5 Player with Dark Theme (play.php)
The player is a single PHP file that renders an HTML5 audio element with a dark-themed UI. It parses the ViciDial recording filename to extract and display call metadata.
Create play.php in your recordings-player directory:
<?php
/**
* Recording Player - HTML5 Audio Player with Dark Theme
*
* Features:
* - Dark gradient background with glassmorphism card
* - Parses ViciDial filename to show date, time, caller ID
* - controlsList="nodownload" hides the browser download button
* - IP-based download gate for authorized users
* - Right-click prevention on audio element
* - Streams via stream.php (never exposes direct file path)
*
* Usage: play.php?file=20260203083923_442039962952_447545815324-all.mp3
*/
// --- Configuration ---
$basePath = '/var/spool/asterisk/monitorDONE/MP3/';
// IPs allowed to download recordings (add your admin/CRM IPs)
$downloadAllowedIps = [
'127.0.0.1',
'::1',
// Add your server and admin IPs below:
// 'YOUR_SERVER_IP',
// 'YOUR_CRM_IP',
// 'YOUR_ADMIN_IP',
];
// --- Input Validation ---
$file = basename($_GET['file'] ?? '');
$path = $basePath . $file;
$clientIp = $_SERVER['REMOTE_ADDR'];
$canDownload = in_array($clientIp, $downloadAllowedIps);
// Validate filename format
if (empty($file) || !preg_match('/^\d[\w\-+*., ]*-all\.(mp3|wav)$/i', $file)) {
http_response_code(400);
echo "Invalid recording file";
exit;
}
if (!file_exists($path)) {
http_response_code(404);
echo "Recording not found";
exit;
}
// --- Handle Download Requests (authorized IPs only) ---
if (isset($_GET['download'])) {
if ($canDownload) {
header('Content-Type: audio/mpeg');
header('Content-Disposition: attachment; filename="' . $file . '"');
header('Content-Length: ' . filesize($path));
readfile($path);
exit;
} else {
http_response_code(403);
echo "Download not permitted from your IP address";
exit;
}
}
// --- Parse Filename for Display ---
// ViciDial format: YYYYMMDDHHMMSS_DID_CallerID-all.mp3
$parts = [];
if (preg_match('/^(\d{4})(\d{2})(\d{2})-?(\d{2})(\d{2})(\d{2})_(.+)-all\.(mp3|wav)$/i', $file, $m)) {
$parts = [
'date' => "{$m[3]}/{$m[2]}/{$m[1]}",
'time' => "{$m[4]}:{$m[5]}:{$m[6]}",
'caller' => $m[7],
];
}
$fileSize = filesize($path);
$fileSizeFormatted = number_format($fileSize / 1024, 1) . ' KB';
if ($fileSize > 1048576) {
$fileSizeFormatted = number_format($fileSize / 1048576, 1) . ' MB';
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recording Player</title>
<style>
/* ============================================
Dark Theme Styles
============================================ */
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
margin: 0;
padding: 20px;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
/* Glassmorphism card */
.player-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 16px;
padding: 30px;
max-width: 500px;
width: 100%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Header with animated pulse indicator */
h2 {
margin: 0 0 20px 0;
font-size: 18px;
color: #4fc3f7;
display: flex;
align-items: center;
gap: 10px;
}
h2::before {
content: '';
display: inline-block;
width: 8px;
height: 8px;
background: #4fc3f7;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Audio element */
audio {
width: 100%;
margin: 20px 0;
border-radius: 8px;
}
/* Metadata grid */
.info {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px 15px;
font-size: 13px;
color: #aaa;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.info-label {
color: #888;
}
.info-value {
color: #ddd;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
}
/* Download button (shown only for authorized IPs) */
.download-btn {
display: inline-block;
margin-top: 20px;
padding: 10px 20px;
background: #4fc3f7;
color: #1a1a2e;
text-decoration: none;
border-radius: 8px;
font-weight: 500;
transition: background 0.2s;
}
.download-btn:hover {
background: #81d4fa;
}
/* Restriction notice (shown for non-authorized IPs) */
.no-download {
margin-top: 20px;
padding: 10px;
background: rgba(255, 152, 0, 0.1);
border: 1px solid rgba(255, 152, 0, 0.3);
border-radius: 8px;
font-size: 12px;
color: #ffb74d;
}
/* Hide download button in Webkit audio controls (backup) */
audio::-webkit-media-controls-enclosure {
overflow: hidden;
}
audio::-webkit-media-controls-panel {
width: calc(100% + 30px);
}
</style>
</head>
<body>
<div class="player-container">
<h2>Call Recording</h2>
<!-- Audio element with download prevention -->
<audio controls autoplay controlsList="nodownload noplaybackrate">
<source src="stream.php?file=<?= urlencode($file) ?>" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
<!-- Call metadata parsed from filename -->
<?php if (!empty($parts)): ?>
<div class="info">
<span class="info-label">Date:</span>
<span class="info-value"><?= htmlspecialchars($parts['date']) ?></span>
<span class="info-label">Time:</span>
<span class="info-value"><?= htmlspecialchars($parts['time']) ?></span>
<span class="info-label">Caller:</span>
<span class="info-value"><?= htmlspecialchars($parts['caller']) ?></span>
<span class="info-label">Size:</span>
<span class="info-value"><?= $fileSizeFormatted ?></span>
</div>
<?php endif; ?>
<!-- Download button: IP-gated -->
<?php if ($canDownload): ?>
<a href="?file=<?= urlencode($file) ?>&download=1" class="download-btn">
Download MP3
</a>
<?php else: ?>
<div class="no-download">
Download is restricted. Contact administrator for access.
</div>
<?php endif; ?>
</div>
<script>
// Prevent right-click context menu on audio element
// This stops the "Save audio as..." option
document.querySelector('audio').addEventListener('contextmenu', function(e) {
e.preventDefault();
return false;
});
</script>
</body>
</html>
CSS design notes
- Glassmorphism: The frosted-glass card effect uses
backdrop-filter: blur(10px)with a semi-transparent background. This creates depth without looking dated. - Gradient background: The
linear-gradient(135deg, #1a1a2e, #16213e)creates a deep navy background that is easy on the eyes for extended listening sessions. - Pulse indicator: The animated dot next to "Call Recording" provides visual feedback that the player is active and not a static error page.
- Monospace metadata: Caller IDs and timestamps are displayed in a monospace font for readability, since they are numeric data.
Step 6: Enhanced Player with Waveform Visualization
For a more advanced player with a visual waveform, you can extend play.php with the Web Audio API. This adds a real-time waveform display that shows the audio shape as it plays.
Add this code inside the .player-container div, between the <audio> element and the metadata section:
<!-- Waveform Canvas -->
<canvas id="waveform" width="440" height="80"
style="width:100%; height:80px; border-radius:8px;
background: rgba(0,0,0,0.3); margin: 10px 0;"></canvas>
Then replace the existing <script> block at the bottom with this enhanced version:
<script>
// Prevent right-click on audio element
document.querySelector('audio').addEventListener('contextmenu', function(e) {
e.preventDefault();
return false;
});
// ============================================
// Waveform Visualization (Web Audio API)
// ============================================
(function() {
const audio = document.querySelector('audio');
const canvas = document.getElementById('waveform');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let audioCtx, analyser, source, connected = false;
// Initialize audio context on first user interaction
// (browsers require user gesture to create AudioContext)
function initAudio() {
if (connected) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source = audioCtx.createMediaElementSource(audio);
source.connect(analyser);
analyser.connect(audioCtx.destination);
connected = true;
drawWaveform();
}
function drawWaveform() {
if (!connected) return;
requestAnimationFrame(drawWaveform);
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
analyser.getByteFrequencyData(dataArray);
// Clear canvas
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw bars
const barWidth = (canvas.width / bufferLength) * 2.5;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * canvas.height;
// Gradient from cyan to blue based on bar height
const ratio = barHeight / canvas.height;
const r = Math.floor(79 * (1 - ratio));
const g = Math.floor(195 * ratio + 100 * (1 - ratio));
const b = Math.floor(247 * ratio + 200 * (1 - ratio));
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight);
x += barWidth;
}
}
// Connect audio context on play
audio.addEventListener('play', initAudio);
// Draw idle state
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#4fc3f7';
ctx.font = '12px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Press play to see waveform', canvas.width / 2, canvas.height / 2 + 4);
})();
</script>
How the waveform works
- Web Audio API: When the user presses play, a
MediaElementSourceis created from the<audio>element and connected to anAnalyserNode - Frequency data: The analyser provides frequency bin data as a
Uint8Arrayof 128 values (half offftSize = 256) - Canvas rendering: Each animation frame, the frequency data is drawn as vertical bars on the canvas, with colors transitioning from a muted blue-grey (low amplitude) to bright cyan (high amplitude)
- Idle state: Before playback starts, the canvas shows a "Press play to see waveform" message
Browser compatibility note
The Web Audio API requires a user gesture (click/tap) before creating an AudioContext. This is a browser security policy to prevent pages from auto-playing audio. The code handles this by initializing the audio context on the first play event of the audio element. If the autoplay attribute triggers playback before a user gesture, the waveform will not appear until the user manually pauses and plays again.
Step 7: Testing and Verification
Test the Apache redirect
# From a non-whitelisted IP (or use curl to simulate):
curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" \
http://YOUR_SERVER_IP/RECORDINGS/MP3/test-all.mp3
# Expected: 302 -> http://YOUR_SERVER_IP/recordings-player/play.php?file=test-all.mp3
Test from a whitelisted IP
# From the server itself (127.0.0.1 is whitelisted):
curl -s -o /dev/null -w "%{http_code}\n" \
http://127.0.0.1/RECORDINGS/MP3/test-all.mp3
# Expected: 200 (or 404 if the file doesn't exist -- that's fine, it means no redirect)
Test the stream proxy
# Test valid filename
curl -s -o /dev/null -w "%{http_code}\n" \
"http://YOUR_SERVER_IP/recordings-player/stream.php?file=test-all.mp3"
# Expected: 404 (file doesn't exist, but the request was accepted)
# Test path traversal attempt
curl -s -o /dev/null -w "%{http_code}\n" \
"http://YOUR_SERVER_IP/recordings-player/stream.php?file=../../../etc/passwd"
# Expected: 400 (invalid file format)
# Test invalid file extension
curl -s -o /dev/null -w "%{http_code}\n" \
"http://YOUR_SERVER_IP/recordings-player/stream.php?file=test.php"
# Expected: 400 (invalid file format)
Test the player
Open a browser and navigate to:
http://YOUR_SERVER_IP/recordings-player/play.php?file=SOME_REAL_RECORDING.mp3
Replace SOME_REAL_RECORDING.mp3 with an actual filename from your /var/spool/asterisk/monitorDONE/MP3/ directory. You should see the dark-themed player with the recording metadata and a working audio player.
Test range requests (seeking)
# Request bytes 1000-2000 of a recording
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Range: bytes=1000-2000" \
"http://YOUR_SERVER_IP/recordings-player/stream.php?file=SOME_REAL_RECORDING.mp3"
# Expected: 206 (Partial Content)
Step 8: Integration with ViciDial
ViciDial uses the VARHTTP_path setting in /etc/astguiclient.conf to construct recording URLs. By default, it looks like:
VARHTTP_path => http://YOUR_SERVER_IP/RECORDINGS/MP3/
You do not need to change this setting. The mod_rewrite rules intercept requests at the Apache level, so ViciDial's agent interface, admin panels, and CRM links all continue to work. The behavior simply changes based on who is accessing the recording:
- Whitelisted IPs (the server itself, your CRM backend): Direct file access, no change in behavior
- Non-whitelisted IPs (agents, supervisors, external CRM users): Redirected to the HTML5 player
CRM recording links
If your CRM constructs recording URLs and passes them to agents or customers, the redirect is transparent. The user clicks the original URL, gets redirected to the player, and hears the recording. No CRM changes required.
If you want the player for everyone
If you want all users (including whitelisted IPs) to use the player, change VARHTTP_path to point to the player:
VARHTTP_path => http://YOUR_SERVER_IP/recordings-player/play.php?file=
This bypasses the Apache redirect entirely and sends everyone to the player directly. The player still handles download authorization via the IP whitelist in play.php.
Security Considerations
What this solution does protect against
- Casual browsing: Directory listings are intercepted; non-whitelisted users cannot browse the file list
- Direct URL downloads: Non-whitelisted users are redirected to the player, which streams audio inline
- Path traversal:
basename()and regex validation instream.phpprevent reading files outside the recordings directory - PHP injection:
php_admin_value engine Offin the recording directory prevents execution of any PHP files that might be uploaded or injected - Bulk scraping: The redirect adds a layer of friction that breaks simple
wgetorcurldownload scripts
What this solution does NOT protect against
- Determined users: A user can still save a playing stream using browser developer tools, browser extensions, or screen recording software. No client-side protection is unbreakable.
- URL enumeration: If someone knows the filename pattern (
YYYYMMDDHHMMSS_DID_CallerID-all.mp3), they can construct valid URLs forplay.phpand listen to recordings. To prevent this, add authentication (see below). - Network interception: Without HTTPS, recordings stream in cleartext. Anyone on the network path can capture the audio.
- Server compromise: If an attacker gains shell access, all recordings are readable regardless of Apache rules.
Recommended additional hardening
1. Add HTTPS (strongly recommended)
# If you have a domain name, use Let's Encrypt:
certbot --apache -d recordings.yourdomain.com
# If using IP-only access, create a self-signed certificate:
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/ssl/private/recordings.key \
-out /etc/ssl/certs/recordings.crt
2. Add session-based authentication
Replace the IP whitelist in play.php with ViciDial's own authentication:
// At the top of play.php, before any output:
session_start();
// Check if user is authenticated via ViciDial
if (!isset($_SESSION['vicidial_user'])) {
// Redirect to login page or return 401
http_response_code(401);
echo "Authentication required";
exit;
}
3. Add access logging
Add logging to stream.php to track who accesses which recording:
// After the file validation, before streaming:
$logLine = sprintf(
"[%s] IP=%s file=%s range=%s\n",
date('Y-m-d H:i:s'),
$_SERVER['REMOTE_ADDR'],
$file,
$_SERVER['HTTP_RANGE'] ?? 'full'
);
file_put_contents('/var/log/recording-access.log', $logLine, FILE_APPEND | LOCK_EX);
4. Rate limiting
Use Apache's mod_ratelimit or mod_evasive to prevent bulk access:
<Directory "/srv/www/htdocs/recordings-player">
# Limit bandwidth to 500 KB/s per connection
SetOutputFilter RATE_LIMIT
SetEnv rate-limit 500
</Directory>
5. Restrict the archive directory separately
If you also expose an archive directory (e.g., /archive/), apply the same protections:
<Directory "/home/archive">
Options Indexes MultiViews
AllowOverride None
Require all granted
php_admin_value engine Off
RewriteEngine On
RewriteCond %{REMOTE_ADDR} !^127\.0\.0\.1$
RewriteCond %{REMOTE_ADDR} !^YOUR_SERVER_IP_ESCAPED$
RewriteRule ^.*\.mp3$ /recordings-player/play.php?file=$1 [L,R=302]
</Directory>
6. Disable directory indexing entirely
If you do not need the directory listing even for whitelisted IPs:
<Directory "/var/spool/asterisk/monitorDONE">
Options -Indexes MultiViews FollowSymLinks
# ... rest of config
</Directory>
Change Options Indexes to Options -Indexes. Whitelisted IPs can still access files by direct URL, but nobody can browse the directory listing.
Troubleshooting
Redirect is not working (file downloads directly for all IPs)
# Check that mod_rewrite is loaded
apachectl -M | grep rewrite
# Check Apache error log for rewrite issues
tail -20 /var/log/apache2/error_log
# or: tail -20 /var/log/httpd/error_log
# Enable rewrite logging (Apache 2.4+)
# Add to your VirtualHost or server config:
LogLevel alert rewrite:trace3
# Then reload and check error_log for rewrite decisions
Player shows "Invalid recording file"
The filename does not match the expected regex pattern. Check:
# List some real filenames to see the pattern
ls /var/spool/asterisk/monitorDONE/MP3/ | head -5
If your filenames differ from the expected pattern (YYYYMMDDHHMMSS_...-all.mp3), update the regex in both stream.php and play.php.
Audio does not play (no sound, player spinner)
# Check that stream.php can read the file
sudo -u wwwrun test -r /var/spool/asterisk/monitorDONE/MP3/FILENAME.mp3 && echo "readable" || echo "NOT readable"
# Check PHP error log
tail -20 /var/log/apache2/php_error_log
The web server user (wwwrun on openSUSE, apache on CentOS, www-data on Debian) must have read access to the recording files.
Seeking does not work (cannot skip ahead)
This usually means Range requests are not working. Test:
curl -v -H "Range: bytes=0-100" \
"http://YOUR_SERVER_IP/recordings-player/stream.php?file=FILENAME.mp3" 2>&1 | head -20
# Look for: HTTP/1.1 206 Partial Content
# And: Content-Range: bytes 0-100/FILESIZE
If you see 200 instead of 206, the Range handling code is not executing. Check for PHP errors.
Waveform not showing
- The Web Audio API requires the page to be served over HTTPS in some browsers (Chrome enforces this for
AudioContexton non-localhost origins) - The user must interact with the page (click/tap) before the audio context can be created
- Check the browser console (F12) for errors like
The AudioContext was not allowed to start
What's Next
This tutorial gives you a solid foundation for recording access control. Here are some directions to extend it:
- Token-based access: Generate time-limited, single-use tokens for recording URLs instead of relying on IP whitelists. This is more secure for CRM integrations where the end user's IP is unpredictable.
- ViciDial session authentication: Integrate with ViciDial's existing session management so only logged-in agents can access recordings, scoped to their campaign or user group.
- Elasticsearch indexing: Index recording metadata (filename, size, duration) into Elasticsearch and build a search interface on top of the player.
- Automated transcription: Pipe recordings through a speech-to-text service (Whisper, Google STT) and display transcripts alongside the audio player.
- Retention enforcement: Add a cron job that deletes recordings older than N days from the MP3 directory and logs the deletion for compliance.
File Reference
| File | Location | Purpose |
|---|---|---|
vicirecord.conf |
/etc/apache2/conf.d/ or /etc/httpd/conf.d/ |
Apache config with mod_rewrite rules |
play.php |
{WEB_ROOT}/recordings-player/ |
HTML5 player with dark theme |
stream.php |
{WEB_ROOT}/recordings-player/ |
PHP streaming proxy with range support |
| Recording files | /var/spool/asterisk/monitorDONE/MP3/ |
ViciDial MP3 recordings |
astguiclient.conf |
/etc/astguiclient.conf |
ViciDial config (VARHTTP_path) |
This tutorial is based on a production deployment securing 100,000+ call recordings across a multi-server ViciDial call center. The code has been sanitized and annotated for educational use.