rayjlinden |
127 | Gilbert Gilb's |
128 | Christoph Kluge |
129 |
number of items: {{ .Count }}
156 | {{- if .MoreLink }} 157 | more... 158 | {{- end }} 159 | 160 | ` 161 | -------------------------------------------------------------------------------- /browse_test.go: -------------------------------------------------------------------------------- 1 | package caddys3proxy 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "reflect" 7 | "testing" 8 | "time" 9 | 10 | "github.com/aws/aws-sdk-go/aws" 11 | "github.com/aws/aws-sdk-go/service/s3" 12 | ) 13 | 14 | func TestConstructListObjInput(t *testing.T) { 15 | type testCase struct { 16 | name string 17 | key string 18 | bucket string 19 | queryString string 20 | expected s3.ListObjectsV2Input 21 | } 22 | 23 | testCases := []testCase{ 24 | testCase{ 25 | name: "no query options", 26 | bucket: "myBucket", 27 | key: "/mypath/", 28 | expected: s3.ListObjectsV2Input{ 29 | Bucket: aws.String("myBucket"), 30 | Delimiter: aws.String("/"), 31 | Prefix: aws.String("mypath/"), 32 | }, 33 | }, 34 | testCase{ 35 | name: "max option", 36 | bucket: "myBucket", 37 | key: "/mypath/", 38 | queryString: "?max=20", 39 | expected: s3.ListObjectsV2Input{ 40 | Bucket: aws.String("myBucket"), 41 | Delimiter: aws.String("/"), 42 | Prefix: aws.String("mypath/"), 43 | MaxKeys: aws.Int64(20), 44 | }, 45 | }, 46 | testCase{ 47 | name: "max with next", 48 | bucket: "myBucket", 49 | key: "/mypath/", 50 | queryString: "?max=20&next=FOO", 51 | expected: s3.ListObjectsV2Input{ 52 | Bucket: aws.String("myBucket"), 53 | Delimiter: aws.String("/"), 54 | Prefix: aws.String("mypath/"), 55 | MaxKeys: aws.Int64(20), 56 | ContinuationToken: aws.String("FOO"), 57 | }, 58 | }, 59 | } 60 | for _, tc := range testCases { 61 | r := http.Request{} 62 | u, _ := url.Parse(tc.queryString) 63 | r.URL = u 64 | p := S3Proxy{ 65 | Bucket: tc.bucket, 66 | } 67 | result := p.ConstructListObjInput(&r, tc.key) 68 | if !reflect.DeepEqual(tc.expected, result) { 69 | t.Errorf("Expected obj %v, got %v.", tc.expected, result) 70 | } 71 | } 72 | } 73 | 74 | func TestMakePageObj(t *testing.T) { 75 | p := S3Proxy{} 76 | listOutput := s3.ListObjectsV2Output{ 77 | KeyCount: aws.Int64(20), 78 | NextContinuationToken: aws.String("next_token"), 79 | MaxKeys: aws.Int64(20), 80 | CommonPrefixes: []*s3.CommonPrefix{ 81 | &s3.CommonPrefix{ 82 | Prefix: aws.String("/mydir"), 83 | }, 84 | &s3.CommonPrefix{ 85 | Prefix: aws.String("/otherdir"), 86 | }, 87 | }, 88 | Contents: []*s3.Object{ 89 | &s3.Object{ 90 | Key: aws.String("/path/to/myobj"), 91 | Size: aws.Int64(1024), 92 | LastModified: aws.Time(time.Date(1845, time.November, 10, 23, 0, 0, 0, time.UTC)), 93 | }, 94 | }, 95 | } 96 | 97 | result := p.MakePageObj(&listOutput) 98 | expected := PageObj{ 99 | Count: 20, 100 | MoreLink: "?max=20&next=next_token", 101 | Items: []Item{ 102 | Item{ 103 | Url: "./mydir/", 104 | IsDir: true, 105 | Name: "mydir", 106 | }, 107 | Item{ 108 | Url: "./otherdir/", 109 | IsDir: true, 110 | Name: "otherdir", 111 | }, 112 | Item{ 113 | Url: "./myobj", 114 | Key: "/path/to/myobj", 115 | IsDir: false, 116 | Name: "myobj", 117 | Size: "1.0 kB", 118 | LastModified: "a long while ago", 119 | }, 120 | }, 121 | } 122 | 123 | if !reflect.DeepEqual(expected, result) { 124 | t.Errorf("Expected obj %v, got %v.", expected, result) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /caddyfile.go: -------------------------------------------------------------------------------- 1 | package caddys3proxy 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 7 | "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 8 | "github.com/caddyserver/caddy/v2/modules/caddyhttp" 9 | ) 10 | 11 | func init() { 12 | httpcaddyfile.RegisterHandlerDirective("s3proxy", parseCaddyfile) 13 | } 14 | 15 | // parseCaddyfile parses the s3proxy directive. It enables the proxying 16 | // requests to S3 and configures it with this syntax: 17 | // 18 | // s3proxy [This is a silly sample site served by caddy and s3proxy.
9 | EOF 10 | awslocal s3 cp index.html s3://my-bucket/index.html 11 | 12 | awslocal s3 mb s3://test-results 13 | echo "test reports" | awslocal s3 cp - s3://test-results/index.html 14 | echo "resuults for test 1" | awslocal s3 cp - s3://test-results/1/report.txt 15 | echo "resuults for test 2" | awslocal s3 cp - s3://test-results/2/report.txt 16 | echo "resuults for test 57" | awslocal s3 cp - s3://test-results/57/report.txt 17 | echo "resuults for test 784" | awslocal s3 cp - s3://test-results/784/report.txt 18 | 19 | awslocal s3 mb s3://bkt 20 | echo "CAT" | awslocal s3 cp - s3://bkt/a/long/path/we/have/for/animals/cat.txt 21 | echo "DOG" | awslocal s3 cp - s3://bkt/a/long/path/we/have/for/animals/dog.txt 22 | echo "COW" | awslocal s3 cp - s3://bkt/a/long/path/we/have/for/animals/cow.txt 23 | echo "BAT" | awslocal s3 cp - s3://bkt/a/long/path/we/have/for/animals/bat.txt 24 | set +x 25 | 26 | -------------------------------------------------------------------------------- /example/docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | version: '2.1' 3 | 4 | services: 5 | localstack: 6 | image: localstack/localstack 7 | ports: 8 | - "4566-4599:4566-4599" 9 | environment: 10 | - SERVICES=s3 11 | - DEBUG=1 12 | volumes: 13 | - ./awslocal:/docker-entrypoint-initaws.d 14 | caddy: 15 | image: caddy 16 | volumes: 17 | - ./Caddyfile:/Caddyfile 18 | ports: 19 | - "80:80" 20 | links: 21 | - localstack 22 | command: caddy run --config /Caddyfile 23 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lindenlab/caddy-s3-proxy 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/alecthomas/chroma v0.8.2 // indirect 7 | github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 // indirect 8 | github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect 9 | github.com/aws/aws-sdk-go v1.44.272 10 | github.com/caddyserver/caddy/v2 v2.6.4 11 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 12 | github.com/dustin/go-humanize v1.0.1 13 | github.com/google/cel-go v0.13.0 // indirect 14 | github.com/google/cel-spec v0.4.0 // indirect 15 | github.com/jsternberg/zap-logfmt v1.2.0 // indirect 16 | github.com/klauspost/cpuid v1.3.1 // indirect 17 | github.com/lucas-clemente/quic-go v0.19.3 // indirect 18 | github.com/naoina/go-stringutil v0.1.0 // indirect 19 | github.com/naoina/toml v0.1.1 // indirect 20 | github.com/smallstep/certificates v0.23.2 // indirect 21 | github.com/smallstep/cli v0.15.2 // indirect 22 | github.com/spf13/cobra v1.7.0 // indirect 23 | github.com/yuin/goldmark-highlighting v0.0.0-20200307114337-60d527fdb691 // indirect 24 | go.step.sm/crypto v0.23.2 // indirect 25 | go.step.sm/linkedca v0.19.1 // indirect 26 | go.uber.org/multierr v1.6.0 // indirect 27 | go.uber.org/zap v1.24.0 28 | gopkg.in/yaml.v3 v3.0.1 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /s3proxy.go: -------------------------------------------------------------------------------- 1 | package caddys3proxy 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "html/template" 8 | "io" 9 | "io/ioutil" 10 | "net/http" 11 | "path" 12 | "path/filepath" 13 | "reflect" 14 | "strings" 15 | "time" 16 | 17 | "github.com/aws/aws-sdk-go/aws" 18 | "github.com/aws/aws-sdk-go/aws/awserr" 19 | "github.com/aws/aws-sdk-go/aws/session" 20 | "github.com/aws/aws-sdk-go/service/s3" 21 | caddy "github.com/caddyserver/caddy/v2" 22 | "github.com/caddyserver/caddy/v2/modules/caddyhttp" 23 | "go.uber.org/zap" 24 | ) 25 | 26 | var defaultIndexNames = []string{"index.html", "index.txt"} 27 | 28 | func init() { 29 | caddy.RegisterModule(S3Proxy{}) 30 | } 31 | 32 | // S3Proxy implements a proxy to return, set, delete or browse objects from S3 33 | type S3Proxy struct { 34 | // The path to the root of the site. Default is `{http.vars.root}` if set, 35 | // Or if not set the value is "" - meaning use the whole path as a key. 36 | Root string `json:"root,omitempty"` 37 | 38 | // The AWS region the bucket is hosted in 39 | Region string `json:"region,omitempty"` 40 | 41 | // The AWS profile to use if mulitple profiles are specified in creds 42 | Profile string `json:"profile,omitempty"` 43 | 44 | // The name of the S3 bucket 45 | Bucket string `json:"bucket,omitempty"` 46 | 47 | // Use non-standard endpoint for S3 48 | Endpoint string `json:"endpoint,omitempty"` 49 | 50 | // The names of files to try as index files if a folder is requested. 51 | IndexNames []string `json:"index_names,omitempty"` 52 | 53 | // A glob pattern used to hide matching key paths (returning a 404) 54 | Hide []string 55 | 56 | // Flag to determine if PUT operations are allowed (default false) 57 | EnablePut bool 58 | 59 | // Flag to determine if DELETE operations are allowed (default false) 60 | EnableDelete bool 61 | 62 | // Flag to enable browsing of "directories" in S3 (paths that end with a /) 63 | EnableBrowse bool 64 | 65 | // Path to a template file to use for generating browse dir html page 66 | BrowseTemplate string 67 | 68 | // Mapping of HTTP error status to S3 keys or pass through option. 69 | ErrorPages map[int]string `json:"error_pages,omitempty"` 70 | 71 | // S3 key to a default error page or pass through option. 72 | DefaultErrorPage string `json:"default_error_page,omitempty"` 73 | 74 | // Set this to `true` to force the request to use path-style addressing. 75 | S3ForcePathStyle bool `json:"force_path_style,omitempty"` 76 | 77 | // Set this to `true` to enable S3 Accelerate feature. 78 | S3UseAccelerate bool `json:"use_accelerate,omitempty"` 79 | 80 | client *s3.S3 81 | dirTemplate *template.Template 82 | log *zap.Logger 83 | } 84 | 85 | // CaddyModule returns the Caddy module information. 86 | func (S3Proxy) CaddyModule() caddy.ModuleInfo { 87 | return caddy.ModuleInfo{ 88 | ID: "http.handlers.s3proxy", 89 | New: func() caddy.Module { return new(S3Proxy) }, 90 | } 91 | } 92 | 93 | func (p *S3Proxy) Provision(ctx caddy.Context) (err error) { 94 | p.log = ctx.Logger(p) 95 | 96 | if p.Root == "" { 97 | p.Root = "{http.vars.root}" 98 | } 99 | 100 | if p.IndexNames == nil { 101 | p.IndexNames = defaultIndexNames 102 | } 103 | 104 | if p.ErrorPages == nil { 105 | p.ErrorPages = make(map[int]string) 106 | } 107 | 108 | if p.EnableBrowse { 109 | var tpl *template.Template 110 | var err error 111 | 112 | if p.BrowseTemplate != "" { 113 | tpl, err = template.ParseFiles(p.BrowseTemplate) 114 | if err != nil { 115 | return fmt.Errorf("parsing browse template file: %v", err) 116 | } 117 | } else { 118 | tpl, err = template.New("default_listing").Parse(defaultBrowseTemplate) 119 | if err != nil { 120 | return fmt.Errorf("parsing default browse template: %v", err) 121 | } 122 | } 123 | p.dirTemplate = tpl 124 | } 125 | 126 | var config aws.Config 127 | 128 | // If Region is not specified NewSession will look for it from an env value AWS_REGION 129 | if p.Region != "" { 130 | config.Region = aws.String(p.Region) 131 | } 132 | 133 | if p.Endpoint != "" { 134 | config.Endpoint = aws.String(p.Endpoint) 135 | } 136 | 137 | if p.S3ForcePathStyle { 138 | config.S3ForcePathStyle = aws.Bool(p.S3ForcePathStyle) 139 | } 140 | 141 | if p.S3UseAccelerate { 142 | config.S3UseAccelerate = aws.Bool(p.S3UseAccelerate) 143 | } 144 | 145 | sess, err := session.NewSessionWithOptions(session.Options{ 146 | Profile: p.Profile, 147 | Config: config, 148 | SharedConfigState: session.SharedConfigEnable, 149 | }) 150 | if err != nil { 151 | p.log.Error("could not create AWS session", 152 | zap.String("error", err.Error()), 153 | ) 154 | return err 155 | } 156 | 157 | // Create S3 service client 158 | p.client = s3.New(sess) 159 | p.log.Info("S3 proxy initialized for bucket: " + p.Bucket) 160 | p.log.Debug("config values", 161 | zap.String("endpoint", p.Endpoint), 162 | zap.String("region", p.Region), 163 | zap.String("profile", p.Profile), 164 | zap.Bool("enable_put", p.EnablePut), 165 | zap.Bool("enable_delete", p.EnableDelete), 166 | zap.String("default_error_page", p.DefaultErrorPage), 167 | zap.Bool("enable_browse", p.EnableBrowse), 168 | zap.Bool("force_path_style", p.S3ForcePathStyle), 169 | zap.Bool("use_accelerate", p.S3UseAccelerate), 170 | ) 171 | 172 | return nil 173 | } 174 | 175 | func (p S3Proxy) getS3Object(bucket string, path string, headers http.Header) (*s3.GetObjectOutput, error) { 176 | oi := &s3.GetObjectInput{ 177 | Bucket: aws.String(bucket), 178 | Key: aws.String(path), 179 | } 180 | 181 | if rg := headers.Get("Range"); rg != "" { 182 | oi = oi.SetRange(rg) 183 | } 184 | if ifMatch := headers.Get("If-Match"); ifMatch != "" { 185 | oi = oi.SetIfMatch(ifMatch) 186 | } 187 | if ifNoneMatch := headers.Get("If-None-Match"); ifNoneMatch != "" { 188 | oi = oi.SetIfNoneMatch(ifNoneMatch) 189 | } 190 | if ifModifiedSince := headers.Get("If-Modified-Since"); ifModifiedSince != "" { 191 | t, err := time.Parse(http.TimeFormat, ifModifiedSince) 192 | if err == nil { 193 | oi = oi.SetIfModifiedSince(t) 194 | } 195 | } 196 | if ifUnmodifiedSince := headers.Get("If-Unmodified-Since"); ifUnmodifiedSince != "" { 197 | t, err := time.Parse(http.TimeFormat, ifUnmodifiedSince) 198 | if err == nil { 199 | oi = oi.SetIfUnmodifiedSince(t) 200 | } 201 | } 202 | 203 | p.log.Debug("get from S3", 204 | zap.String("bucket", bucket), 205 | zap.String("key", path), 206 | ) 207 | 208 | // TODO: GetObject could return the aws error InternalError, if that happens it is best practice to retry the 209 | // the call. That retry logic should go here... 210 | return p.client.GetObject(oi) 211 | } 212 | 213 | func joinPath(root string, uriPath string) string { 214 | isDir := uriPath[len(uriPath)-1:] == "/" 215 | newPath := path.Join(root, uriPath) 216 | if isDir && newPath != "/" { 217 | // Join will strip the ending / 218 | // add it back if it was there as it implies a dir view 219 | return newPath + "/" 220 | } 221 | return newPath 222 | } 223 | 224 | func makeAwsString(str string) *string { 225 | if str == "" { 226 | return nil 227 | } 228 | return aws.String(str) 229 | } 230 | 231 | func (p S3Proxy) PutHandler(w http.ResponseWriter, r *http.Request, key string) error { 232 | isDir := strings.HasSuffix(key, "/") 233 | if isDir || !p.EnablePut { 234 | err := errors.New("method not allowed") 235 | return caddyhttp.Error(http.StatusMethodNotAllowed, err) 236 | } 237 | 238 | // The request gives us r.Body a ReadCloser. However, Put needs a ReadSeeker. 239 | // So we need to read the entire object in memory and create the ReadSeeker. 240 | // TODO: this will not work well for very large files - will run out of memory 241 | buf, err := ioutil.ReadAll(r.Body) 242 | if err != nil { 243 | return convertToCaddyError(err) 244 | } 245 | 246 | oi := s3.PutObjectInput{ 247 | Bucket: aws.String(p.Bucket), 248 | Key: aws.String(key), 249 | CacheControl: makeAwsString(r.Header.Get("Cache-Control")), 250 | ContentDisposition: makeAwsString(r.Header.Get("Content-Disposition")), 251 | ContentEncoding: makeAwsString(r.Header.Get("Content-Encoding")), 252 | ContentLanguage: makeAwsString(r.Header.Get("Content-Language")), 253 | ContentType: makeAwsString(r.Header.Get("Content-Type")), 254 | Body: bytes.NewReader(buf), 255 | } 256 | po, err := p.client.PutObject(&oi) 257 | if err != nil { 258 | return convertToCaddyError(err) 259 | } 260 | 261 | setStrHeader(w, "ETag", po.ETag) 262 | 263 | return nil 264 | } 265 | 266 | func (p S3Proxy) DeleteHandler(w http.ResponseWriter, r *http.Request, key string) error { 267 | isDir := strings.HasSuffix(key, "/") 268 | if isDir || !p.EnableDelete { 269 | err := errors.New("method not allowed") 270 | return caddyhttp.Error(http.StatusMethodNotAllowed, err) 271 | } 272 | 273 | di := s3.DeleteObjectInput{ 274 | Bucket: aws.String(p.Bucket), 275 | Key: aws.String(key), 276 | } 277 | _, err := p.client.DeleteObject(&di) 278 | if err != nil { 279 | return convertToCaddyError(err) 280 | } 281 | 282 | return nil 283 | } 284 | 285 | func (p S3Proxy) BrowseHandler(w http.ResponseWriter, r *http.Request, key string) error { 286 | 287 | input := p.ConstructListObjInput(r, key) 288 | 289 | result, err := p.client.ListObjectsV2(&input) 290 | if err != nil { 291 | p.log.Debug("error in ListObjectsV2", 292 | zap.String("bucket", p.Bucket), 293 | zap.String("key", key), 294 | zap.String("err", err.Error()), 295 | ) 296 | return convertToCaddyError(err) 297 | } 298 | 299 | pageObj := p.MakePageObj(result) 300 | 301 | if r.Header.Get("Content-type") == "application/json" { 302 | // Give JSON output of dir 303 | err = pageObj.GenerateJson(w) 304 | } else { 305 | // Generate html response of dir 306 | err = pageObj.GenerateHtml(w, p.dirTemplate) 307 | } 308 | if err != nil { 309 | return convertToCaddyError(err) 310 | } 311 | return nil 312 | } 313 | 314 | func (p S3Proxy) writeResponseFromGetObject(w http.ResponseWriter, obj *s3.GetObjectOutput) error { 315 | // Copy headers from AWS response to our response 316 | setStrHeader(w, "Cache-Control", obj.CacheControl) 317 | setStrHeader(w, "Content-Disposition", obj.ContentDisposition) 318 | setStrHeader(w, "Content-Encoding", obj.ContentEncoding) 319 | setStrHeader(w, "Content-Language", obj.ContentLanguage) 320 | setStrHeader(w, "Content-Range", obj.ContentRange) 321 | setStrHeader(w, "Content-Type", obj.ContentType) 322 | setStrHeader(w, "ETag", obj.ETag) 323 | setStrHeader(w, "Expires", obj.Expires) 324 | setTimeHeader(w, "Last-Modified", obj.LastModified) 325 | 326 | // Adds all custom headers which where used on this object 327 | for key, value := range obj.Metadata { 328 | setStrHeader(w, key, value) 329 | } 330 | 331 | var err error 332 | if obj.Body != nil { 333 | // io.Copy will set Content-Length 334 | w.Header().Del("Content-Length") 335 | _, err = io.Copy(w, obj.Body) 336 | } 337 | 338 | return err 339 | } 340 | 341 | func (p S3Proxy) serveErrorPage(w http.ResponseWriter, s3Key string) error { 342 | obj, err := p.getS3Object(p.Bucket, s3Key, nil) 343 | if err != nil { 344 | return err 345 | } 346 | 347 | if err := p.writeResponseFromGetObject(w, obj); err != nil { 348 | return err 349 | } 350 | 351 | return nil 352 | } 353 | 354 | // ServeHTTP implements the main entry point for a request for the caddyhttp.Handler interface. 355 | func (p S3Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { 356 | repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) 357 | 358 | fullPath := joinPath(repl.ReplaceAll(p.Root, ""), r.URL.Path) 359 | 360 | var err error 361 | switch r.Method { 362 | case http.MethodGet: 363 | err = p.GetHandler(w, r, fullPath) 364 | case http.MethodPut: 365 | err = p.PutHandler(w, r, fullPath) 366 | case http.MethodDelete: 367 | err = p.DeleteHandler(w, r, fullPath) 368 | default: 369 | err = caddyhttp.Error(http.StatusMethodNotAllowed, errors.New("method not allowed")) 370 | } 371 | if err == nil { 372 | // Success! 373 | return nil 374 | } 375 | 376 | // Make the err a caddyErr if it is not already 377 | caddyErr, isCaddyErr := err.(caddyhttp.HandlerError) 378 | if !isCaddyErr { 379 | caddyErr = caddyhttp.Error(http.StatusInternalServerError, err) 380 | } 381 | 382 | // If non OK status code - WriteHeader - except for GET method, where we still need to process more 383 | if r.Method != http.MethodGet { 384 | if caddyErr.StatusCode != 0 { 385 | w.WriteHeader(caddyErr.StatusCode) 386 | } 387 | return caddyErr 388 | } 389 | 390 | // Certain errors we will not pass through 391 | if caddyErr.StatusCode == http.StatusNotModified || 392 | caddyErr.StatusCode == http.StatusPreconditionFailed || 393 | caddyErr.StatusCode == http.StatusRequestedRangeNotSatisfiable { 394 | w.WriteHeader(caddyErr.StatusCode) 395 | return caddyErr 396 | } 397 | 398 | // process errors directive 399 | doPassThrough, doS3ErrorPage, s3Key := p.determineErrorsAction(caddyErr.StatusCode) 400 | if doPassThrough { 401 | return next.ServeHTTP(w, r) 402 | } 403 | 404 | if caddyErr.StatusCode != 0 { 405 | w.WriteHeader(caddyErr.StatusCode) 406 | } 407 | if doS3ErrorPage { 408 | if err := p.serveErrorPage(w, s3Key); err != nil { 409 | // Just log the error as we don't want to swallow the parent error. 410 | p.log.Error("error serving error page", 411 | zap.String("bucket", p.Bucket), 412 | zap.String("key", s3Key), 413 | zap.String("err", err.Error()), 414 | ) 415 | } 416 | } 417 | return caddyErr 418 | } 419 | 420 | func (p S3Proxy) determineErrorsAction(statusCode int) (bool, bool, string) { 421 | var s3Key string 422 | if errorPageS3Key, hasErrorPageForCode := p.ErrorPages[statusCode]; hasErrorPageForCode { 423 | s3Key = errorPageS3Key 424 | } else if p.DefaultErrorPage != "" { 425 | s3Key = p.DefaultErrorPage 426 | } 427 | 428 | if strings.ToLower(s3Key) == "pass_through" { 429 | return true, false, "" 430 | } 431 | 432 | return false, s3Key != "", s3Key 433 | } 434 | 435 | func (p S3Proxy) GetHandler(w http.ResponseWriter, r *http.Request, fullPath string) error { 436 | // If file is hidden - return 404 437 | if fileHidden(fullPath, p.Hide) { 438 | return caddyhttp.Error(http.StatusNotFound, nil) 439 | } 440 | 441 | isDir := strings.HasSuffix(fullPath, "/") 442 | var obj *s3.GetObjectOutput 443 | var err error 444 | 445 | if isDir && len(p.IndexNames) > 0 { 446 | for _, indexPage := range p.IndexNames { 447 | indexPath := path.Join(fullPath, indexPage) 448 | obj, err = p.getS3Object(p.Bucket, indexPath, r.Header) 449 | caddyErr := convertToCaddyError(err) 450 | if err == nil || caddyErr.StatusCode == 304 { 451 | // We found an index! 452 | isDir = false 453 | break 454 | } else { 455 | logIt := true 456 | if aerr, ok := err.(awserr.Error); ok { 457 | // Getting no such key here could be rather common 458 | // So only log a warning if we get any other type of error 459 | if aerr.Code() != s3.ErrCodeNoSuchKey { 460 | logIt = false 461 | } 462 | } 463 | if logIt { 464 | p.log.Warn("error when looking for index", 465 | zap.String("bucket", p.Bucket), 466 | zap.String("key", fullPath), 467 | zap.String("err", err.Error()), 468 | ) 469 | } 470 | } 471 | } 472 | } 473 | 474 | // If this is still a dir then browse or throw an error 475 | if isDir { 476 | if p.EnableBrowse { 477 | return p.BrowseHandler(w, r, fullPath) 478 | } else { 479 | err = errors.New("can not view a directory") 480 | return caddyhttp.Error(http.StatusForbidden, err) 481 | } 482 | } 483 | 484 | // Get the obj from S3 (skip if we already did when looking for an index) 485 | if obj == nil { 486 | obj, err = p.getS3Object(p.Bucket, fullPath, r.Header) 487 | } 488 | if err != nil { 489 | caddyErr := convertToCaddyError(err) 490 | if caddyErr.StatusCode == http.StatusNotFound { 491 | // Log as debug as this one may be quite common 492 | p.log.Debug("not found", 493 | zap.String("bucket", p.Bucket), 494 | zap.String("key", fullPath), 495 | zap.String("err", caddyErr.Error()), 496 | ) 497 | } else { 498 | p.log.Error("failed to get object", 499 | zap.String("bucket", p.Bucket), 500 | zap.String("key", fullPath), 501 | zap.String("err", caddyErr.Error()), 502 | ) 503 | } 504 | 505 | return caddyErr 506 | } 507 | 508 | return p.writeResponseFromGetObject(w, obj) 509 | } 510 | 511 | func setStrHeader(w http.ResponseWriter, key string, value *string) { 512 | if value != nil && len(*value) > 0 { 513 | w.Header().Add(key, *value) 514 | } 515 | } 516 | 517 | func setTimeHeader(w http.ResponseWriter, key string, value *time.Time) { 518 | if value != nil && !reflect.DeepEqual(*value, time.Time{}) { 519 | w.Header().Add(key, value.UTC().Format(http.TimeFormat)) 520 | } 521 | } 522 | 523 | // fileHidden returns true if filename is hidden 524 | // according to the hide list. 525 | func fileHidden(filename string, hide []string) bool { 526 | sep := string(filepath.Separator) 527 | var components []string 528 | 529 | for _, h := range hide { 530 | if !strings.Contains(h, sep) { 531 | // if there is no separator in h, then we assume the user 532 | // wants to hide any files or folders that match that 533 | // name; thus we have to compare against each component 534 | // of the filename, e.g. hiding "bar" would hide "/bar" 535 | // as well as "/foo/bar/baz" but not "/barstool". 536 | if len(components) == 0 { 537 | components = strings.Split(filename, sep) 538 | } 539 | for _, c := range components { 540 | if c == h { 541 | return true 542 | } 543 | } 544 | } else if strings.HasPrefix(filename, h) { 545 | // otherwise, if there is a separator in h, and 546 | // filename is exactly prefixed with h, then we 547 | // can do a prefix match so that "/foo" matches 548 | // "/foo/bar" but not "/foobar". 549 | withoutPrefix := strings.TrimPrefix(filename, h) 550 | if strings.HasPrefix(withoutPrefix, sep) { 551 | return true 552 | } 553 | } 554 | 555 | // in the general case, a glob match will suffice 556 | if hidden, _ := filepath.Match(h, filename); hidden { 557 | return true 558 | } 559 | } 560 | 561 | return false 562 | } 563 | -------------------------------------------------------------------------------- /s3proxy_test.go: -------------------------------------------------------------------------------- 1 | package caddys3proxy 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "math/rand" 9 | "mime" 10 | "net/http" 11 | "net/http/httptest" 12 | "os" 13 | "path/filepath" 14 | "reflect" 15 | "strings" 16 | "testing" 17 | "time" 18 | 19 | "go.uber.org/zap" 20 | 21 | "github.com/aws/aws-sdk-go/aws/awserr" 22 | caddy "github.com/caddyserver/caddy/v2" 23 | 24 | "github.com/aws/aws-sdk-go/aws" 25 | "github.com/aws/aws-sdk-go/aws/session" 26 | "github.com/aws/aws-sdk-go/service/s3" 27 | ) 28 | 29 | type jTestCase struct { 30 | root string 31 | path string 32 | expected string 33 | } 34 | 35 | func TestJoinPath(t *testing.T) { 36 | testCases := []jTestCase{ 37 | jTestCase{ 38 | root: "", 39 | path: "/foo", 40 | expected: "/foo", 41 | }, 42 | jTestCase{ 43 | root: "", 44 | path: "/", 45 | expected: "/", 46 | }, 47 | jTestCase{ 48 | root: "/", 49 | path: "/", 50 | expected: "/", 51 | }, 52 | jTestCase{ 53 | root: "/", 54 | path: "/foo", 55 | expected: "/foo", 56 | }, 57 | jTestCase{ 58 | root: "/cat", 59 | path: "/dog", 60 | expected: "/cat/dog", 61 | }, 62 | jTestCase{ 63 | root: "/cat/", 64 | path: "/dog", 65 | expected: "/cat/dog", 66 | }, 67 | jTestCase{ 68 | root: "/cat/", 69 | path: "/dog/", 70 | expected: "/cat/dog/", 71 | }, 72 | jTestCase{ 73 | root: "", 74 | path: "/dog/", 75 | expected: "/dog/", 76 | }, 77 | } 78 | for _, tc := range testCases { 79 | r := joinPath(tc.root, tc.path) 80 | if r != tc.expected { 81 | t.Errorf("When joining '%s' and '%s' we expected '%s' but got '%s'", tc.root, tc.path, tc.expected, r) 82 | } 83 | } 84 | } 85 | 86 | func TestFileHidden(t *testing.T) { 87 | for i, tc := range []struct { 88 | inputHide []string 89 | inputPath string 90 | expect bool 91 | }{ 92 | { 93 | inputHide: nil, 94 | inputPath: "", 95 | expect: false, 96 | }, 97 | { 98 | inputHide: []string{".gitignore"}, 99 | inputPath: "/.gitignore", 100 | expect: true, 101 | }, 102 | { 103 | inputHide: []string{".git"}, 104 | inputPath: "/.gitignore", 105 | expect: false, 106 | }, 107 | { 108 | inputHide: []string{"/.git"}, 109 | inputPath: "/.gitignore", 110 | expect: false, 111 | }, 112 | { 113 | inputHide: []string{".git"}, 114 | inputPath: "/.git", 115 | expect: true, 116 | }, 117 | { 118 | inputHide: []string{".git"}, 119 | inputPath: "/.git/foo", 120 | expect: true, 121 | }, 122 | { 123 | inputHide: []string{".git"}, 124 | inputPath: "/foo/.git/bar", 125 | expect: true, 126 | }, 127 | { 128 | inputHide: []string{"/prefix"}, 129 | inputPath: "/prefix/foo", 130 | expect: true, 131 | }, 132 | { 133 | inputHide: []string{"/foo/*/bar"}, 134 | inputPath: "/foo/asdf/bar", 135 | expect: true, 136 | }, 137 | { 138 | inputHide: []string{"/foo"}, 139 | inputPath: "/foo", 140 | expect: true, 141 | }, 142 | { 143 | inputHide: []string{"/foo"}, 144 | inputPath: "/foobar", 145 | expect: false, 146 | }, 147 | } { 148 | // for Windows' sake 149 | tc.inputPath = filepath.FromSlash(tc.inputPath) 150 | for i := range tc.inputHide { 151 | tc.inputHide[i] = filepath.FromSlash(tc.inputHide[i]) 152 | } 153 | 154 | actual := fileHidden(tc.inputPath, tc.inputHide) 155 | if actual != tc.expect { 156 | t.Errorf("Test %d: Is %s hidden in %v? Got %t but expected %t", 157 | i, tc.inputPath, tc.inputHide, actual, tc.expect) 158 | } 159 | } 160 | } 161 | 162 | func newS3Client(t *testing.T) *s3.S3 { 163 | endpoint := os.Getenv("AWS_ENDPOINT") 164 | if endpoint == "" { 165 | t.Skip("Skipping test because AWS_ENDPOINT environment variable is not set.") 166 | } 167 | 168 | config := aws.Config{ 169 | S3ForcePathStyle: aws.Bool(true), 170 | Endpoint: aws.String(endpoint), 171 | } 172 | 173 | sess, err := session.NewSession(&config) 174 | if err != nil { 175 | t.Fatal(err) 176 | } 177 | 178 | return s3.New(sess) 179 | } 180 | 181 | func setupTestBucket(t *testing.T, client *s3.S3) string { 182 | bucketName := fmt.Sprintf( 183 | "caddy-s3-proxy-testdata-%d-%d", 184 | time.Now().UnixNano(), 185 | rand.Int(), 186 | ) 187 | testDataDir := "testdata" 188 | 189 | _, err := client.CreateBucket(&s3.CreateBucketInput{ 190 | Bucket: aws.String(bucketName), 191 | }) 192 | if awsErr, isAwsErr := err.(awserr.Error); isAwsErr { 193 | if awsErr.Code() == s3.ErrCodeBucketAlreadyExists { 194 | err = nil 195 | } 196 | } 197 | if err != nil { 198 | t.Fatal(err) 199 | } 200 | 201 | if err := filepath.Walk(testDataDir, func(p string, info os.FileInfo, err error) error { 202 | if info.IsDir() { 203 | return nil 204 | } 205 | 206 | if err != nil { 207 | return err 208 | } 209 | 210 | key := strings.TrimPrefix(p, testDataDir) 211 | contentType := mime.TypeByExtension(filepath.Ext(p)) 212 | if contentType == "" { 213 | contentType = "application/octet-stream" 214 | } 215 | 216 | file, err := os.Open(p) 217 | if err != nil { 218 | return err 219 | } 220 | defer file.Close() 221 | 222 | if _, err := client.PutObject(&s3.PutObjectInput{ 223 | Bucket: aws.String(bucketName), 224 | Key: aws.String(key), 225 | ContentType: aws.String(contentType), 226 | Body: file, 227 | }); err != nil { 228 | return err 229 | } 230 | 231 | return nil 232 | }); err != nil { 233 | t.Fatal(err) 234 | } 235 | 236 | return bucketName 237 | } 238 | 239 | func TestProxy(t *testing.T) { 240 | client := newS3Client(t) 241 | bucketName := setupTestBucket(t, client) 242 | 243 | for _, tc := range []struct { 244 | name string 245 | proxy S3Proxy 246 | method string 247 | body []byte 248 | headers http.Header 249 | path string 250 | expectedCode int 251 | expectedHeaders http.Header 252 | expectedResponseText string 253 | expectsEmptyResponse bool 254 | }{ 255 | { 256 | name: "can get simple JSON object", 257 | proxy: S3Proxy{Bucket: bucketName}, 258 | method: http.MethodGet, 259 | path: "/test.json", 260 | expectedCode: http.StatusOK, 261 | expectedResponseText: `{"foo": "bar"}`, 262 | expectedHeaders: http.Header{ 263 | "Content-Type": []string{"application/json"}, 264 | }, 265 | }, 266 | { 267 | name: "hidden file are not served", 268 | proxy: S3Proxy{Bucket: bucketName, Hide: []string{"test.json"}}, 269 | method: http.MethodGet, 270 | path: "/test.json", 271 | expectedCode: http.StatusNotFound, 272 | expectsEmptyResponse: true, 273 | }, 274 | { 275 | name: "can't post", 276 | proxy: S3Proxy{Bucket: bucketName}, 277 | method: http.MethodPost, 278 | path: "/cannot-post", 279 | expectedCode: http.StatusMethodNotAllowed, 280 | expectsEmptyResponse: true, 281 | }, 282 | { 283 | name: "can't delete if not allowed", 284 | proxy: S3Proxy{Bucket: bucketName}, 285 | method: http.MethodDelete, 286 | path: "/cannot-delete", 287 | expectedCode: http.StatusMethodNotAllowed, 288 | expectsEmptyResponse: true, 289 | }, 290 | { 291 | name: "can delete if allowed", 292 | proxy: S3Proxy{Bucket: bucketName, EnableDelete: true}, 293 | method: http.MethodDelete, 294 | path: "/to-delete.json", 295 | expectedCode: http.StatusOK, 296 | expectsEmptyResponse: true, 297 | }, 298 | { 299 | name: "can't put if not allowed", 300 | proxy: S3Proxy{Bucket: bucketName}, 301 | method: http.MethodPut, 302 | path: "/cannot-put", 303 | expectedCode: http.StatusMethodNotAllowed, 304 | expectsEmptyResponse: true, 305 | }, 306 | { 307 | name: "can put if allowed", 308 | proxy: S3Proxy{Bucket: bucketName, EnablePut: true}, 309 | method: http.MethodPut, 310 | path: "/can-put", 311 | body: []byte("some content"), 312 | expectedCode: http.StatusOK, 313 | expectsEmptyResponse: true, 314 | }, 315 | { 316 | name: "serves index.html", 317 | proxy: S3Proxy{Bucket: bucketName, IndexNames: []string{"index.html"}}, 318 | method: http.MethodGet, 319 | path: "/inner/", 320 | expectedCode: http.StatusOK, 321 | expectedResponseText: "my index.html", 322 | }, 323 | { 324 | name: "returns 304 If-None-Match on index", 325 | proxy: S3Proxy{Bucket: bucketName, IndexNames: []string{"index.html"}}, 326 | method: http.MethodGet, 327 | path: "/inner/", 328 | headers: http.Header{ 329 | "If-None-Match": []string{`"44bacca965de5aef310706cc55c4a7b0"`}, 330 | }, 331 | expectedCode: http.StatusNotModified, 332 | expectsEmptyResponse: true, 333 | }, 334 | { 335 | name: "cannot browse", 336 | proxy: S3Proxy{Bucket: bucketName}, 337 | method: http.MethodGet, 338 | path: "/inner/", 339 | expectedCode: http.StatusForbidden, 340 | expectsEmptyResponse: true, 341 | }, 342 | { 343 | name: "returns 404 if not found", 344 | proxy: S3Proxy{Bucket: bucketName}, 345 | method: http.MethodGet, 346 | path: "/doesnt-exist", 347 | expectedCode: http.StatusNotFound, 348 | }, 349 | { 350 | name: "returns 404 page if 404 error page is set", 351 | proxy: S3Proxy{ 352 | Bucket: bucketName, 353 | ErrorPages: map[int]string{404: "_404.txt"}, 354 | DefaultErrorPage: "default_error_page.txt", 355 | }, 356 | method: http.MethodGet, 357 | path: "/doesnt-exist", 358 | expectedCode: http.StatusNotFound, 359 | expectedResponseText: `this is 404`, 360 | }, 361 | { 362 | name: "returns default page if default error page is set", 363 | proxy: S3Proxy{ 364 | Bucket: bucketName, 365 | DefaultErrorPage: "default_error_page.txt", 366 | }, 367 | method: http.MethodGet, 368 | path: "/doesnt-exist", 369 | expectedCode: http.StatusNotFound, 370 | expectedResponseText: `this is a default error page`, 371 | }, 372 | { 373 | name: "returns range", 374 | proxy: S3Proxy{Bucket: bucketName}, 375 | method: http.MethodGet, 376 | path: "/test.json", 377 | headers: http.Header{ 378 | "Range": []string{"bytes=0-4"}, 379 | }, 380 | expectedCode: http.StatusOK, 381 | expectedResponseText: `{"foo`, 382 | }, 383 | { 384 | name: "returns 200 code If-Match", 385 | proxy: S3Proxy{Bucket: bucketName}, 386 | method: http.MethodGet, 387 | path: "/test.json", 388 | headers: http.Header{ 389 | "If-Match": []string{`"a38212e01d6f419c9bd303b304a99e9b"`}, 390 | }, 391 | expectedCode: http.StatusOK, 392 | expectedResponseText: `{"foo": "bar"}`, 393 | }, 394 | { 395 | name: "returns 412 If-Match", 396 | proxy: S3Proxy{Bucket: bucketName}, 397 | method: http.MethodGet, 398 | path: "/test.json", 399 | headers: http.Header{ 400 | "If-Match": []string{`"no good etag"`}, 401 | }, 402 | expectedCode: http.StatusPreconditionFailed, 403 | expectsEmptyResponse: true, 404 | }, 405 | { 406 | name: "returns 304 If-None-Match", 407 | proxy: S3Proxy{Bucket: bucketName}, 408 | method: http.MethodGet, 409 | path: "/test.json", 410 | headers: http.Header{ 411 | "If-None-Match": []string{`"a38212e01d6f419c9bd303b304a99e9b"`}, 412 | }, 413 | expectedCode: http.StatusNotModified, 414 | expectsEmptyResponse: true, 415 | }, 416 | { 417 | name: "returns 200 If-None-Match", 418 | proxy: S3Proxy{Bucket: bucketName}, 419 | method: http.MethodGet, 420 | path: "/test.json", 421 | headers: http.Header{ 422 | "If-None-Match": []string{`"no good etag"`}, 423 | }, 424 | expectedCode: http.StatusOK, 425 | expectedResponseText: `{"foo": "bar"}`, 426 | }, 427 | { 428 | name: "returns 200 If-Unmodified-Since", 429 | proxy: S3Proxy{Bucket: bucketName}, 430 | method: http.MethodGet, 431 | path: "/test.json", 432 | headers: http.Header{ 433 | "If-Unmodified-Since": []string{`Thu, 05 May 2568 07:28:00 GMT`}, 434 | }, 435 | expectedCode: http.StatusOK, 436 | expectedResponseText: `{"foo": "bar"}`, 437 | }, 438 | { 439 | name: "returns 412 If-Unmodified-Since", 440 | proxy: S3Proxy{Bucket: bucketName}, 441 | method: http.MethodGet, 442 | path: "/test.json", 443 | headers: http.Header{ 444 | "If-Unmodified-Since": []string{`Wed, 21 Oct 2015 07:28:00 GMT`}, 445 | }, 446 | expectedCode: http.StatusPreconditionFailed, 447 | expectsEmptyResponse: true, 448 | }, 449 | { 450 | name: "returns 200 If-Modified-Since", 451 | proxy: S3Proxy{Bucket: bucketName}, 452 | method: http.MethodGet, 453 | path: "/test.json", 454 | headers: http.Header{ 455 | "If-Modified-Since": []string{`Thu, 05 May 2568 07:28:00 GMT`}, 456 | }, 457 | expectedCode: http.StatusNotModified, 458 | expectsEmptyResponse: true, 459 | }, 460 | { 461 | name: "returns 412 If-Modified-Since", 462 | proxy: S3Proxy{Bucket: bucketName}, 463 | method: http.MethodGet, 464 | path: "/test.json", 465 | headers: http.Header{ 466 | "If-Modified-Since": []string{`Wed, 21 Oct 2015 07:28:00 GMT`}, 467 | }, 468 | expectedCode: http.StatusOK, 469 | expectedResponseText: `{"foo": "bar"}`, 470 | }, 471 | } { 472 | t.Run(tc.name, func(t *testing.T) { 473 | var body io.Reader 474 | if tc.body != nil { 475 | body = bytes.NewReader(tc.body) 476 | } 477 | 478 | req, err := http.NewRequest(tc.method, tc.path, body) 479 | if err != nil { 480 | t.Fatal(err) 481 | } 482 | repl := caddy.NewReplacer() 483 | ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl) 484 | req = req.WithContext(ctx) 485 | req.Header = tc.headers 486 | 487 | recorder := httptest.NewRecorder() 488 | 489 | tc.proxy.client = client 490 | tc.proxy.log = zap.NewExample() 491 | 492 | _ = tc.proxy.ServeHTTP(recorder, req, nil) 493 | 494 | // Check HTTP status code 495 | if tc.expectedCode != 0 && recorder.Code != tc.expectedCode { 496 | t.Errorf("Expected code %d, got %d.", tc.expectedCode, recorder.Code) 497 | } 498 | 499 | // Check response headers 500 | respHeaders := recorder.Header() 501 | for k, v := range tc.expectedHeaders { 502 | if !reflect.DeepEqual(respHeaders.Values(k), v) { 503 | t.Errorf("Expected headers %v, got %v.", tc.expectedHeaders, respHeaders.Values(k)) 504 | } 505 | } 506 | 507 | // Check response body 508 | if tc.expectedResponseText != "" && tc.expectedResponseText != strings.TrimSpace(recorder.Body.String()) { 509 | t.Errorf( 510 | "Expected response text %s, got %s.", 511 | tc.expectedResponseText, 512 | recorder.Body.String(), 513 | ) 514 | } 515 | 516 | // Check if response should be empty 517 | if tc.expectsEmptyResponse && recorder.Body.Len() != 0 { 518 | t.Errorf("Expected response body to be empty, got %s.", recorder.Body.String()) 519 | } 520 | }) 521 | } 522 | } 523 | -------------------------------------------------------------------------------- /tag.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | BRANCH=$DRONE_COMMIT_BRANCH 3 | if [ "$BRANCH" = "" ]; then 4 | BRANCH=$(git rev-parse --abbrev-ref HEAD) 5 | fi 6 | 7 | 8 | calc_version () { 9 | VERSION_FILE=$1 10 | DIR=$(dirname $VERSION_FILE) 11 | if [ "$DIR" = "." ]; then 12 | MODULE_PATH= 13 | else 14 | MODULE_PATH=$DIR/ 15 | fi 16 | 17 | echo $MODULE_PATH"v$(cat $VERSION_FILE)" 18 | } 19 | 20 | if [ "$1" = "check_version" ]; then 21 | shift 22 | for var in "$@" 23 | do 24 | VERSION=$(calc_version $var) 25 | VER_EXIST=$(git tag -l $VERSION) 26 | echo $VER_EXIST 27 | if [ "$DRONE_COMMIT_BRANCH" != master ] && [ -n "$VER_EXIST" ]; then echo "Need to update $var - exiting" && exit 1; fi 28 | done 29 | exit 30 | fi 31 | 32 | git checkout $BRANCH 33 | git pull origin $BRANCH 34 | 35 | for var in "$@" 36 | do 37 | VERSION=$(calc_version $var) 38 | 39 | if [ "$BRANCH" != "master" ]; then 40 | git describe --match "$VERSION-pre.*" --abbrev=0 HEAD --tags 2> /dev/null 41 | COUNTER=1 42 | while [ $COUNTER -lt 15 ]; do 43 | tag="$VERSION-pre.$COUNTER" 44 | git describe --match $tag --abbrev=0 HEAD --tags 2> /dev/null 45 | if [ $? != 0 ]; then 46 | break; 47 | fi 48 | let COUNTER=COUNTER+1 49 | done 50 | 51 | VERSION=$tag 52 | fi 53 | git tag $VERSION 54 | done 55 | 56 | git push --tags origin $BRANCH 57 | 58 | -------------------------------------------------------------------------------- /testdata/_404.txt: -------------------------------------------------------------------------------- 1 | this is 404 2 | -------------------------------------------------------------------------------- /testdata/default_error_page.txt: -------------------------------------------------------------------------------- 1 | this is a default error page 2 | -------------------------------------------------------------------------------- /testdata/inner/index.html: -------------------------------------------------------------------------------- 1 | my index.html 2 | -------------------------------------------------------------------------------- /testdata/test.json: -------------------------------------------------------------------------------- 1 | {"foo": "bar"} 2 | -------------------------------------------------------------------------------- /testdata/to-delete.json: -------------------------------------------------------------------------------- 1 | {"foo": "bar"} 2 | --------------------------------------------------------------------------------