WebDAV Mock2Image 的核心思路,是在 Node.js 里用一套轻量 HTTP 服务模拟一个可访问的图片仓库,把真实 WebDAV 客户端或图片处理链路需要的目录查询、文件读取、二进制响应和部分写入能力,映射到本地目录、内存对象或测试数据上。这样做的价值在于把外部存储依赖从开发流程中剥离出来,让图片上传、缩略图生成、资源预览、权限联调都能在本地稳定复现。

为什么要把WebDAV协议mock成图片服务
WebDAV 本质上是 HTTP 的扩展,它不是单独端口协议,而是要求服务端理解 PROPFIND、OPTIONS、PUT、DELETE、LOCK 等方法。很多前端项目只关心图片能否列出、能否预览、能否上传,真正接入 NAS、云盘或企业网盘时,却会被目录结构、权限、网络、证书、限流等问题拖慢节奏。Mock2Image 就是把这一层抽象出来,让开发者在本地用一个目录模拟远端存储,用固定响应格式模拟真实客户端行为。
对图片资源而言,Mock 服务不能只返回 JSON。浏览器加载图片依赖正确的 Content-Type、Content-Length、Accept-Ranges,有些页面还会通过 Range 请求做分段加载或断点预览。WebDAV 客户端则依赖 PROPFIND 返回的 XML 目录树,其中包含资源类型、显示名、内容长度、内容类型等属性。把这些细节模拟完整,才能让上层业务代码在联调阶段尽量接近真实环境。
另一个常见场景是自动化测试。CI 环境里访问外部存储不稳定,测试账号和目录权限又容易变化。通过 Node.js 启动一个临时 WebDAV Mock,可以让测试用例自行准备图片文件,验证上传、读取、删除、目录遍历等链路。测试结束后进程退出,不留下外部依赖,也不会污染真实存储。
从协议方法开始设计Mock2Image路由
实现 WebDAV Mock 的第一步,不是急着写文件读写,而是把 HTTP 方法拆清楚。OPTIONS 用于预检,返回允许方法和请求头。PROPFIND 用于查询目录或文件属性,通常返回 207 Multi-Status。GET 和 HEAD 用于读取图片二进制流和响应头。PUT 用于上传或覆盖文件。DELETE 用于删除资源。如果业务只需要读取图片,可以先实现 OPTIONS、PROPFIND、GET、HEAD;如果需要模拟上传,再补 PUT。
路由设计要围绕资源路径展开。真实 WebDAV 的 href 往往是绝对路径,客户端会按路径拼接请求。Mock 服务可以把请求路径映射到本地目录下的文件,例如请求 /dav/cover.png 映射到 dav/cover.png。这里最关键的是路径安全,必须防止客户端通过 ../ 跳出根目录。一个稳妥做法是先 normalize,再判断目标路径是否仍然位于根目录内,不满足就返回 404 或 403。
PROPFIND 的响应格式决定了很多客户端能否正常显示目录。根目录请求通常返回 collection 类型,文件请求返回普通资源。Depth 头会影响返回范围,Depth 0 只返回当前资源,Depth 1 返回当前资源及其直接子项。Mock 阶段不必完整实现所有属性,但至少要提供 displayname、resourcetype、getcontenttype、getcontentlength,否则一些客户端会把图片识别为未知文件,或者无法生成缩略图列表。
Node.js实现图片目录与二进制流的关键代码
下面示例用原生 http 模块搭建一个最小可用的 WebDAV Mock2Image 服务。代码只依赖 Node.js 内置模块,适合快速嵌入测试脚本。它把请求路径映射到本地 dav 目录,处理 OPTIONS、PROPFIND、GET、HEAD、PUT、DELETE,并给 PROPFIND 返回简化的 DAV XML。示例没有实现 LOCK、MOVE、COPY,因为这些能力通常只在文件管理场景里才需要。
const http = require('http');
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, 'dav');
fs.mkdirSync(root, { recursive: true });
function safePath(resourcePath) {
const decoded = decodeURIComponent(resourcePath.replace(/^\/+/, ''));
const target = path.normalize(path.join(root, decoded));
const relative = path.relative(root, target);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error('非法路径');
}
return target;
}
function getMime(filePath) {
const ext = path.extname(filePath).toLowerCase();
const map = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp'
};
return map[ext] || 'application/octet-stream';
}
function listPropfind(target, href) {
const stats = fs.statSync(target);
if (stats.isDirectory()) {
const baseHref = href.endsWith('/') ? href : href + '/';
const children = fs.readdirSync(target);
const members = children.map(function (name) {
const childPath = path.join(target, name);
const childStat = fs.statSync(childPath);
return '<dav:response>' +
'<dav:href>' + baseHref + encodeURIComponent(name) + '</dav:href>' +
'<dav:propstat>' +
'<dav:prop>' +
'<dav:displayname>' + name + '</dav:displayname>' +
(childStat.isDirectory() ? '<dav:resourcetype><dav:collection/></dav:resourcetype>' : '') +
'<dav:getcontenttype>' + (childStat.isDirectory() ? 'httpd/unix-directory' : getMime(childPath)) + '</dav:getcontenttype>' +
'</dav:prop>' +
'</dav:propstat>' +
'</dav:response>';
}).join('');
return '<?xml version="1.0" encoding="utf-8"?>' +
'<dav:multistatus xmlns:dav="DAV:">' +
'<dav:response>' +
'<dav:href>' + href + '</dav:href>' +
'<dav:propstat>' +
'<dav:prop>' +
'<dav:displayname>image-dav</dav:displayname>' +
'<dav:resourcetype><dav:collection/></dav:resourcetype>' +
'</dav:prop>' +
'</dav:propstat>' +
'</dav:response>' +
members +
'</dav:multistatus>';
}
return '<?xml version="1.0" encoding="utf-8"?>' +
'<dav:multistatus xmlns:dav="DAV:">' +
'<dav:response>' +
'<dav:href>' + href + '</dav:href>' +
'<dav:propstat>' +
'<dav:prop>' +
'<dav:getcontenttype>' + getMime(target) + '</dav:getcontenttype>' +
'<dav:getcontentlength>' + stats.size + '</dav:getcontentlength>' +
'</dav:prop>' +
'</dav:propstat>' +
'</dav:response>' +
'</dav:multistatus>';
}
const server = http.createServer(function (req, res) {
const url = new URL(req.url, 'http://localhost');
const method = req.method.toUpperCase();
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,OPTIONS,PROPFIND,PUT,DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,Depth');
if (method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
try {
let resourcePath = url.pathname;
if (resourcePath === '/dav') {
resourcePath = '/';
}
if (resourcePath.indexOf('/dav/') === 0) {
resourcePath = resourcePath.slice(5);
}
const target = safePath(resourcePath);
if ((method === 'PROPFIND' || method === 'GET' || method === 'HEAD') && !fs.existsSync(target)) {
res.writeHead(404);
res.end();
return;
}
if (method === 'PROPFIND') {
const depth = req.headers.depth || '1';
const xml = listPropfind(target, url.pathname);
res.writeHead(207, {
'Content-Type': 'application/xml; charset=utf-8',
'Depth': depth
});
res.end(xml);
return;
}
if (method === 'GET' || method === 'HEAD') {
const stats = fs.statSync(target);
if (stats.isDirectory()) {
res.writeHead(404);
res.end();
return;
}
res.writeHead(200, {
'Content-Type': getMime(target),
'Content-Length': stats.size,
'Accept-Ranges': 'bytes'
});
if (method === 'HEAD') {
res.end();
} else {
fs.createReadStream(target).pipe(res);
}
return;
}
if (method === 'PUT') {
const dir = path.dirname(target);
fs.mkdirSync(dir, { recursive: true });
const writeStream = fs.createWriteStream(target);
req.pipe(writeStream);
writeStream.on('finish', function () {
res.writeHead(201, { 'Location': url.pathname });
res.end();
});
writeStream.on('error', function () {
res.writeHead(500);
res.end();
});
return;
}
if (method === 'DELETE') {
fs.rmSync(target, { recursive: true, force: true });
res.writeHead(204);
res.end();
return;
}
res.writeHead(405, { 'Allow': 'GET,HEAD,OPTIONS,PROPFIND,PUT,DELETE' });
res.end();
} catch (err) {
res.writeHead(500);
res.end();
}
});
server.listen(3000, function () {
console.log('WebDAV Mock2Image running on http://localhost:3000');
});代码里有几个值得注意的点。safePath 使用 path.relative 判断越界,比简单字符串前缀匹配更可靠。listPropfind 对目录和文件分别生成 XML,目录会列出直接子项,文件只返回自身属性。GET 和 HEAD 使用 fs.createReadStream 管道输出,避免把大图一次性读入内存。PUT 直接把请求流写入磁盘,适合模拟图片上传。若要让服务更像真实 WebDAV,还可以补上 ETag、Last-Modified、Range 解析和目录自动创建。
测试验证与常见坑位
服务启动后,可以用 curl 做快速验证。PROPFIND 能返回 207 和 XML,说明目录查询链路基本可用。GET 能返回 image/png 且图片能打开,说明二进制流和 MIME 类型正确。PUT 能写入文件,说明上传链路可用。下面命令仅作为本地联调示例,真实项目里还需要根据鉴权方式补充 Authorization 头。
curl -X PROPFIND http://localhost:3000/dav/ -H "Depth: 1" curl -X PUT http://localhost:3000/dav/test.png --data-binary @./test.png -H "Content-Type: image/png" curl -I http://localhost:3000/dav/test.png
常见坑之一是 XML 命名空间。WebDAV 客户端通常要求响应带有 DAV 命名空间,如果只是返回普通 XML 字符串,某些客户端会解析失败。另一个坑是路径编码,URL 里的中文文件名、空格、百分号编码需要在服务端 decode 后再映射到磁盘路径,否则会出现文件存在但 404 的情况。还有 Content-Type 缺失,图片在浏览器里显示为下载文件,通常是 Mock 服务没有根据扩展名返回 image/png、image/jpeg、image/webp。
如果面向生产化测试,还可以把 Mock2Image 做成可配置服务。根目录、端口、鉴权开关、允许方法、响应延迟、错误注入都可以通过环境变量控制。这样既能模拟正常图片仓库,也能验证超时、403、404、500 等异常分支。对于图片处理链路,还可以内置一张固定测试图,或者按请求参数动态返回不同尺寸,让缩略图、原图、占位图的测试成本更低。