diff --git a/chfs-ng/internal/api/server.go b/chfs-ng/internal/api/server.go index 502c665..7074340 100644 --- a/chfs-ng/internal/api/server.go +++ b/chfs-ng/internal/api/server.go @@ -76,7 +76,17 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/chfs/shared/", s.shared) mux.HandleFunc("/chfs/preview", s.preview) mux.HandleFunc("/", s.fileHandler) - return s.ipFilter(mux) + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Go's ServeMux canonicalizes a subtree route before dispatching. + // CHFS v4 deliberately returns a relative 301 from /chfs/shared, so + // intercept the exact path before ServeMux gets a chance to rewrite it. + if r.URL.Path == "/chfs/shared" { + s.shared(w, r) + return + } + mux.ServeHTTP(w, r) + }) + return s.ipFilter(h) } func account(r *http.Request, s *Server) *auth.Account { c, err := r.Cookie("JWT") @@ -659,7 +669,9 @@ func (s *Server) shared(w http.ResponseWriter, r *http.Request) { if r.URL.RawQuery != "" { location += "?" + r.URL.RawQuery } - http.Redirect(w, r, location, http.StatusMovedPermanently) + w.Header().Set("Content-Disposition", `attachment;filename="shared"`) + w.Header().Set("Location", location) + w.WriteHeader(http.StatusMovedPermanently) return } p := strings.Trim(strings.TrimPrefix(r.URL.Path, "/chfs/shared/"), "/") @@ -808,6 +820,7 @@ func (s *Server) downloadDir(w http.ResponseWriter, r *http.Request) { name = "download" } w.Header().Set("Content-Type", "application/zip") + // CHFS v4 does not emit Content-Disposition for directory archives. zw := zip.NewWriter(w) defer zw.Close() var walk func(string, string) error diff --git a/chfs-ng/internal/api/share_archive_test.go b/chfs-ng/internal/api/share_archive_test.go new file mode 100644 index 0000000..2628278 --- /dev/null +++ b/chfs-ng/internal/api/share_archive_test.go @@ -0,0 +1,95 @@ +package api + +import ( + "archive/zip" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "chfs-ng/internal/auth" + "chfs-ng/internal/storage" +) + +func TestSharedRoutesMatchCHFS(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "hello.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Accounts: map[string]*auth.Account{}, Guest: &auth.Account{Mask: auth.Read | auth.Write | auth.Delete}}} + + r := httptest.NewRequest(http.MethodGet, "/chfs/shared?file=hello.txt", nil) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + if w.Code != http.StatusMovedPermanently || w.Header().Get("Location") != "shared/?file=hello.txt" { + t.Fatalf("shared redirect: status=%d location=%q", w.Code, w.Header().Get("Location")) + } + + r = httptest.NewRequest(http.MethodGet, "/chfs/shared/hello.txt", nil) + w = httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + if w.Code != http.StatusOK || w.Header().Get("Content-Disposition") != `attachment;filename="hello.txt"` { + t.Fatalf("shared file: status=%d disposition=%q", w.Code, w.Header().Get("Content-Disposition")) + } + body, _ := io.ReadAll(w.Result().Body) + if string(body) != "hello" { + t.Fatalf("shared body=%q", body) + } +} + +func TestDownloadDirArchiveHeadersAndEntries(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "pack") + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("abc"), 0o644); err != nil { + t.Fatal(err) + } + s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Accounts: map[string]*auth.Account{}, Guest: &auth.Account{Mask: auth.Read | auth.Write | auth.Delete}}} + + r := httptest.NewRequest(http.MethodGet, "/chfs/downloaddir/pack", nil) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("archive status=%d body=%s", w.Code, w.Body.String()) + } + if got := w.Header().Get("Content-Type"); got != "application/zip" { + t.Fatalf("archive content type=%q", got) + } + if got := w.Header().Get("Content-Disposition"); got != "" { + t.Fatalf("CHFS archive must not emit Content-Disposition, got %q", got) + } + zr, err := zip.NewReader(bytesReaderAt{r: w.Body.Bytes()}, int64(w.Body.Len())) + if err != nil { + t.Fatalf("open archive: %v", err) + } + if len(zr.File) != 1 || zr.File[0].Name != "a.txt" { + t.Fatalf("archive entries=%v", archiveNames(zr.File)) + } + if zr.File[0].UncompressedSize64 != 3 { + t.Fatalf("archive size=%d", zr.File[0].UncompressedSize64) + } +} + +type bytesReaderAt struct{ r []byte } + +func (b bytesReaderAt) ReadAt(p []byte, off int64) (int, error) { + if off >= int64(len(b.r)) { + return 0, io.EOF + } + n := copy(p, b.r[off:]) + if n < len(p) { + return n, io.EOF + } + return n, nil +} +func archiveNames(files []*zip.File) []string { + n := make([]string, 0, len(files)) + for _, f := range files { + n = append(n, f.Name) + } + return n +}