2e631ed3b5
Deploy Server / deploy-server (push) Successful in 2m59s
- server: /downloads/* 公开静态服务(NewDownloadsHandler,DOWNLOADS_DIR 默认
/var/lib/pangolin/downloads),防目录穿越/不列目录/缺目录不崩,Cache-Control:
no-cache 保证 CI 覆盖后永远最新。挂在 /healthz 同级(免鉴权)。含 4 项测试。
- deploy.sh: install -d 下载目录 + server.env 注入 DOWNLOADS_DIR。
- web: site.ts 增 downloads.{android,windows}(→ https://api.yanmeiai.com/downloads/*);
Download.astro 接上 Android/Windows 按钮,iOS/macOS/Linux 改「即将推出」禁用态。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
88 lines
3.2 KiB
Go
88 lines
3.2 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// DownloadsHandler 静态服务客户端安装包(pangolin-android.apk /
|
|
// pangolin-windows-x64-setup.exe 等),供官网下载按钮直链。CI
|
|
// (scripts/ci/deploy-client.sh)把最新安装包 scp 到该目录、按平台固定文件名覆盖。
|
|
//
|
|
// 无需鉴权(匿名下载),但:
|
|
// - 不暴露目录列表(裸目录 / 不存在的文件一律 404,不用 http.FileServer 的默认
|
|
// 目录浏览行为)
|
|
// - 防目录穿越(拒绝任何解析后逃出 dir 的路径,而不仅仅是字符串里含 ".." 就拒,
|
|
// 这样能正确处理 "foo/../bar" 这类仍落在 dir 内的写法,同时挡住真正逃逸的路径)
|
|
// - DOWNLOADS_DIR 在启动时可以不存在(比如还没跑过一次 CI 部署),路由仍要能
|
|
// 注册,只是请求会 404,不能让进程直接崩溃
|
|
type DownloadsHandler struct {
|
|
dir string
|
|
}
|
|
|
|
// NewDownloadsHandler 指定安装包所在目录(DOWNLOADS_DIR,默认 /var/lib/pangolin/downloads)。
|
|
func NewDownloadsHandler(dir string) *DownloadsHandler {
|
|
if dir == "" {
|
|
dir = "/var/lib/pangolin/downloads"
|
|
}
|
|
return &DownloadsHandler{dir: dir}
|
|
}
|
|
|
|
// Serve 处理 GET /downloads/{file}(chi 通配 "*",支持任意文件名,不限定白名单,
|
|
// 因为 CI 产出的安装包文件名随平台/版本命名策略变化,这里只做路径安全校验)。
|
|
func (h *DownloadsHandler) Serve(w http.ResponseWriter, r *http.Request) {
|
|
name := chi.URLParam(r, "*")
|
|
if name == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// 拒绝任何路径分隔符之外的穿越:先按 "/" 做 Clean,再校验结果既不是绝对路径、
|
|
// 也没有以 ".." 开头(即没有逃出 dir),最后拒绝空/根路径。这比单纯 strings.Contains(name, "..")
|
|
// 更准确 —— 例如 "sub/../file.apk" 清洗后是 "file.apk",本来就没有逃逸,不该被误杀;
|
|
// 而 "../etc/passwd" 清洗后以 ".." 开头,必须拒绝。
|
|
cleaned := filepath.Clean("/" + name) // 前置 "/" 后 Clean,任何 ".." 都无法越过根
|
|
cleaned = strings.TrimPrefix(cleaned, "/")
|
|
if cleaned == "" || cleaned == "." || strings.HasPrefix(cleaned, "..") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
full := filepath.Join(h.dir, cleaned)
|
|
// 双重保险:确认最终路径确实在 dir 之下(处理 dir 本身含 ".." 或符号链接等边角情况)。
|
|
relDir, err := filepath.Abs(h.dir)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
absFull, err := filepath.Abs(full)
|
|
if err != nil || (absFull != relDir && !strings.HasPrefix(absFull, relDir+string(filepath.Separator))) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
f, err := os.Open(full)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
st, err := f.Stat()
|
|
if err != nil || st.IsDir() {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\""+filepath.Base(cleaned)+"\"")
|
|
// no-cache:CI「仅留最新」在同一稳定 URL 覆盖文件,不能让 CF/浏览器返回旧包。
|
|
// no-cache = 可缓存但每次须回源校验;ServeContent 带 Last-Modified,未变则 304
|
|
// (便宜),变了才传新字节 —— 既保证永远最新,又避免每次全量 82MB 回源。
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
http.ServeContent(w, r, filepath.Base(cleaned), st.ModTime(), f)
|
|
}
|