Files
wangjia 2e631ed3b5
Deploy Server / deploy-server (push) Successful in 2m59s
feat(server+web): 客户端下载闭环——pangolin-server /downloads + 官网下载链接
- 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
2026-07-06 19:48:57 +08:00

118 lines
3.6 KiB
Go

package httpapi
import (
"context"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
)
// buildDownloadsRouter mounts DownloadsHandler on a real chi router (mirrors
// how main.go mounts it under "/downloads/*") so the wildcard param behaves
// exactly as it does in production.
func buildDownloadsRouter(dir string) chi.Router {
h := NewDownloadsHandler(dir)
r := chi.NewRouter()
r.Get("/downloads/*", h.Serve)
return r
}
func TestDownloadsHandler_ServesExistingFile(t *testing.T) {
dir := t.TempDir()
content := []byte("hello pangolin apk bytes")
if err := os.WriteFile(filepath.Join(dir, "pangolin-android.apk"), content, 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
r := buildDownloadsRouter(dir)
req := httptest.NewRequest("GET", "/downloads/pangolin-android.apk", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != string(content) {
t.Errorf("body = %q, want %q", got, content)
}
}
func TestDownloadsHandler_MissingFile404(t *testing.T) {
dir := t.TempDir()
r := buildDownloadsRouter(dir)
req := httptest.NewRequest("GET", "/downloads/nope.exe", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestDownloadsHandler_BareDirNotListed(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "pangolin-android.apk"), []byte("x"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
r := buildDownloadsRouter(dir)
req := httptest.NewRequest("GET", "/downloads/", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("bare dir status = %d, want 404 (no directory listing)", rec.Code)
}
}
func TestDownloadsHandler_PathTraversalBlocked(t *testing.T) {
outerDir := t.TempDir()
secretPath := filepath.Join(outerDir, "secret.txt")
if err := os.WriteFile(secretPath, []byte("top secret"), 0o600); err != nil {
t.Fatalf("write secret: %v", err)
}
dir := filepath.Join(outerDir, "downloads")
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatalf("mkdir downloads: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "pangolin-android.apk"), []byte("apk"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
r := buildDownloadsRouter(dir)
// net/http's ServeMux/chi normalize ".." segments in the URL path before
// routing, so we exercise the handler directly with a raw URLParam to
// simulate any escape attempt that might otherwise reach it, in addition
// to the router-level request below.
h := NewDownloadsHandler(dir)
req := httptest.NewRequest("GET", "/downloads/../secret.txt", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("*", "../secret.txt")
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
h.Serve(rec, req)
if rec.Code != 404 {
t.Fatalf("direct traversal status = %d, want 404 (must not escape dir)", rec.Code)
}
if rec.Body.String() == "top secret" {
t.Fatalf("traversal leaked secret file contents")
}
// Router-level request: most HTTP clients/servers collapse ".." during URL
// normalization, but confirm the end-to-end path also can't reach the file
// outside dir and doesn't 200 with the secret's contents.
req2 := httptest.NewRequest("GET", "/downloads/../secret.txt", nil)
rec2 := httptest.NewRecorder()
r.ServeHTTP(rec2, req2)
if rec2.Body.String() == "top secret" {
t.Fatalf("router-level traversal leaked secret file contents (status=%d)", rec2.Code)
}
}