← All Tutorials

Recording Security & HTML5 Player for ViciDial

ViciDial Administration Intermediate 26 min read #21

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

  1. Introduction
  2. The Problem: ViciDial Recordings Are Wide Open
  3. Architecture Overview
  4. Prerequisites
  5. Step 1: Verify Your Current Exposure
  6. Step 2: Create the Recording Player Directory
  7. Step 3: Apache Configuration with mod_rewrite
  8. Step 4: PHP Streaming Proxy (stream.php)
  9. Step 5: HTML5 Player with Dark Theme (play.php)
  10. Step 6: Enhanced Player with Waveform Visualization
  11. Step 7: Testing and Verification
  12. Step 8: Integration with ViciDial
  13. Security Considerations
  14. Troubleshooting
  15. 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?


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:

  1. Browsable index: Visit http://YOUR_SERVER_IP/RECORDINGS/MP3/ and you get a full directory listing of every MP3 recording
  2. Direct download: Any file is downloadable by URL -- http://YOUR_SERVER_IP/RECORDINGS/MP3/20260313120000_441234567890_449876543210-all.mp3
  3. No authentication: Require all granted means the entire internet has access
  4. 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:

  1. Apache mod_rewrite -- Intercepts direct file requests at the web server level and redirects non-whitelisted IPs to the player
  2. PHP streaming proxy -- Validates filenames, prevents path traversal, and streams audio with Content-Disposition: inline (play, not download)
  3. 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:

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:

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


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

  1. Web Audio API: When the user presses play, a MediaElementSource is created from the <audio> element and connected to an AnalyserNode
  2. Frequency data: The analyser provides frequency bin data as a Uint8Array of 128 values (half of fftSize = 256)
  3. 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)
  4. 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:

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

What this solution does NOT protect against

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


What's Next

This tutorial gives you a solid foundation for recording access control. Here are some directions to extend it:


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.

Need expert help with your setup?

VoIP infrastructure consulting, AI voice agent integration, monitoring stacks, scaling — I've done it all in production.

Get a Free Consultation