深度解构:Typecho 与 WebDAV 融合的流媒体分发实践

在 Web 开发中,直接将 WebDAV 作为视频流后端是一项具有挑战性的任务。它涉及非标准的 HTTP 方法、XML 解析瓶颈以及浏览器沙箱对身份验证的严格限制。本文将深度拆解这一套 Typecho 模板源码。

一、 通信层:基于 PROPFIND 的目录元数据检索

WebDAV 的核心在于对 HTTP 协议的扩展。代码中 webdav_list 函数本质上是一个 元数据抓取引擎

1. 深度控制 (Depth Header)

代码显式设置了 Depth: 1。在 WebDAV 标准中:

  • Depth 0: 仅获取当前目录属性。
  • Depth 1: 获取当前目录及其直接子项(本代码的选择)。
  • Depth infinity: 递归获取所有子孙项(极其耗费性能,通常被生产环境禁用)。

2. 状态码 207 Multi-Status

代码中测试连接逻辑判断 httpCode == 207。这是 WebDAV 的特有响应码,表示返回的 XML 实体内部包含多个资源的状态。这种“批处理”式的响应要求后端必须具备健壮的 XML 容错处理(如代码中使用的 libxml_use_internal_errors)。


二、 解析层:XPath 驱动的 XML 数据抽取

WebDAV 返回的 XML 结构极为冗余且带有复杂的命名空间(Namespace)。

1. 命名空间冲突

XML 默认带有的 xmlns:D="DAV:" 导致普通的 DOM 解析器无法通过简单标签名定位。代码通过:

PHP

$xml->registerXPathNamespace('D', 'DAV:');
$responses = $xml->xpath('//D:response');

这一步是性能关键点。使用 XPath 比起循环遍历 DOM 树快了一个量级,尤其是在处理包含数百个视频文件的目录时。

2. 资源类型判定逻辑

代码通过判断 D:resourcetype/D:collection 是否存在来区分目录。这比通过 URL 末尾是否有斜杠(/)判断要可靠得多,因为后者依赖于服务器的具体实现,而前者是协议规范。


三、 传输层:视频流的身份验证与跨端分发

这是本代码最具“实战价值”也是最具争议的部分。

1. Inline Authentication (嵌入式认证)

浏览器 <video> 标签不支持在请求头中动态注入 Authorization: Basic ...。代码采用了 https://user:pass@host 这种旧式的 RFC 1738 规范:

  • 优势:绕过了浏览器 JS 无法干预 <video> 异步请求头的限制。
  • 缺陷

    • 安全风险:账号密码会暴露在浏览器控制台和网络请求日志中。
    • 兼容性:Chrome 等现代浏览器出于安全考虑,已逐渐限制在主地址栏使用此类 URL,但在子资源(如视频源)加载中目前仍具有一定的兼容性。

2. 移动端中转策略 (Alist Proxy)

在移动端具有严格的安全验证,无法使用第一种方式,因此,采用了AList作为代理进行转发:

JavaScript

function genMobielLink(inputPath) {
    return "http://154.37.215.226:15244/d/" + inputPath;
}

移动端(iOS/Android)上,直接使用 WebDAV 认证 URL 可能会触发浏览器的认证弹窗或加载失败。因此,代码引入了一个 不带认证的直链代理。


四、 性能与安全性复盘

1. 内存与分页的考量

代码在 PHP 端一次性获取了 $all_files,然后利用 array_slice 分页。

  • 深度分析:这种属于“假分页”。如果 WebDAV 目录下有 5000 个文件,PHP 依然需要解析这 5000 个节点的 XML,这会导致 PHP 内存溢出(Memory Limit)。
  • 优化建议:对于超大目录,应在 PROPFIND 请求中配合 Request-Range 或在服务端实现属性筛选。

2. XSS 与注入防御

代码大量使用了 urldecode(htmlspecialchars($file['name']))。这是一个良好的习惯,防止了文件名中可能包含恶意脚本导致的 XSS 攻击。


五、 总结:这套方案的适用场景

这套代码是一套 轻量级的个人视频中心解决方案。它避开了复杂的后端转码流程,直接利用浏览器的原生播放能力。

  • 适用场景:个人 NAS 视频预览、小范围私密分享、技术博客附件展示。
  • 不适用场景:高并发公共视频站、对版权及安全性要求极高的企业级项目。

源码如下:

<?php
/**
 * WebDAV 视频在线播放
 * @package custom
 */
if (!defined('__TYPECHO_ROOT_DIR__')) exit;
$this->need('header.php');

// 获取主题配置的 WebDAV 信息
$options = Typecho_Widget::widget('Widget_Options');
$webdav_url = rtrim($options->webdav_url, '/').'/';
$webdav_user = $options->webdav_user;
$webdav_pass = $options->webdav_pass;

// 允许通过GET参数切换目录
$dir = isset($_GET['dir']) ? trim($_GET['dir'], '/') : '';
$scan_url = $webdav_url . ($dir ? $dir . '/' : '');

// 分页相关参数
$page_size = 20; // 每页显示数量
$current_page = isset($_GET['page']) ? intval($_GET['page']) : 1;
$offset = ($current_page - 1) * $page_size;

// 只显示常见视频格式
$video_exts = array('mp4','webm','ogg','mkv','mov','m4v','flv','ts','m3u8');

function webdav_list($url, $user, $pass) {
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PROPFIND');
    curl_setopt($ch, CURLOPT_HTTPHEADER, array('Depth: 1'));
    if ($user && $pass) {
        curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $pass);
    }
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    // 添加调试信息
    if (!$response) {
        error_log("WebDAV: No response received for URL: {$url}");
        return array('error' => 'No response from server', 'files' => array(), 'raw_response' => '');
    }
    
    // 先保存响应,即使解析失败也要返回
    $raw_response = $response;
    
    // 清理可能的问题字符
    $cleanResponse = trim($response);
    
    // 启用libxml错误处理
    libxml_clear_errors(); // 清除之前的错误
    libxml_use_internal_errors(true);
    
    // 尝试解析XML
    $xml = simplexml_load_string($cleanResponse);
    
    if ($xml === false) {
        // 获取并记录错误
        $errors = libxml_get_errors();
        $errorMessages = [];
        foreach ($errors as $error) {
            $errorMessages[] = "Line {$error->line}: {$error->message}";
        }
        error_log("WebDAV: XML parsing failed for URL: {$url}, Errors: " . implode(", ", $errorMessages) . ", First 500 chars of response: " . substr($cleanResponse, 0, 500));
        
        return array('error' => 'Failed to parse response XML: ' . implode(", ", $errorMessages), 'files' => array(), 'http_code' => $httpCode, 'raw_response' => $raw_response);
    }
    // 注册命名空间
    $xml->registerXPathNamespace('D', 'DAV:');
    
    $files = array();
    
    // 使用XPath查询D:response元素
    $responses = $xml->xpath('//D:response');
    
    if ($responses === false || empty($responses)) {
        error_log("WebDAV: XPath query for D:response failed, Raw response preview: " . substr($raw_response, 0, 500));
        return array('error' => 'No response elements found in XML', 'files' => array(), 'http_code' => $httpCode, 'raw_response' => $raw_response);
    }
    
    foreach ($responses as $response_item) {
        //跳过第一个Response,
        //因为它是当前目录本身的信息   
        // 获取href
        if ($response_item === $responses[0]) continue;
        $href_nodes = $response_item->xpath('.//D:href');
        if (count($href_nodes) > 0) {
            $href = (string)$href_nodes[0];
            $name = basename(trim($href, '/'));
            
            // 检查是否是当前根目录或特殊条目
            if ($name === '' || $name === '.' || $name === '..') continue;
            
            // 跳过父级目录引用
            if ($href === $url || $href === dirname($url) . '/') continue;
            
            $isdir = false;
            // 检查是否为目录:查看resourcetype下是否有collection元素
            $collection_nodes = $response_item->xpath('.//D:propstat/D:prop/D:resourcetype/D:collection');
            if (count($collection_nodes) > 0) {
                $isdir = true;
            }
            
            $files[] = array('name'=>$name, 'href'=>$href, 'isdir'=>$isdir);
        }
    }
    
    return array('error' => null, 'files' => $files, 'http_code' => $httpCode, 'raw_response' => $response);
}

// 测试连接是否成功
function test_webdav_connection($url, $user, $pass) {
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PROPFIND');
    curl_setopt($ch, CURLOPT_HTTPHEADER, array('Depth: 0'));
    if ($user && $pass) {
        curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $pass);
    }
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    return array(
        'success' => $httpCode == 207,
        'http_code' => $httpCode,
        'response' => $response
    );
}

// 创建直接URL函数
function create_direct_url($original_url) {
    // 返回原始URL
    return $original_url;
}

//解码UTF-8函数
function utf8_urldecode($str) {
    $decoded_str = urldecode($str);
    if (function_exists('mb_convert_encoding')) {
        $decoded_str = mb_convert_encoding($decoded_str, 'UTF-8', 'auto');
    }
    return $decoded_str;
}

// 构建待认证信息的url的函数
function build_authenticated_url($url) {
    //去除url头部的/webdav,如果有的话
    $options = Typecho_Widget::widget('Widget_Options');
    $webdav_url = rtrim($options->webdav_url, '/').'/';
    $webdav_user = $options->webdav_user;
    $webdav_pass = $options->webdav_pass;
    $url = preg_replace('/^\/webdav\//', '', $url);
    
    // 如果url不是完整URL,则与webdav基本URL拼接
    if (!preg_match('/^https?:\/\//', $url)) {
        $url = $webdav_url . (substr($url, 0, 1) === '/' ? substr($url, 1) : $url);
    }
    
    // 构建带认证信息的URL
    $url = preg_replace('/^https?:\/\//', 'https://' . $webdav_user . ':' . $webdav_pass . '@', $url);
    // var authenticatedUrl = url.replace(/^https?:\/\//, 'https://' + encodeURIComponent(webdavInfo.user) + ':' + encodeURIComponent(webdavInfo.pass) + '@');
    return $url;
}

$connection_test = test_webdav_connection($webdav_url, $webdav_user, $webdav_pass);
$connection_success = $connection_test['success'];

$result = webdav_list($scan_url, $webdav_user, $webdav_pass);
$all_files = $result['files'];
$error = $result['error'];
$http_code = $result['http_code'];
$raw_response = $result['raw_response'];

// 分页处理
$total_files = count($all_files);
$total_pages = ceil($total_files / $page_size);
$paged_files = array_slice($all_files, $offset, $page_size);
?>

<div class="wrap wrap-center" style="max-width:800px;margin:40px auto;">
    <h2>WebDAV 视频在线播放</h2>
    
    <!-- 连接状态提示 -->
    <div style="margin-bottom:15px;padding:10px;border-radius:4px;<?php echo $connection_success ? 'background:#d4edda;color:#155724;' : 'background:#f8d7da;color:#721c24;'; ?>">
        <strong>连接状态:</strong> 
        <?php 
        if($connection_success) {
            echo '✅ 连接成功';
        } else {
            echo '❌ 连接失败 (HTTP '.$connection_test['http_code'].'),请检查 WebDAV 设置';
        }
        ?>
    </div>
    
    <?php if($error !== null): ?>
    <div style="margin-bottom:15px;padding:10px;background:#f8d7da;color:#721c24;border-radius:4px;">
        <strong>获取文件列表失败:</strong> <?php echo $error; ?> (HTTP <?php echo $http_code; ?>)
        
        <!-- 即使解析失败也显示原始响应 -->
        <details style="margin-top:10px;">
            <summary>查看原始响应 (用于调试)</summary>
            <pre style="white-space: pre-wrap; max-height: 300px; overflow-y: scroll;"><?php echo htmlspecialchars($raw_response); ?></pre>
        </details>
    </div>
    <?php endif; ?>
    
    <?php if($error === null && count($all_files) === 0): ?>
    <div style="margin-bottom:15px;padding:10px;background:#fff3cd;color:#856404;border-radius:4px;">
        <strong>提示:</strong> 目录为空或未找到匹配的视频文件
        
        <details style="margin-top:10px;">
            <summary>查看原始响应 (用于调试)</summary>
            <pre style="white-space: pre-wrap; max-height: 300px; overflow-y: scroll;"><?php echo htmlspecialchars($raw_response); ?></pre>
        </details>
    </div>
    <?php endif; ?>
    
    <!-- <?php if($error === null && count($all_files) > 0): ?>
    <div style="margin-bottom:15px;padding:10px;background:#d1ecf1;color:#0c5460;border-radius:4px;">
        <strong>调试信息:</strong> 找到 <?php echo count($all_files); ?> 个项目 (当前页 <?php echo $current_page; ?>/<?php echo $total_pages; ?>)
        <details style="margin-top:10px;">
            <summary>查看解析结果</summary>
            <pre style="white-space: pre-wrap; max-height: 300px; overflow-y: scroll;">
<?php 
foreach ($all_files as $idx => $file) {
    $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
    $is_video = in_array($ext, array('mp4','webm','ogg','mkv','mov','m4v','flv','ts','m3u8'));
    echo "[$idx] Name: {$file['name']}, IsDir: " . ($file['isdir'] ? 'true' : 'false') . ", Ext: $ext, IsVideo: " . ($is_video ? 'true' : 'false') . "\n";
}
?>
            </pre>
        </details>
    </div>
    <?php endif; ?> -->
    
    <form id="webdav-form" style="margin-bottom:20px;">
        <input type="text" id="webdav-url" placeholder="输入 WebDAV 视频文件直链" style="width:70%;padding:8px;">
        <button type="submit" style="padding:8px 16px;">播放</button>
    </form>

    <div style="margin-bottom:20px;">
        <strong>当前目录:</strong> /<?php echo urldecode(htmlspecialchars($dir)); ?>
        <?php if ($dir): ?>
            <a href="?dir=<?php echo urlencode(dirname($dir)); ?>">[返回上级]</a>
        <?php endif; ?>
        
        <!-- 分页导航 -->
        <?php if ($total_pages > 1): ?>
        <span style="float:right;">
            <?php if ($current_page > 1): ?>
                <a href="?dir=<?php echo urlencode($dir); ?>&page=<?php echo $current_page - 1; ?>">上一页</a>
            <?php endif; ?>
            
            <?php if ($current_page < $total_pages): ?>
                <a href="?dir=<?php echo urlencode($dir); ?>&page=<?php echo $current_page + 1; ?>" style="margin-left:10px;">下一页</a>
            <?php endif; ?>
            
            <span style="margin-left:10px;">第 <?php echo $current_page; ?>/<?php echo $total_pages; ?> 页</span>
        </span>
        <?php endif; ?>
    </div>

    <?php if(count($paged_files) > 0): ?>
    <ul id="webdav-list" style="padding:0;list-style:none;">
        <?php foreach ($paged_files as $file):
            $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
            if ($file['isdir']): ?>
                <li style="margin:8px 0;">
                    
                    <a href="?dir=<?php echo urlencode(ltrim(($dir ? $dir.'/' : '').$file['name'], '/')); ?>">📁<?php //在php中将htmlspecialchars($file['name'])用utf8解码
                        
                        echo urldecode(htmlspecialchars($file['name'])); ?></a>
                </li>
            <?php elseif (in_array($ext, $video_exts)): 
                $directUrl = $file['href'];
            ?>
                <li style="margin:8px 0;">
                    <a href="#" class="webdav-video-link" data-url="<?php echo htmlspecialchars($directUrl); ?>" data-filename="<?php echo htmlspecialchars($file['name']); ?>">🎬 <?php echo urldecode(htmlspecialchars($file['name'])); ?></a>
                </li>
            <?php else: ?>
                <li style="margin:8px 0;">
                    <a href="<?php echo build_authenticated_url(htmlspecialchars($file['href'])); ?>" target="_blank">📄 <?php echo urldecode(htmlspecialchars($file['name'])); ?></a>
                </li>
            <?php endif;
        endforeach; ?>
    </ul>
    
    <!-- 底部分页导航 -->
    <?php if ($total_pages > 1): ?>
    <div style="text-align:center;margin-top:20px;">
        <?php if ($current_page > 1): ?>
            <a href="?dir=<?php echo urlencode($dir); ?>&page=<?php echo $current_page - 1; ?>">« 上一页</a>
        <?php endif; ?>
        
        <span style="margin:0 15px;">第 <?php echo $current_page; ?>/<?php echo $total_pages; ?> 页</span>
        
        <?php if ($current_page < $total_pages): ?>
            <a href="?dir=<?php echo urlencode($dir); ?>&page=<?php echo $current_page + 1; ?>">下一页 »</a>
        <?php endif; ?>
    </div>
    <?php endif; ?>
    
    <?php endif; ?>
    
    <!-- 全屏视频播放遮罩层 -->
    <div id="video-overlay" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background-color:rgba(0,0,0,0.9); z-index:9999; justify-content:center; align-items:center;">
        <div style="position:relative; width:90%; max-width:800px; text-align:center;">
            <button id="close-video-btn" style="position:absolute; top:-35px; right:0; color:white; font-size:24px; cursor:pointer; background:none; border:none; z-index:10000;">✕</button>
            <video id="webdav-player" style="width:100%; max-height:80vh; display:block; object-fit: contain;" controls>
                您的浏览器不支持 video 标签。
            </video>
        </div>
    </div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
<script>
// 将WebDAV信息存储到JavaScript变量中
var webdavInfo = {
    url: '<?php echo addslashes($webdav_url); ?>',
    user: '<?php echo addslashes($webdav_user); ?>',
    pass: '<?php echo addslashes($webdav_pass); ?>'
};



function isMobile() {
    return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}


function genMobielLink(inputPath) {
    // 去除开头的斜杠,防止双斜杠
    if (inputPath.startsWith('/')) {
        inputPath = inputPath.substring(1);
    }
    path = "http://154.37.215.226:15244/d/" + inputPath
    return path;
}
// 现代化的剪贴板复制功能
function copyLink(text, buttonElement) {
    // 创建临时输入框来执行复制(兼容性最好)
    var textarea = document.createElement("textarea");
    textarea.value = text;
    textarea.style.position = "fixed"; // 避免页面滚动
    document.body.appendChild(textarea);
    textarea.focus();
    textarea.select();
    
    try {
        var successful = document.execCommand('copy');
        if(successful) {
            // 复制成功的视觉反馈
            var originalText = buttonElement.innerHTML;
            buttonElement.innerHTML = "✅ 已复制!请切换到 VLC";
            buttonElement.style.background = "#28a745"; // 绿色
            
            // 3秒后恢复按钮原状
            setTimeout(function(){
                buttonElement.innerHTML = originalText;
                buttonElement.style.background = "#007bff"; // 恢复蓝色
            }, 3000);
        } else {
            alert("复制失败,请长按下方链接手动复制");
        }
    } catch (err) {
        alert("复制失败,请长按下方链接手动复制");
    }
    
    document.body.removeChild(textarea);
}




// 播放视频的函数
function playVideo(url, filename) {
    // 在控制台打印请求的WebDAV地址
    console.log('正在请求视频URL:', url);
    path = url;

    //去除url头部的/webdav,如果有的话
    url = url.replace(/^\/webdav\//, '');
    
    // 如果url不是完整URL,则与webdav基本URL拼接
    if (!url.startsWith('http')) {
        url = webdavInfo.url + (url.startsWith('/') ? url.substring(1) : url);
    }
    
    // 构建带认证信息的URL
    var authenticatedUrl = url.replace(/^https?:\/\//, 'https://' + encodeURIComponent(webdavInfo.user) + ':' + encodeURIComponent(webdavInfo.pass) + '@');

   
    var player = document.getElementById('webdav-player');
    var overlay = document.getElementById('video-overlay');
    mobilepath = genMobielLink(path);
    console.log('移动端视频URL:', mobilepath);
    
    // 设置视频源
    if(isMobile()){
        
        player.src = mobilepath;

    } else {
        player.src = authenticatedUrl;
    }

    // 显示遮罩层
    overlay.style.display = 'flex';
    
    // 尝试播放视频
    player.load();
    var playPromise = player.play();
    
    // 处理可能的播放错误
    if (playPromise !== undefined) {
        playPromise.then(function() {
            console.log('视频开始播放: ' + filename);
        }).catch(function(error) {
            console.error('视频播放失败: ' + filename, error);
            alert('视频播放失败: ' + filename + '\n错误信息: ' + error.message + '\n请检查网络连接和文件格式');
        });
    }
}


// 统一关闭函数:只隐藏,不刷新,体验更好
function closeVideo() {
    var player = document.getElementById('webdav-player');
    var overlay = document.getElementById('video-overlay');
    
    if (player) {
        player.pause(); // 停止播放
        player.src = ""; // 清空源
    }
    overlay.style.display = 'none'; // 隐藏遮罩
    // overlay.innerHTML = ""; // 清空内容
}

// 为视频链接添加点击事件
document.querySelectorAll('.webdav-video-link').forEach(function(link){
    link.addEventListener('click', function(e){
        e.preventDefault();
        var url = this.getAttribute('data-url');
        var filename = this.getAttribute('data-filename');
        console.log('点击视频链接:', filename, 'URL:', url);
        playVideo(url, filename);
    });
});

document.getElementById('webdav-form').onsubmit = function(e) {
    e.preventDefault();
    var url = document.getElementById('webdav-url').value.trim();
    if(url) {
        console.log('表单提交视频URL:', url);
        playVideo(url, '自定义视频');
    }
};

// 为关闭按钮添加点击事件
document.getElementById('close-video-btn').addEventListener('click', function() {
    closeVideo();
});

// 点击遮罩层背景也可以关闭视频
document.getElementById('video-overlay').addEventListener('click', function(e) {
    if (e.target === this) {
        closeVideo();
    }
});
</script>

<?php $this->need('footer.php'); ?>

评论区 0