aboutsummaryrefslogtreecommitdiff
path: root/fake-r2.go
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commit3284b1c4906b90ca2e3864937c66d0fa30772ebf (patch)
treead799407ac7b0a2589a344751f4c5b3cf575d5c5 /fake-r2.go
downloadgit-forge-cloudflare-master.tar.gz
Enter git-forge-cloudflareHEADmaster
Runs git-forge on Cloudflare Containers with R2 for durable storage. A Worker routes every request to one container through a Durable Object. Inside it, syncd mirrors the data directory to the bucket and restores it at boot, holding a write until it is durable. git-forge is neither modified nor vendored: the image builds it from its own repository at the commit named in forge.rev.
Diffstat (limited to 'fake-r2.go')
-rw-r--r--fake-r2.go161
1 files changed, 161 insertions, 0 deletions
diff --git a/fake-r2.go b/fake-r2.go
new file mode 100644
index 0000000..6bfdb3f
--- /dev/null
+++ b/fake-r2.go
@@ -0,0 +1,161 @@
+//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})
+}