//go:build ignore package main // A stand-in for the object handler in worker.js, backed by a directory, so // the image can be driven end to end without a Cloudflare account. // // go run fake-r2.go -dir /tmp/bucket -listen :9000 import ( "encoding/json" "flag" "io" "io/fs" "log" "net/http" "os" "path/filepath" "slices" "strconv" "strings" ) var dir string func main() { listen := flag.String("listen", ":9000", "address to serve on") flag.StringVar(&dir, "dir", "", "directory to keep objects in") flag.Parse() if dir == "" { log.Fatal("-dir is required") } if err := os.MkdirAll(dir, 0700); err != nil { log.Fatal(err) } log.Printf("fake r2 on %s backed by %s", *listen, dir) log.Fatal(http.ListenAndServe(*listen, http.HandlerFunc(serve))) } // path keeps a key inside the directory. func path(key string) (string, bool) { if !filepath.IsLocal(key) { return "", false } return filepath.Join(dir, filepath.FromSlash(key)), true } func serve(w http.ResponseWriter, r *http.Request) { key := strings.TrimPrefix(r.URL.Path, "/") // A post with no key is the log host; the lines are logged for the test to // assert on. if r.Method == http.MethodPost && key == "" { body, _ := io.ReadAll(r.Body) for _, line := range strings.Split(strings.TrimRight(string(body), "\n"), "\n") { if line != "" { log.Printf("container: %s", line) } } w.WriteHeader(http.StatusNoContent) return } // Logged, so the test can assert an idle sweep writes nothing. log.Printf("%s %s", r.Method, key) if r.Method == http.MethodGet && key == "" { list(w, r) return } file, ok := path(key) if !ok { http.Error(w, "bad key", http.StatusBadRequest) return } switch r.Method { case http.MethodGet: f, err := os.Open(file) if err != nil { http.Error(w, "not found", http.StatusNotFound) return } defer f.Close() info, err := f.Stat() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10)) _, _ = io.Copy(w, f) case http.MethodPut: if r.ContentLength < 0 { http.Error(w, "content-length required", http.StatusLengthRequired) return } if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } tmp, err := os.CreateTemp(filepath.Dir(file), ".put*") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer os.Remove(tmp.Name()) if _, err := io.Copy(tmp, r.Body); err != nil { tmp.Close() http.Error(w, err.Error(), http.StatusInternalServerError) return } tmp.Close() if err := os.Rename(tmp.Name(), file); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) case http.MethodDelete: _ = os.Remove(file) w.WriteHeader(http.StatusNoContent) default: http.Error(w, "bad method", http.StatusMethodNotAllowed) } } // Real r2 pages at a thousand; ten makes the container follow a cursor. const page = 10 func list(w http.ResponseWriter, r *http.Request) { prefix := r.URL.Query().Get("prefix") var keys []string err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } rel, err := filepath.Rel(dir, p) if err != nil { return err } key := filepath.ToSlash(rel) if strings.HasPrefix(key, prefix) && !strings.HasPrefix(filepath.Base(key), ".put") { keys = append(keys, key) } return nil }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } slices.Sort(keys) if keys == nil { keys = []string{} // an empty listing is [], as the worker's map gives } from := 0 if c := r.URL.Query().Get("cursor"); c != "" { from, _ = strconv.Atoi(c) } to := min(from+page, len(keys)) next := "" if to < len(keys) { next = strconv.Itoa(to) } _ = json.NewEncoder(w).Encode(map[string]any{"keys": keys[from:to], "cursor": next}) }