Simple S3-like server for development purposes, written in Go
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

First commit, basic scaffolding working

+162
+27
.gitignore
··· 1 + # Created by https://www.toptal.com/developers/gitignore/api/go 2 + # Edit at https://www.toptal.com/developers/gitignore?templates=go 3 + 4 + ### Go ### 5 + # If you prefer the allow list template instead of the deny list, see community template: 6 + # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 7 + # 8 + # Binaries for programs and plugins 9 + *.exe 10 + *.exe~ 11 + *.dll 12 + *.so 13 + *.dylib 14 + 15 + # Test binary, built with `go test -c` 16 + *.test 17 + 18 + # Output of the go coverage tool, specifically when used with LiteIDE 19 + *.out 20 + 21 + # Dependency directories (remove the comment below to include it) 22 + # vendor/ 23 + 24 + # Go workspace file 25 + go.work 26 + 27 + # End of https://www.toptal.com/developers/gitignore/api/go
+3
go.mod
··· 1 + module tangled.org/juanlu.space/gos3dir 2 + 3 + go 1.24.4
+132
gos3dir.go
··· 1 + package main 2 + 3 + import ( 4 + "errors" 5 + "io" 6 + "io/fs" 7 + "log" 8 + "net/http" 9 + "os" 10 + "path/filepath" 11 + ) 12 + 13 + func addServerHeaders(serverName string) func(http.Handler) http.Handler { 14 + return func(next http.Handler) http.Handler { 15 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 + w.Header().Add("Server", serverName) 17 + next.ServeHTTP(w, r) 18 + }) 19 + } 20 + } 21 + 22 + func logging(logger *log.Logger) func(http.Handler) http.Handler { 23 + return func(next http.Handler) http.Handler { 24 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 25 + logger.Printf("%s %s %s", r.Method, r.URL.Path, r.RemoteAddr) 26 + next.ServeHTTP(w, r) 27 + }) 28 + } 29 + } 30 + 31 + func ls(w http.ResponseWriter, r *http.Request) { 32 + log.Printf("Path %s", r.PathValue("bucket")) 33 + // TODO: Probably requires actual XML 34 + w.WriteHeader(http.StatusNotImplemented) 35 + } 36 + 37 + func mb(w http.ResponseWriter, r *http.Request) { 38 + bucketName := r.PathValue("bucket") 39 + if err := os.Mkdir(bucketName, 0775); err != nil { 40 + if errors.Is(err, fs.ErrExist) { 41 + http.Error(w, "Your previous request to create the named bucket succeeded and you already own it.", http.StatusConflict) 42 + } else { 43 + http.Error(w, err.Error(), http.StatusInternalServerError) 44 + } 45 + return 46 + } 47 + 48 + w.WriteHeader(http.StatusOK) 49 + } 50 + 51 + func rb(w http.ResponseWriter, r *http.Request) { 52 + bucketName := r.PathValue("bucket") 53 + files, err := os.ReadDir(bucketName) 54 + if err != nil { 55 + if errors.Is(err, fs.ErrNotExist) { 56 + http.Error(w, "The specified bucket does not exist", http.StatusConflict) 57 + } else { 58 + http.Error(w, err.Error(), http.StatusInternalServerError) 59 + } 60 + return 61 + } 62 + if len(files) > 0 { 63 + http.Error(w, "The bucket you tried to delete is not empty", http.StatusConflict) 64 + return 65 + } 66 + 67 + if err := os.Remove(bucketName); err != nil { 68 + http.Error(w, err.Error(), http.StatusInternalServerError) 69 + return 70 + } 71 + 72 + w.WriteHeader(http.StatusOK) 73 + } 74 + 75 + func cp(w http.ResponseWriter, r *http.Request) { 76 + bucketName := r.PathValue("bucket") 77 + key := r.PathValue("key") 78 + 79 + path := filepath.Join(bucketName, key) 80 + if err := os.MkdirAll(filepath.Dir(path), 0775); err != nil { 81 + http.Error(w, err.Error(), http.StatusInternalServerError) 82 + return 83 + } 84 + 85 + file, err := os.Create(path) 86 + if err != nil { 87 + http.Error(w, err.Error(), http.StatusInternalServerError) 88 + return 89 + } 90 + defer file.Close() 91 + 92 + // TODO: (Claude) "if io.Copy fails, you've already created the file. 93 + // Consider cleaning it up or using os.CreateTemp + os.Rename for atomicity." 94 + if _, err := io.Copy(file, r.Body); err != nil { 95 + http.Error(w, err.Error(), http.StatusInternalServerError) 96 + return 97 + } 98 + w.WriteHeader(http.StatusOK) 99 + } 100 + 101 + func rm(w http.ResponseWriter, r *http.Request) { 102 + bucketName := r.PathValue("bucket") 103 + key := r.PathValue("key") 104 + 105 + // TODO: Dangling empty directory 106 + path := filepath.Join(bucketName, key) 107 + if err := os.Remove(path); err != nil { 108 + http.Error(w, err.Error(), http.StatusInternalServerError) 109 + return 110 + } 111 + 112 + w.WriteHeader(http.StatusOK) 113 + } 114 + 115 + func main() { 116 + // TODO: Prevent path traversal 117 + // TODO: (Claude) "S3 returns ETags for uploaded objects. Consider adding MD5 hashing" 118 + http.HandleFunc("GET /{bucket}", ls) 119 + http.HandleFunc("PUT /{bucket}", mb) 120 + http.HandleFunc("PUT /{bucket}/{key...}", cp) 121 + http.HandleFunc("DELETE /{bucket}", rb) 122 + http.HandleFunc("DELETE /{bucket}/{key...}", rm) 123 + 124 + logger := log.New(os.Stdout, "gos3dir: ", log.LstdFlags) 125 + 126 + handler := addServerHeaders("gos3dir")(logging(logger)(http.DefaultServeMux)) 127 + 128 + logger.Print("Server listening on :8041...") 129 + if err := http.ListenAndServe(":8041", handler); err != nil { 130 + logger.Fatal(err) 131 + } 132 + }