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
    }
}

关键点:

  1. browse:开启目录列表
  2. index off:禁止自动返回 index.html,让 / 显示目录列表
  3. 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>

解析步骤:

  1. 用 DOMParser 解析 HTML
  2. 遍历所有 a[href]
  3. 过滤掉:

    • ../、/、#、? 开头
    • 包含 ? 的排序链接
    • ./ 或空字符串
    • 以 / 结尾的目录链接
    • 非 .md / .xmind 的文件
  4. 去重、排序、渲染

七、代码精简与优化

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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>

九、关键经验总结

  1. Caddy 的 file_server 默认不列目录,必须显式加 browse
  2. root * /marksrv 意味着 URL 中不能再带 /marksrv,/ 已经是 marksrv 根
  3. 目录下存在 index.html 会劫持根路径请求,用 index off 关闭自动 index 即可,不影响直接访问 index.html
  4. 纯前端方案可行:请求 / → 解析 Caddy 返回的目录 HTML → 渲染列表
  5. query 参数格式要与 view.html 内部读取方式匹配(?file=xxx vs ?xxx)
  6. 文件名要 encodeURIComponent,中文、空格、特殊字符才不会出错
  7. 20KB 静态页不算大,但可精简到 3.5KB,功能完全一致
  8. 调试三件套:curl -i、F12 Network、journalctl -u caddy
  9. 最终实现: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)

标签: none

添加新评论