Simple S3-like server for development purposes, written in Go
at main 8.3 kB view raw
1package main 2 3import ( 4 "crypto/md5" 5 "crypto/rand" 6 "encoding/hex" 7 "encoding/xml" 8 "errors" 9 "fmt" 10 "io" 11 "io/fs" 12 "log" 13 "math/big" 14 "net/http" 15 "os" 16 "path/filepath" 17 "strconv" 18 "strings" 19 "time" 20) 21 22type server struct { 23 rootDir string 24 logger *log.Logger 25} 26 27func listBuckets(root *os.Root) (ListAllMyBucketsResult, error) { 28 files, err := fs.ReadDir(root.FS(), ".") 29 if err != nil { 30 return ListAllMyBucketsResult{}, err 31 } 32 33 var buckets []Bucket 34 for _, file := range files { 35 fileInfo, err := file.Info() 36 var lastModified string 37 if err != nil { 38 // Some error retrieving the dir info, setting LastModified to dummy value 39 lastModified = "2000-01-01T00:00:00Z" 40 } else { 41 // NOTE: For actual birth time, see https://github.com/djherbis/times 42 lastModified = fileInfo.ModTime().UTC().Format(time.RFC3339) 43 } 44 buckets = append(buckets, Bucket{ 45 Name: file.Name(), 46 CreationDate: lastModified, 47 }) 48 } 49 result := ListAllMyBucketsResult{ 50 Buckets: Buckets{Buckets: buckets}, 51 } 52 return result, nil 53} 54 55func listObjects(root *os.Root, bucketName string, prefix string) (ListBucketResult, error) { 56 subpath, filter := filepath.Split(prefix) 57 path := filepath.Join(bucketName, subpath) 58 files, err := fs.ReadDir(root.FS(), path) 59 if err != nil { 60 if errors.Is(err, fs.ErrNotExist) { 61 return ListBucketResult{KeyCount: 0, IsTruncated: false, Prefix: prefix}, nil 62 } else { 63 return ListBucketResult{}, err 64 } 65 } 66 67 var objects []Object 68 for _, file := range files { 69 if strings.HasPrefix(file.Name(), filter) { 70 fileInfo, err := file.Info() 71 var lastModified string 72 var objectSize int64 73 if err != nil { 74 // Some error retrieving the file info, setting LastModified to dummy value 75 lastModified = "2000-01-01T00:00:00Z" 76 objectSize = 0 77 } else { 78 lastModified = fileInfo.ModTime().UTC().Format(time.RFC3339) 79 objectSize = fileInfo.Size() 80 } 81 key, _ := filepath.Rel(bucketName, filepath.Join(path, file.Name())) 82 objects = append(objects, Object{ 83 Key: key, 84 LastModified: lastModified, 85 Size: objectSize, 86 }) 87 } 88 } 89 90 result := ListBucketResult{ 91 KeyCount: int64(len(objects)), 92 IsTruncated: false, 93 Prefix: prefix, 94 Contents: objects, 95 } 96 return result, nil 97} 98 99func (s *server) cpGet(w http.ResponseWriter, r *http.Request) { 100 root, err := os.OpenRoot(s.rootDir) 101 if err != nil { 102 http.Error(w, err.Error(), http.StatusInternalServerError) 103 } 104 defer root.Close() 105 106 path := filepath.Join(r.PathValue("bucket"), r.PathValue("key")) 107 fileInfo, err := root.Stat(path) 108 if err != nil { 109 if errors.Is(err, fs.ErrNotExist) { 110 http.Error(w, fmt.Sprintf("Key %s does not exist", r.PathValue("key")), http.StatusNotFound) 111 } else { 112 http.Error(w, err.Error(), http.StatusInternalServerError) 113 } 114 return 115 } 116 117 // NOTE: If the file is huge, we will open it even in HEAD requests, 118 // which shouldn't be needed - but then using http.ServeContent gets trickier 119 file, err := root.Open(path) 120 if err != nil { 121 http.Error(w, err.Error(), http.StatusInternalServerError) 122 } 123 124 http.ServeContent(w, r, fileInfo.Name(), fileInfo.ModTime(), file) 125} 126 127func (s *server) ls(w http.ResponseWriter, r *http.Request) { 128 root, err := os.OpenRoot(s.rootDir) 129 if err != nil { 130 http.Error(w, err.Error(), http.StatusInternalServerError) 131 } 132 defer root.Close() 133 134 bucketName := r.PathValue("bucket") 135 if bucketName == "" { 136 // List available buckets 137 result, err := listBuckets(root) 138 if err != nil { 139 http.Error(w, err.Error(), http.StatusInternalServerError) 140 } 141 142 w.Header().Set("Content-Type", "application/xml") 143 xml.NewEncoder(w).Encode(result) 144 } else { 145 // List objects 146 // But first, check if bucket exists 147 if _, err := root.Stat(bucketName); err != nil { 148 if errors.Is(err, fs.ErrNotExist) { 149 http.Error(w, "The specified bucket does not exist", http.StatusNotFound) 150 return 151 } 152 } 153 154 result, err := listObjects(root, bucketName, r.URL.Query().Get("prefix")) 155 if err != nil { 156 http.Error(w, err.Error(), http.StatusInternalServerError) 157 return 158 } 159 160 w.Header().Set("Content-Type", "application/xml") 161 xml.NewEncoder(w).Encode(result) 162 } 163} 164 165func (s *server) mb(w http.ResponseWriter, r *http.Request) { 166 root, err := os.OpenRoot(s.rootDir) 167 if err != nil { 168 http.Error(w, err.Error(), http.StatusInternalServerError) 169 } 170 defer root.Close() 171 172 path := filepath.Join(r.PathValue("bucket")) 173 174 bucketName := strings.Split(path, "/")[0] 175 if err := root.Mkdir(bucketName, 0775); err != nil { 176 if errors.Is(err, fs.ErrExist) { 177 http.Error(w, "Your previous request to create the named bucket succeeded and you already own it.", http.StatusConflict) 178 } else { 179 http.Error(w, err.Error(), http.StatusInternalServerError) 180 } 181 return 182 } 183 184 w.WriteHeader(http.StatusOK) 185} 186 187func (s *server) rb(w http.ResponseWriter, r *http.Request) { 188 root, err := os.OpenRoot(s.rootDir) 189 if err != nil { 190 http.Error(w, err.Error(), http.StatusInternalServerError) 191 } 192 defer root.Close() 193 194 path := filepath.Join(r.PathValue("bucket")) 195 196 bucketName := strings.Split(path, "/")[0] 197 files, err := fs.ReadDir(root.FS(), bucketName) 198 if err != nil { 199 if errors.Is(err, fs.ErrNotExist) { 200 http.Error(w, "The specified bucket does not exist", http.StatusConflict) 201 } else { 202 http.Error(w, err.Error(), http.StatusInternalServerError) 203 } 204 return 205 } 206 if len(files) > 0 { 207 http.Error(w, "The bucket you tried to delete is not empty", http.StatusConflict) 208 return 209 } 210 211 if err := root.Remove(bucketName); err != nil { 212 http.Error(w, err.Error(), http.StatusInternalServerError) 213 return 214 } 215 216 w.WriteHeader(http.StatusOK) 217} 218 219func (s *server) cpPut(w http.ResponseWriter, r *http.Request) { 220 root, err := os.OpenRoot(s.rootDir) 221 if err != nil { 222 http.Error(w, err.Error(), http.StatusInternalServerError) 223 return 224 } 225 defer root.Close() 226 227 path := filepath.Join(r.PathValue("bucket"), r.PathValue("key")) 228 229 // Prevents `cp` from accidentally creating a new bucket if it doesn't exist 230 bucketName := strings.Split(path, "/")[0] 231 if _, err := root.Stat(bucketName); err != nil { 232 if errors.Is(err, fs.ErrNotExist) { 233 http.Error(w, "The specified bucket does not exist", http.StatusNotFound) 234 } else { 235 http.Error(w, err.Error(), http.StatusInternalServerError) 236 } 237 return 238 } 239 240 randomInt, _ := rand.Int(rand.Reader, big.NewInt(1000)) 241 tempName := fmt.Sprintf(".tmp.%d.%s", randomInt, filepath.Base(path)) 242 tempPath := filepath.Join(bucketName, tempName) 243 244 tempFile, err := root.Create(tempPath) 245 if err != nil { 246 http.Error(w, err.Error(), http.StatusInternalServerError) 247 return 248 } 249 defer root.Remove(tempPath) 250 defer tempFile.Close() 251 252 hasher := md5.New() 253 teeReader := io.TeeReader(r.Body, hasher) 254 255 bytesWritten, err := io.Copy(tempFile, teeReader) 256 if err != nil { 257 http.Error(w, err.Error(), http.StatusInternalServerError) 258 return 259 } 260 // We ignore errors here, if anything, the program will break when renaming 261 tempFile.Sync() 262 tempFile.Close() 263 264 expectedContentLength := r.Header.Get("Content-Length") 265 actualContentLength := strconv.FormatInt(bytesWritten, 10) 266 if expectedContentLength != "" && expectedContentLength != actualContentLength { 267 http.Error( 268 w, 269 fmt.Sprintf( 270 "Expected Content-Length was %s, gor %s", 271 expectedContentLength, 272 actualContentLength, 273 ), 274 http.StatusInternalServerError, 275 ) 276 } 277 278 // Safely create nested directories if needed 279 // TODO: Remove dangling paths if anything fails 280 if err := root.MkdirAll(filepath.Dir(path), 0775); err != nil { 281 http.Error(w, err.Error(), http.StatusInternalServerError) 282 return 283 } 284 285 // Move temporary file to final destination 286 if err := root.Rename(tempPath, path); err != nil { 287 http.Error(w, err.Error(), http.StatusInternalServerError) 288 return 289 } 290 291 etag := fmt.Sprintf("\"%s\"", hex.EncodeToString(hasher.Sum(nil))) 292 w.Header().Set("ETag", etag) 293 294 w.WriteHeader(http.StatusOK) 295} 296 297func (s *server) rm(w http.ResponseWriter, r *http.Request) { 298 root, err := os.OpenRoot(s.rootDir) 299 if err != nil { 300 http.Error(w, err.Error(), http.StatusInternalServerError) 301 } 302 defer root.Close() 303 304 // TODO: Dangling empty directory 305 path := filepath.Join(r.PathValue("bucket"), r.PathValue("key")) 306 if err := root.Remove(path); err != nil { 307 http.Error(w, err.Error(), http.StatusInternalServerError) 308 return 309 } 310 311 w.WriteHeader(http.StatusOK) 312}