Initial release of bdrclone

This commit is contained in:
2026-08-13 23:02:25 +08:00
commit d6d1956050
23 changed files with 3042 additions and 0 deletions

136
internal/serve/http.go Normal file
View File

@@ -0,0 +1,136 @@
package serve
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"gitea.dddbg.com/youbin/bdrclone/internal/baidu"
)
type Server struct {
client *baidu.Client
server *http.Server
}
func New(client *baidu.Client, address string) *Server {
s := &Server{client: client}
s.server = &http.Server{Addr: address, Handler: s, ReadHeaderTimeout: 10 * time.Second}
return s
}
func (s *Server) ListenAndServe(ctx context.Context) error {
errCh := make(chan error, 1)
go func() { errCh <- s.server.ListenAndServe() }()
select {
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return errors.Join(s.server.Shutdown(shutdownCtx), ctx.Err())
}
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
entry, err := s.client.Stat(r.Context(), r.URL.Path)
if err != nil {
if errors.Is(err, baidu.ErrNotFound) {
http.NotFound(w, r)
return
}
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if entry.IsDirectory() {
s.serveDirectory(w, r)
return
}
s.serveFile(w, r, entry)
}
func (s *Server) serveDirectory(w http.ResponseWriter, r *http.Request) {
entries, err := s.client.List(r.Context(), r.URL.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if r.Method == http.MethodHead {
return
}
_ = json.NewEncoder(w).Encode(entries)
}
func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, file baidu.File) {
offset, length, partial, err := parseRange(r.Header.Get("Range"), file.Size)
if err != nil {
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", file.Size))
http.Error(w, err.Error(), http.StatusRequestedRangeNotSatisfiable)
return
}
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("Last-Modified", file.ModTime().UTC().Format(http.TimeFormat))
if r.Method == http.MethodHead || length == 0 {
w.Header().Set("Content-Length", strconv.FormatInt(length, 10))
if partial {
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+length-1, file.Size))
w.WriteHeader(http.StatusPartialContent)
}
return
}
body, err := s.client.Open(r.Context(), file, offset, length)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer body.Close()
w.Header().Set("Content-Length", strconv.FormatInt(length, 10))
if partial {
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+length-1, file.Size))
w.WriteHeader(http.StatusPartialContent)
}
_, _ = io.CopyN(w, body, length)
}
func parseRange(value string, size int64) (offset, length int64, partial bool, err error) {
if value == "" {
return 0, size, false, nil
}
if !strings.HasPrefix(value, "bytes=") || strings.Contains(value, ",") {
return 0, 0, false, errors.New("only one bytes range is supported")
}
parts := strings.SplitN(strings.TrimPrefix(value, "bytes="), "-", 2)
if len(parts) != 2 || parts[0] == "" {
return 0, 0, false, errors.New("suffix ranges are not supported")
}
start, parseErr := strconv.ParseInt(parts[0], 10, 64)
if parseErr != nil || start < 0 || start >= size {
return 0, 0, false, errors.New("invalid range start")
}
end := size - 1
if parts[1] != "" {
end, parseErr = strconv.ParseInt(parts[1], 10, 64)
if parseErr != nil || end < start {
return 0, 0, false, errors.New("invalid range end")
}
if end >= size {
end = size - 1
}
}
return start, end - start + 1, true, nil
}

View File

@@ -0,0 +1,29 @@
package serve
import "testing"
func TestParseRange(t *testing.T) {
tests := []struct {
value string
offset, length int64
partial, shouldReject bool
}{
{"", 0, 10, false, false},
{"bytes=2-5", 2, 4, true, false},
{"bytes=7-", 7, 3, true, false},
{"bytes=7-99", 7, 3, true, false},
{"bytes=-3", 0, 0, false, true},
{"bytes=10-11", 0, 0, false, true},
{"items=0-1", 0, 0, false, true},
}
for _, test := range tests {
offset, length, partial, err := parseRange(test.value, 10)
if (err != nil) != test.shouldReject {
t.Errorf("parseRange(%q) err=%v", test.value, err)
continue
}
if err == nil && (offset != test.offset || length != test.length || partial != test.partial) {
t.Errorf("parseRange(%q) = (%d,%d,%t), want (%d,%d,%t)", test.value, offset, length, partial, test.offset, test.length, test.partial)
}
}
}