在 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'); ?>