Caddy代理目录与预览
Caddy 代理 marksrv 目录 · 文件浏览页面开发笔记
一、需求背景
在 Caddy 服务器上,配置了如下站点:
example.com {
root * /marksrv
file_server
}希望写一个网页,能显示 /marksrv 目录下的所有文件(实际只需 .md 和 .xmind 两类),并为每个文件提供两种预览方式的入口。
容器内 /marksrv 实际内容示例:
a1111.html
dm.mm.md
index.html
jianliaa.md
kexing.xmind
quai.md
test.xmind
view.html
zai.md可见根目录下存在 index.html,这点后面成了关键坑。
二、整体架构
浏览器
│
│ GET / (请求 Caddy root)
▼
Caddy (root * /marksrv, file_server browse)
│
│ 返回目录列表 HTML
▼
页面 JS 解析 HTML → 提取 .md / .xmind 链接 → 渲染列表
│
├── 预览1 按钮 → view.html/?file=文件名
└── 预览2 按钮 → https://zmdmap.zhaopeng.site/文件名核心思路:不依赖任何后端接口,直接请求 Caddy 的根路径 /,让 Caddy 返回目录列表 HTML,前端用 DOMParser 解析出文件链接。
三、踩坑与排查过程
坑 1:404 —— file_server 未开启 browse
现象:网页 fetch / 返回 404,报错:
加载文件列表失败: 目录不存在或无法访问 (404)原因:Caddy 的 file_server 默认不列出目录,只提供文件访问。
解决:在 Caddyfile 中加 browse 参数:
example.com {
root * /marksrv
file_server browse
}reload:
caddy reload --config /etc/caddy/Caddyfile或:
sudo systemctl reload caddy排查命令:
podman exec caddy ls /marksrv # 确认文件在容器内
curl -i https://域名/ # 看返回状态码
journalctl -u caddy -n 50 --no-pager # 看 Caddy 日志坑 2:root 与 URL 路径的映射关系
Caddyfile 中:
root * /marksrv含义是:URL 的 / 直接映射到容器内 /marksrv/。
| 浏览器访问 | 实际读取的文件 |
|---|---|
https://域名/ | /marksrv/index.html |
https://域名/dm.mm.md | /marksrv/dm.mm.md |
https://域名/view.html | /marksrv/view.html |
结论:URL 中不应该出现 marksrv,root 已把它吃掉了。
网页里显示的 /marksrv/* 只是装饰文字,实际请求地址是:
const API_ENDPOINT = '/';坑 3:根路径被 index.html 劫持
现象:加了 browse 后,直接访问 / 仍然拿不到目录列表。
原因:/marksrv/index.html 存在。Caddy 的 file_server 默认会优先返回目录下的 index.html,而不是返回目录列表。
解决:禁用自动 index:
example.com {
root * /marksrv
file_server browse {
index off
}
}reload 后访问 https://域名/,即可看到目录列表。
重要说明:index off 只影响目录请求(如 /、/sub/)时是否自动返回 index.html。直接访问 https://域名/index.html 仍然正常,不会破坏原有页面。
坑 4:nameEncoded 变量未定义
现象:代码里用了 ${nameEncoded},但 renderFileList 的 map 回调里没有定义它,运行时抛 ReferenceError。
解决:在回调里加:
const nameEncoded = encodeURIComponent(file.name);如果文件名可能包含子目录(含 /),需要保留斜杠:
const nameEncoded = file.name.split('/').map(encodeURIComponent).join('/');四、Caddyfile 最终配置
example.com {
root * /marksrv
file_server browse {
index off
}
}关键点:
browse:开启目录列表index off:禁止自动返回 index.html,让/显示目录列表root * /marksrv:URL/对应容器内/marksrv/
五、前端页面设计
5.1 功能清单
| 功能 | 说明 |
|---|---|
| 目录列表 | 请求 /,解析 Caddy 返回的 HTML |
只显示 .md 和 .xmind | 过滤其他文件 |
| 文件类型标签 | MD 蓝色 / XMIND 紫色 |
| 预览1 按钮 | 跳转 view.html/?file=文件名(蓝色) |
| 预览2 按钮 | 跳转 https://zmdmap.zhaopeng.site/文件名(绿色) |
| 刷新按钮 | 重新拉取目录 |
| 状态提示 | 加载 / 空目录 / 错误 |
5.2 两个预览按钮的差异
<!-- 预览1:本域内的 view.html,走 query 传参 -->
<a class="file-link" href="view.html/?file=${nameEncoded}" target="_blank" rel="noopener noreferrer">
预览1
</a>
<!-- 预览2:外部域名 zmdmap,走路径拼接 -->
<a class="file-link alt" href="https://zmdmap.zhaopeng.site/${nameEncoded}" target="_blank" rel="noopener noreferrer">
预览2
</a>注意 query 格式取决于 view.html 内部读取方式:
- 若用
new URLSearchParams(location.search).get('file')→ 用?file=xxx - 若用
location.search.slice(1)直接拿整串 → 用?xxx(无参数名)
六、目录列表 HTML 解析逻辑
Caddy 的 browse 页面结构大致为:
<!DOCTYPE html>
<html>
<head><title>/</title></head>
<body>
<h1>Index of /</h1>
<pre>
<a href="dm.mm.md">dm.mm.md</a>
<a href="kexing.xmind">kexing.xmind</a>
<a href="index.html">index.html</a>
...
</pre>
</body>
</html>解析步骤:
- 用
DOMParser解析 HTML - 遍历所有
a[href] 过滤掉:
../、/、#、?开头- 包含
?的排序链接 ./或空字符串- 以
/结尾的目录链接 - 非
.md/.xmind的文件
- 去重、排序、渲染
七、代码精简与优化
7.1 原始版本体积
约 20KB,主要来自:
- 内联 SVG(每行两个)
- 复杂 CSS(阴影、过渡、圆角)
- 冗余 HTML 结构与注释
7.2 精简策略
| 优化项 | 说明 |
|---|---|
| 移除内联 SVG | 用纯文字 ↗ 替代 |
| CSS 极简化 | 只保留布局必需属性 |
| 合并状态样式 | 加载 / 空 / 错误统一处理 |
| 箭头函数 + 模板字符串 | 替代冗长写法 |
| 删除未用变量 | 如 formatFileSize(Caddy 不返回大小) |
7.3 精简后体积
- 约 3.5KB
- gzip 后约 1.2KB
7.4 20KB 是否算大
- 对纯静态页面来说,20KB 不算大,gzip 后一般 5–6KB
- 真正影响体验的是
.md/.xmind文件本身的大小 - 若追求极致或低带宽环境,可改用精简版
八、最终精简版代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>marksrv</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font:14px/1.5 system-ui,sans-serif;background:#f8fafc;color:#0f172a;padding:1.5rem}
.wrap{max-width:900px;margin:0 auto;background:#fff;border-radius:12px;padding:1.5rem;box-shadow:0 4px 12px rgba(0,0,0,.06)}
h1{font-size:1.3rem;margin-bottom:1rem}
h1 span{font-size:.75rem;background:#e8f0fe;color:#1e5bc6;padding:2px 8px;border-radius:20px;margin-left:8px;vertical-align:middle}
.bar{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;font-size:.85rem;color:#555}
button{background:#fff;border:1px solid #cfddee;padding:5px 12px;border-radius:20px;cursor:pointer;font:inherit;color:#1e3a5f}
button:hover{background:#eef4ff}
ul{list-style:none;border:1px solid #e3eaf2;border-radius:8px;overflow:hidden}
li{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid #ecf2f9;flex-wrap:wrap}
li:last-child{border-bottom:none}
li:hover{background:#f6faff}
.n{flex:1;min-width:150px;font-family:monospace;word-break:break-all}
a{font-size:.8rem;text-decoration:none;padding:3px 10px;border-radius:20px;white-space:nowrap}
.a1{background:#e8f0fe;color:#1a5bc7}
.a1:hover{background:#d4e2fc}
.a2{background:#e6f7ee;color:#1a7f4c}
.a2:hover{background:#d0efe0}
.empty{text-align:center;padding:2rem;color:#888}
.err{background:#fff2f0;color:#b13e3e;padding:12px;border-radius:8px}
</style>
</head>
<body>
<div class="wrap">
<h1>marksrv <span>root</span></h1>
<div class="bar">
<span id="count">加载中...</span>
<button id="refresh">刷新</button>
</div>
<div id="list"></div>
</div>
<script>
const API='/', EXT=['.md','.xmind'], ALT='https://zmdmap.zhaopeng.site/';
const $=id=>document.getElementById(id);
const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const enc=n=>n.split('/').map(encodeURIComponent).join('/');
async function load(){
$('list').innerHTML='<div class="empty">加载中...</div>';
$('count').textContent='加载中...';
try{
const r=await fetch(API+'?t='+Date.now(),{cache:'no-store'});
if(!r.ok) throw new Error('HTTP '+r.status);
const html=await r.text();
const doc=new DOMParser().parseFromString(html,'text/html');
const seen=new Set(), files=[];
doc.querySelectorAll('a[href]').forEach(a=>{
let h=a.getAttribute('href');
if(!h||/^(\.\.\/?|\/|#|\?)/.test(h)||h.includes('?')) return;
h=h.replace(/^\.\//,'');
if(!h||h==='./'||h==='/') return;
let n=(a.textContent||'').trim()||decodeURIComponent(h);
if(n.endsWith('/')) return;
const low=n.toLowerCase();
if(!EXT.some(e=>low.endsWith(e))) return;
if(seen.has(n)) return;
seen.add(n); files.push(n);
});
files.sort((a,b)=>a.localeCompare(b,'zh-CN'));
if(!files.length){
$('list').innerHTML='<div class="empty">没有 .md 或 .xmind 文件</div>';
$('count').textContent='共 0 个文件';
return;
}
$('list').innerHTML='<ul>'+files.map(n=>{
const e=enc(n);
return `<li><span class="n">${esc(n)}</span>
<a class="a1" href="view.html/?file=${e}" target="_blank" rel="noopener">预览1</a>
<a class="a2" href="${ALT}${e}" target="_blank" rel="noopener">预览2</a></li>`;
}).join('')+'</ul>';
$('count').textContent='共 '+files.length+' 个文件';
}catch(err){
$('list').innerHTML='<div class="err">加载失败:'+esc(err.message)+'</div>';
$('count').textContent='加载失败';
}
}
$('refresh').onclick=load;
load();
</script>
</body>
</html>九、关键经验总结
- Caddy 的
file_server默认不列目录,必须显式加browse root * /marksrv意味着 URL 中不能再带/marksrv,/已经是 marksrv 根- 目录下存在
index.html会劫持根路径请求,用index off关闭自动 index 即可,不影响直接访问index.html - 纯前端方案可行:请求
/→ 解析 Caddy 返回的目录 HTML → 渲染列表 - query 参数格式要与
view.html内部读取方式匹配(?file=xxxvs?xxx) - 文件名要
encodeURIComponent,中文、空格、特殊字符才不会出错 - 20KB 静态页不算大,但可精简到 3.5KB,功能完全一致
- 调试三件套:
curl -i、F12 Network、journalctl -u caddy - 最终实现:zmind页面访问,和zmdmap树状访问。zmind和caddy挂载了zmdmap的目录,把zmind用的index.html放在挂载的目录。zmdmap无法根目录访问,借助zmind另个域名实现根加载目录访问,index.html再实现两种预览。
十、相关命令速查
# 查看 Caddy 容器内目录
podman exec caddy ls /marksrv
# reload Caddy
caddy reload --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# 查看 Caddy 日志
journalctl -u caddy -n 50 --no-pager
# 测试根路径返回
curl -i https://域名/
# 强制刷新浏览器
Ctrl+Shift+R (Windows/Linux)
Cmd+Shift+R (macOS)