From bb6234a1b688157815a45e9d0d28382e64609738 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Mon, 9 Nov 2026 12:03:05 -0800 Subject: [PATCH] Confine upload_download paths to the configured os.Root directory --- upload_download/cmd/upload_download/main.go | 18 +- upload_download/design/design.go | 2 + upload_download/gen/http/openapi.json | 2 +- upload_download/gen/http/openapi.yaml | 49 +- upload_download/gen/http/openapi3.2.json | 2 +- upload_download/gen/http/openapi3.2.yaml | 4 +- upload_download/gen/http/openapi3.json | 2 +- upload_download/gen/http/openapi3.yaml | 4 +- .../gen/http/updown/client/encode_decode.go | 15 + .../gen/http/updown/client/types.go | 57 +++ .../gen/http/updown/server/encode_decode.go | 13 + .../gen/http/updown/server/types.go | 32 ++ upload_download/gen/updown/client.go | 1 + upload_download/gen/updown/service.go | 10 +- upload_download/updown.go | 234 ++++++--- upload_download/updown_test.go | 474 ++++++++++++++++++ 16 files changed, 842 insertions(+), 77 deletions(-) create mode 100644 upload_download/updown_test.go diff --git a/upload_download/cmd/upload_download/main.go b/upload_download/cmd/upload_download/main.go index 6bad900f3..308f0e1f1 100644 --- a/upload_download/cmd/upload_download/main.go +++ b/upload_download/cmd/upload_download/main.go @@ -13,7 +13,7 @@ import ( "syscall" uploaddownload "goa.design/examples/upload_download" - "goa.design/examples/upload_download/gen/updown" + genupdown "goa.design/examples/upload_download/gen/updown" ) func main() { @@ -36,19 +36,27 @@ func main() { logger = log.New(os.Stderr, "[upload-download] ", log.Ltime) } - // Initialize the services. - updownSvc, err := uploaddownload.NewUpdown(*dirF, logger) + // Open the root directory used to confine uploaded and downloaded files. + root, err := os.OpenRoot(*dirF) if err != nil { logger.Fatal(err.Error()) } + defer func() { + if closeErr := root.Close(); closeErr != nil { + logger.Printf("failed to close root directory: %v", closeErr) + } + }() + + // Initialize the services. + updownSvc := uploaddownload.NewUpdown(root, logger) // Wrap the services in endpoints that can be invoked from other services // potentially running in different processes. var ( - updownEndpoints *updown.Endpoints + updownEndpoints *genupdown.Endpoints ) { - updownEndpoints = updown.NewEndpoints(updownSvc) + updownEndpoints = genupdown.NewEndpoints(updownSvc) } // Create channel used by both the signal handler and server goroutines diff --git a/upload_download/design/design.go b/upload_download/design/design.go index 45a3a7a08..c2b903995 100644 --- a/upload_download/design/design.go +++ b/upload_download/design/design.go @@ -39,6 +39,7 @@ incompatible with gRPC.`) Error("invalid_media_type", ErrorResult, "Error returned when the Content-Type header does not define a multipart request.") Error("invalid_multipart_request", ErrorResult, "Error returned when the request body is not a valid multipart content.") + Error("invalid_file_path", ErrorResult, "Error returned when the upload directory path is invalid.") Error("internal_error", ErrorResult, "Fault while processing upload.") HTTP(func() { @@ -53,6 +54,7 @@ incompatible with gRPC.`) // Define error HTTP statuses. Response("invalid_media_type", StatusBadRequest) Response("invalid_multipart_request", StatusBadRequest) + Response("invalid_file_path", StatusBadRequest) Response("internal_error", StatusInternalServerError) }) }) diff --git a/upload_download/gen/http/openapi.json b/upload_download/gen/http/openapi.json index fcd07f7e5..3b13a05f3 100644 --- a/upload_download/gen/http/openapi.json +++ b/upload_download/gen/http/openapi.json @@ -1 +1 @@ -{"swagger":"2.0","info":{"title":"","description":"Simple file upload and download example.","version":"0.0.1"},"host":"localhost:80","consumes":["application/json","application/xml","application/gob"],"produces":["application/json","application/xml","application/gob"],"paths":{"/download/{filename}":{"get":{"tags":["updown"],"summary":"download updown","operationId":"updown#download","parameters":[{"name":"filename","in":"path","description":"Path to downloaded file.","required":true,"type":"string"}],"responses":{"200":{"description":"OK response.","headers":{"Content-Length":{"description":"Length is the downloaded content length in bytes.","type":"integer","format":"int64"}}},"404":{"description":"invalid_file_path: Could not locate file for download","schema":{"$ref":"#/definitions/UpdownDownloadInvalidFilePathResponseBody"},"examples":{"application/vnd.goa.error":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"invalid_file_path","temporary":false,"timeout":false}}},"500":{"description":"internal_error: Fault while processing download.","schema":{"$ref":"#/definitions/UpdownDownloadInternalErrorResponseBody"},"examples":{"application/vnd.goa.error":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"internal_error","temporary":false,"timeout":false}}}},"schemes":["http"]}},"/upload/{dir}":{"post":{"tags":["updown"],"summary":"upload updown","operationId":"updown#upload","parameters":[{"name":"dir","in":"path","description":"Dir is the relative path to the file directory where the uploaded content is saved.","required":true,"type":"string","default":"upload"},{"name":"Content-Type","in":"header","description":"Content-Type header, must define value for multipart boundary.","required":false,"type":"string","default":"multipart/form-data; boundary=goa","pattern":"multipart/[^;]+; boundary=.+"}],"responses":{"204":{"description":"No Content response."},"400":{"description":"invalid_multipart_request: Error returned when the request body is not a valid multipart content.","schema":{"$ref":"#/definitions/UpdownUploadInvalidMultipartRequestResponseBody"},"examples":{"application/vnd.goa.error":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"invalid_multipart_request","temporary":false,"timeout":false}}},"500":{"description":"internal_error: Fault while processing upload.","schema":{"$ref":"#/definitions/UpdownUploadInternalErrorResponseBody"},"examples":{"application/vnd.goa.error":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"internal_error","temporary":false,"timeout":false}}}},"schemes":["http"]}}},"definitions":{"UpdownDownloadInternalErrorResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":false},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":false},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":false}},"description":"Error response result type (default view)","example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":false},"required":["name","id","message","temporary","timeout","fault"]},"UpdownDownloadInvalidFilePathResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":true},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":true},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":true}},"description":"Error response result type (default view)","example":{"fault":true,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":true,"timeout":true},"required":["name","id","message","temporary","timeout","fault"]},"UpdownUploadInternalErrorResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":false},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":false},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":true}},"description":"Error response result type (default view)","example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":true},"required":["name","id","message","temporary","timeout","fault"]},"UpdownUploadInvalidMediaTypeResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":false},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":false},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":false}},"description":"Error response result type (default view)","example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":false},"required":["name","id","message","temporary","timeout","fault"]},"UpdownUploadInvalidMultipartRequestResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":true},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":true},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":false}},"description":"Error response result type (default view)","example":{"fault":true,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":true,"timeout":false},"required":["name","id","message","temporary","timeout","fault"]}}} \ No newline at end of file +{"swagger":"2.0","info":{"title":"","description":"Simple file upload and download example.","version":"0.0.1"},"host":"localhost:80","consumes":["application/json","application/xml","application/gob"],"produces":["application/json","application/xml","application/gob"],"paths":{"/download/{filename}":{"get":{"tags":["updown"],"summary":"download updown","operationId":"updown#download","parameters":[{"name":"filename","in":"path","description":"Path to downloaded file.","required":true,"type":"string"}],"responses":{"200":{"description":"OK response.","headers":{"Content-Length":{"description":"Length is the downloaded content length in bytes.","type":"integer","format":"int64"}}},"404":{"description":"invalid_file_path: Could not locate file for download","schema":{"$ref":"#/definitions/UpdownDownloadInvalidFilePathResponseBody"},"examples":{"application/vnd.goa.error":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"invalid_file_path","temporary":false,"timeout":false}}},"500":{"description":"internal_error: Fault while processing download.","schema":{"$ref":"#/definitions/UpdownDownloadInternalErrorResponseBody"},"examples":{"application/vnd.goa.error":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"internal_error","temporary":false,"timeout":false}}}},"schemes":["http"]}},"/upload/{dir}":{"post":{"tags":["updown"],"summary":"upload updown","operationId":"updown#upload","parameters":[{"name":"dir","in":"path","description":"Dir is the relative path to the file directory where the uploaded content is saved.","required":true,"type":"string","default":"upload"},{"name":"Content-Type","in":"header","description":"Content-Type header, must define value for multipart boundary.","required":false,"type":"string","default":"multipart/form-data; boundary=goa","pattern":"multipart/[^;]+; boundary=.+"}],"responses":{"204":{"description":"No Content response."},"400":{"description":"invalid_file_path: Error returned when the upload directory path is invalid.","schema":{"$ref":"#/definitions/UpdownUploadInvalidFilePathResponseBody"},"examples":{"application/vnd.goa.error":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"invalid_file_path","temporary":false,"timeout":false}}},"500":{"description":"internal_error: Fault while processing upload.","schema":{"$ref":"#/definitions/UpdownUploadInternalErrorResponseBody"},"examples":{"application/vnd.goa.error":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"internal_error","temporary":false,"timeout":false}}}},"schemes":["http"]}}},"definitions":{"UpdownDownloadInternalErrorResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":false},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":false},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":false}},"description":"Error response result type (default view)","example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":false},"required":["name","id","message","temporary","timeout","fault"]},"UpdownDownloadInvalidFilePathResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":true},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":true},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":true}},"description":"Error response result type (default view)","example":{"fault":true,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":true,"timeout":true},"required":["name","id","message","temporary","timeout","fault"]},"UpdownUploadInternalErrorResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":false},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":false},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":true}},"description":"Error response result type (default view)","example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":true},"required":["name","id","message","temporary","timeout","fault"]},"UpdownUploadInvalidFilePathResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":false},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":false},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":true}},"description":"Error response result type (default view)","example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":true},"required":["name","id","message","temporary","timeout","fault"]},"UpdownUploadInvalidMediaTypeResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":false},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":false},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":false}},"description":"Error response result type (default view)","example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":false},"required":["name","id","message","temporary","timeout","fault"]},"UpdownUploadInvalidMultipartRequestResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":true},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":true},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":false}},"description":"Error response result type (default view)","example":{"fault":true,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":true,"timeout":false},"required":["name","id","message","temporary","timeout","fault"]}}} \ No newline at end of file diff --git a/upload_download/gen/http/openapi.yaml b/upload_download/gen/http/openapi.yaml index 6c3245cbe..9a8634648 100644 --- a/upload_download/gen/http/openapi.yaml +++ b/upload_download/gen/http/openapi.yaml @@ -83,15 +83,15 @@ paths: "204": description: No Content response. "400": - description: 'invalid_multipart_request: Error returned when the request body is not a valid multipart content.' + description: 'invalid_file_path: Error returned when the upload directory path is invalid.' schema: - $ref: '#/definitions/UpdownUploadInvalidMultipartRequestResponseBody' + $ref: '#/definitions/UpdownUploadInvalidFilePathResponseBody' examples: application/vnd.goa.error: fault: false id: 123abc message: parameter 'p' must be an integer - name: invalid_multipart_request + name: invalid_file_path temporary: false timeout: false "500": @@ -238,6 +238,49 @@ definitions: - temporary - timeout - fault + UpdownUploadInvalidFilePathResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: false + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: false + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault UpdownUploadInvalidMediaTypeResponseBody: title: 'Mediatype identifier: application/vnd.goa.error; view=default' type: object diff --git a/upload_download/gen/http/openapi3.2.json b/upload_download/gen/http/openapi3.2.json index 449516914..371ac16a8 100644 --- a/upload_download/gen/http/openapi3.2.json +++ b/upload_download/gen/http/openapi3.2.json @@ -1 +1 @@ -{"openapi":"3.2.0","info":{"title":"Goa API","description":"Simple file upload and download example.","version":"0.0.1"},"servers":[{"url":"http://localhost:80","name":"upload_download","description":"Default server for upload_download"}],"paths":{"/download/{filename}":{"get":{"tags":["updown"],"summary":"download updown","operationId":"updown#download","parameters":[{"name":"filename","in":"path","description":"Path to downloaded file.","required":true,"schema":{"type":"string","description":"Path to downloaded file.","example":"Ut rerum est minus natus voluptas ex."},"example":"Ut rerum est minus natus voluptas ex."}],"responses":{"200":{"description":"OK response.","headers":{"Content-Length":{"description":"Length is the downloaded content length in bytes.","schema":{"type":"integer","description":"Length is the downloaded content length in bytes.","example":4194304,"format":"int64"},"example":4194304}},"content":{"application/json":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"invalid_file_path: Could not locate file for download","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"invalid_file_path","temporary":false,"timeout":false}}}},"500":{"description":"internal_error: Fault while processing download.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"internal_error","temporary":false,"timeout":false}}}}}}},"/upload/{dir}":{"post":{"tags":["updown"],"summary":"upload updown","operationId":"updown#upload","parameters":[{"name":"dir","in":"path","description":"Dir is the relative path to the file directory where the uploaded content is saved.","required":true,"schema":{"type":"string","description":"Dir is the relative path to the file directory where the uploaded content is saved.","default":"upload","example":"upload"},"example":"upload"},{"name":"Content-Type","in":"header","description":"Content-Type header, must define value for multipart boundary.","schema":{"type":"string","description":"Content-Type header, must define value for multipart boundary.","default":"multipart/form-data; boundary=goa","example":"multipart/form-data; boundary=goa","pattern":"multipart/[^;]+; boundary=.+"},"example":"multipart/form-data; boundary=goa"}],"responses":{"204":{"description":"No Content response."},"400":{"description":"invalid_multipart_request: Error returned when the request body is not a valid multipart content.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"invalid_multipart_request","temporary":false,"timeout":false}}}},"500":{"description":"internal_error: Fault while processing upload.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"internal_error","temporary":false,"timeout":false}}}}}}}},"components":{"schemas":{"Error":{"type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":false},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":false},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":false}},"description":"Error response result type","example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":false},"required":["name","id","message","temporary","timeout","fault"]}}},"tags":[{"name":"updown","description":"Service updown demonstrates how to implement upload and download of files in\nGoa without having to load the entire content in memory first.\nThe upload method uses SkipRequestBodyEncodeDecode to delegate reading the HTTP\nrequest body to the service logic. This alleviates the need for loading the\nfull body content in memory first to decode it into a data structure. Note that\nusing SkipRequestBodyDecode is incompatible with gRPC and can only be used on\nmethods that only define a HTTP transport mapping. This example implementation\nleverages package \"mime/multipart\" to read the request body.\nSimilarly the download method uses SkipResponseBodyEncodeDecode to stream the \nfile to the client without requiring to load the complete content in memory\nfirst. As with SkipRequestBodyDecode using SkipResponseBodyEncodeDecode is\nincompatible with gRPC."}]} \ No newline at end of file +{"openapi":"3.2.0","info":{"title":"Goa API","description":"Simple file upload and download example.","version":"0.0.1"},"servers":[{"url":"http://localhost:80","name":"upload_download","description":"Default server for upload_download"}],"paths":{"/download/{filename}":{"get":{"tags":["updown"],"summary":"download updown","operationId":"updown#download","parameters":[{"name":"filename","in":"path","description":"Path to downloaded file.","required":true,"schema":{"type":"string","description":"Path to downloaded file.","example":"Ut rerum est minus natus voluptas ex."},"example":"Ut rerum est minus natus voluptas ex."}],"responses":{"200":{"description":"OK response.","headers":{"Content-Length":{"description":"Length is the downloaded content length in bytes.","schema":{"type":"integer","description":"Length is the downloaded content length in bytes.","example":4194304,"format":"int64"},"example":4194304}},"content":{"application/json":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"invalid_file_path: Could not locate file for download","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"invalid_file_path","temporary":false,"timeout":false}}}},"500":{"description":"internal_error: Fault while processing download.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"internal_error","temporary":false,"timeout":false}}}}}}},"/upload/{dir}":{"post":{"tags":["updown"],"summary":"upload updown","operationId":"updown#upload","parameters":[{"name":"dir","in":"path","description":"Dir is the relative path to the file directory where the uploaded content is saved.","required":true,"schema":{"type":"string","description":"Dir is the relative path to the file directory where the uploaded content is saved.","default":"upload","example":"upload"},"example":"upload"},{"name":"Content-Type","in":"header","description":"Content-Type header, must define value for multipart boundary.","schema":{"type":"string","description":"Content-Type header, must define value for multipart boundary.","default":"multipart/form-data; boundary=goa","example":"multipart/form-data; boundary=goa","pattern":"multipart/[^;]+; boundary=.+"},"example":"multipart/form-data; boundary=goa"}],"responses":{"204":{"description":"No Content response."},"400":{"description":"invalid_file_path: Error returned when the upload directory path is invalid.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"invalid_file_path","temporary":false,"timeout":false}}}},"500":{"description":"internal_error: Fault while processing upload.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"internal_error","temporary":false,"timeout":false}}}}}}}},"components":{"schemas":{"Error":{"type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":false},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":false},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":false}},"description":"Error response result type","example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":false},"required":["name","id","message","temporary","timeout","fault"]}}},"tags":[{"name":"updown","description":"Service updown demonstrates how to implement upload and download of files in\nGoa without having to load the entire content in memory first.\nThe upload method uses SkipRequestBodyEncodeDecode to delegate reading the HTTP\nrequest body to the service logic. This alleviates the need for loading the\nfull body content in memory first to decode it into a data structure. Note that\nusing SkipRequestBodyDecode is incompatible with gRPC and can only be used on\nmethods that only define a HTTP transport mapping. This example implementation\nleverages package \"mime/multipart\" to read the request body.\nSimilarly the download method uses SkipResponseBodyEncodeDecode to stream the \nfile to the client without requiring to load the complete content in memory\nfirst. As with SkipRequestBodyDecode using SkipResponseBodyEncodeDecode is\nincompatible with gRPC."}]} \ No newline at end of file diff --git a/upload_download/gen/http/openapi3.2.yaml b/upload_download/gen/http/openapi3.2.yaml index f751bcfe3..69af32a59 100644 --- a/upload_download/gen/http/openapi3.2.yaml +++ b/upload_download/gen/http/openapi3.2.yaml @@ -98,7 +98,7 @@ paths: "204": description: No Content response. "400": - description: 'invalid_multipart_request: Error returned when the request body is not a valid multipart content.' + description: 'invalid_file_path: Error returned when the upload directory path is invalid.' content: application/vnd.goa.error: schema: @@ -107,7 +107,7 @@ paths: fault: false id: 123abc message: parameter 'p' must be an integer - name: invalid_multipart_request + name: invalid_file_path temporary: false timeout: false "500": diff --git a/upload_download/gen/http/openapi3.json b/upload_download/gen/http/openapi3.json index a2002993a..a9682b7f5 100644 --- a/upload_download/gen/http/openapi3.json +++ b/upload_download/gen/http/openapi3.json @@ -1 +1 @@ -{"openapi":"3.0.3","info":{"title":"Goa API","description":"Simple file upload and download example.","version":"0.0.1"},"servers":[{"url":"http://localhost:80","description":"Default server for upload_download"}],"paths":{"/download/{filename}":{"get":{"tags":["updown"],"summary":"download updown","operationId":"updown#download","parameters":[{"name":"filename","in":"path","description":"Path to downloaded file.","required":true,"schema":{"type":"string","description":"Path to downloaded file.","example":"Ut rerum est minus natus voluptas ex."},"example":"Ut rerum est minus natus voluptas ex."}],"responses":{"200":{"description":"OK response.","headers":{"Content-Length":{"description":"Length is the downloaded content length in bytes.","schema":{"type":"integer","description":"Length is the downloaded content length in bytes.","example":4194304,"format":"int64"},"example":4194304}},"content":{"application/json":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"invalid_file_path: Could not locate file for download","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"invalid_file_path","temporary":false,"timeout":false}}}},"500":{"description":"internal_error: Fault while processing download.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"internal_error","temporary":false,"timeout":false}}}}}}},"/upload/{dir}":{"post":{"tags":["updown"],"summary":"upload updown","operationId":"updown#upload","parameters":[{"name":"dir","in":"path","description":"Dir is the relative path to the file directory where the uploaded content is saved.","required":true,"schema":{"type":"string","description":"Dir is the relative path to the file directory where the uploaded content is saved.","default":"upload","example":"upload"},"example":"upload"},{"name":"Content-Type","in":"header","description":"Content-Type header, must define value for multipart boundary.","schema":{"type":"string","description":"Content-Type header, must define value for multipart boundary.","default":"multipart/form-data; boundary=goa","example":"multipart/form-data; boundary=goa","pattern":"multipart/[^;]+; boundary=.+"},"example":"multipart/form-data; boundary=goa"}],"responses":{"204":{"description":"No Content response."},"400":{"description":"invalid_multipart_request: Error returned when the request body is not a valid multipart content.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"invalid_multipart_request","temporary":false,"timeout":false}}}},"500":{"description":"internal_error: Fault while processing upload.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"internal_error","temporary":false,"timeout":false}}}}}}}},"components":{"schemas":{"Error":{"type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":false},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":false},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":false}},"description":"Error response result type","example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":false},"required":["name","id","message","temporary","timeout","fault"]}}},"tags":[{"name":"updown","description":"Service updown demonstrates how to implement upload and download of files in\nGoa without having to load the entire content in memory first.\nThe upload method uses SkipRequestBodyEncodeDecode to delegate reading the HTTP\nrequest body to the service logic. This alleviates the need for loading the\nfull body content in memory first to decode it into a data structure. Note that\nusing SkipRequestBodyDecode is incompatible with gRPC and can only be used on\nmethods that only define a HTTP transport mapping. This example implementation\nleverages package \"mime/multipart\" to read the request body.\nSimilarly the download method uses SkipResponseBodyEncodeDecode to stream the \nfile to the client without requiring to load the complete content in memory\nfirst. As with SkipRequestBodyDecode using SkipResponseBodyEncodeDecode is\nincompatible with gRPC."}]} \ No newline at end of file +{"openapi":"3.0.3","info":{"title":"Goa API","description":"Simple file upload and download example.","version":"0.0.1"},"servers":[{"url":"http://localhost:80","description":"Default server for upload_download"}],"paths":{"/download/{filename}":{"get":{"tags":["updown"],"summary":"download updown","operationId":"updown#download","parameters":[{"name":"filename","in":"path","description":"Path to downloaded file.","required":true,"schema":{"type":"string","description":"Path to downloaded file.","example":"Ut rerum est minus natus voluptas ex."},"example":"Ut rerum est minus natus voluptas ex."}],"responses":{"200":{"description":"OK response.","headers":{"Content-Length":{"description":"Length is the downloaded content length in bytes.","schema":{"type":"integer","description":"Length is the downloaded content length in bytes.","example":4194304,"format":"int64"},"example":4194304}},"content":{"application/json":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"invalid_file_path: Could not locate file for download","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"invalid_file_path","temporary":false,"timeout":false}}}},"500":{"description":"internal_error: Fault while processing download.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"internal_error","temporary":false,"timeout":false}}}}}}},"/upload/{dir}":{"post":{"tags":["updown"],"summary":"upload updown","operationId":"updown#upload","parameters":[{"name":"dir","in":"path","description":"Dir is the relative path to the file directory where the uploaded content is saved.","required":true,"schema":{"type":"string","description":"Dir is the relative path to the file directory where the uploaded content is saved.","default":"upload","example":"upload"},"example":"upload"},{"name":"Content-Type","in":"header","description":"Content-Type header, must define value for multipart boundary.","schema":{"type":"string","description":"Content-Type header, must define value for multipart boundary.","default":"multipart/form-data; boundary=goa","example":"multipart/form-data; boundary=goa","pattern":"multipart/[^;]+; boundary=.+"},"example":"multipart/form-data; boundary=goa"}],"responses":{"204":{"description":"No Content response."},"400":{"description":"invalid_file_path: Error returned when the upload directory path is invalid.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"invalid_file_path","temporary":false,"timeout":false}}}},"500":{"description":"internal_error: Fault while processing upload.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"internal_error","temporary":false,"timeout":false}}}}}}}},"components":{"schemas":{"Error":{"type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":false},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":false},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":false}},"description":"Error response result type","example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":false},"required":["name","id","message","temporary","timeout","fault"]}}},"tags":[{"name":"updown","description":"Service updown demonstrates how to implement upload and download of files in\nGoa without having to load the entire content in memory first.\nThe upload method uses SkipRequestBodyEncodeDecode to delegate reading the HTTP\nrequest body to the service logic. This alleviates the need for loading the\nfull body content in memory first to decode it into a data structure. Note that\nusing SkipRequestBodyDecode is incompatible with gRPC and can only be used on\nmethods that only define a HTTP transport mapping. This example implementation\nleverages package \"mime/multipart\" to read the request body.\nSimilarly the download method uses SkipResponseBodyEncodeDecode to stream the \nfile to the client without requiring to load the complete content in memory\nfirst. As with SkipRequestBodyDecode using SkipResponseBodyEncodeDecode is\nincompatible with gRPC."}]} \ No newline at end of file diff --git a/upload_download/gen/http/openapi3.yaml b/upload_download/gen/http/openapi3.yaml index 2b54308c3..320555161 100644 --- a/upload_download/gen/http/openapi3.yaml +++ b/upload_download/gen/http/openapi3.yaml @@ -97,7 +97,7 @@ paths: "204": description: No Content response. "400": - description: 'invalid_multipart_request: Error returned when the request body is not a valid multipart content.' + description: 'invalid_file_path: Error returned when the upload directory path is invalid.' content: application/vnd.goa.error: schema: @@ -106,7 +106,7 @@ paths: fault: false id: 123abc message: parameter 'p' must be an integer - name: invalid_multipart_request + name: invalid_file_path temporary: false timeout: false "500": diff --git a/upload_download/gen/http/updown/client/encode_decode.go b/upload_download/gen/http/updown/client/encode_decode.go index 9d8e612a2..e017e8897 100644 --- a/upload_download/gen/http/updown/client/encode_decode.go +++ b/upload_download/gen/http/updown/client/encode_decode.go @@ -76,6 +76,7 @@ func EncodeUploadRequest(encoder func(*http.Request) goahttp.Encoder) func(*http // DecodeUploadResponse may return the following errors: // - "invalid_media_type" (type *goa.ServiceError): http.StatusBadRequest // - "invalid_multipart_request" (type *goa.ServiceError): http.StatusBadRequest +// - "invalid_file_path" (type *goa.ServiceError): http.StatusBadRequest // - "internal_error" (type *goa.ServiceError): http.StatusInternalServerError // - error: internal error func DecodeUploadResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { @@ -132,6 +133,20 @@ func DecodeUploadResponse(decoder func(*http.Response) goahttp.Decoder, restoreB return nil, goahttp.ErrValidationError("updown", "upload", err) } return nil, NewUploadInvalidMultipartRequest(&body) + case "invalid_file_path": + var ( + body UploadInvalidFilePathResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("updown", "upload", err) + } + err = ValidateUploadInvalidFilePathResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("updown", "upload", err) + } + return nil, NewUploadInvalidFilePath(&body) default: body, err := io.ReadAll(resp.Body) if err != nil { diff --git a/upload_download/gen/http/updown/client/types.go b/upload_download/gen/http/updown/client/types.go index f9a3fa863..8f0b90895 100644 --- a/upload_download/gen/http/updown/client/types.go +++ b/upload_download/gen/http/updown/client/types.go @@ -49,6 +49,24 @@ type UploadInvalidMultipartRequestResponseBody struct { Fault *bool `form:"fault,omitempty" json:"fault,omitempty" xml:"fault,omitempty"` } +// UploadInvalidFilePathResponseBody is the type of the "updown" service +// "upload" endpoint HTTP response body for the "invalid_file_path" error. +type UploadInvalidFilePathResponseBody struct { + // Name is the name of this class of errors. + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + // ID is a unique identifier for this particular occurrence of the problem. + ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` + // Is the error temporary? + Temporary *bool `form:"temporary,omitempty" json:"temporary,omitempty" xml:"temporary,omitempty"` + // Is the error a timeout? + Timeout *bool `form:"timeout,omitempty" json:"timeout,omitempty" xml:"timeout,omitempty"` + // Is the error a server-side fault? + Fault *bool `form:"fault,omitempty" json:"fault,omitempty" xml:"fault,omitempty"` +} + // UploadInternalErrorResponseBody is the type of the "updown" service "upload" // endpoint HTTP response body for the "internal_error" error. type UploadInternalErrorResponseBody struct { @@ -133,6 +151,21 @@ func NewUploadInvalidMultipartRequest(body *UploadInvalidMultipartRequestRespons return v } +// NewUploadInvalidFilePath builds a updown service upload endpoint +// invalid_file_path error. +func NewUploadInvalidFilePath(body *UploadInvalidFilePathResponseBody) *goa.ServiceError { + v := &goa.ServiceError{ + Name: *body.Name, + ID: *body.ID, + Message: *body.Message, + Temporary: *body.Temporary, + Timeout: *body.Timeout, + Fault: *body.Fault, + } + + return v +} + // NewUploadInternalError builds a updown service upload endpoint // internal_error error. func NewUploadInternalError(body *UploadInternalErrorResponseBody) *goa.ServiceError { @@ -235,6 +268,30 @@ func ValidateUploadInvalidMultipartRequestResponseBody(body *UploadInvalidMultip return } +// ValidateUploadInvalidFilePathResponseBody runs the validations defined on +// UploadInvalidFilePathResponseBody +func ValidateUploadInvalidFilePathResponseBody(body *UploadInvalidFilePathResponseBody) (err error) { + if body.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) + } + if body.ID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("id", "body")) + } + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + if body.Temporary == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("temporary", "body")) + } + if body.Timeout == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("timeout", "body")) + } + if body.Fault == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("fault", "body")) + } + return +} + // ValidateUploadInternalErrorResponseBody runs the validations defined on // UploadInternalErrorResponseBody func ValidateUploadInternalErrorResponseBody(body *UploadInternalErrorResponseBody) (err error) { diff --git a/upload_download/gen/http/updown/server/encode_decode.go b/upload_download/gen/http/updown/server/encode_decode.go index 714bae883..21dd4e68b 100644 --- a/upload_download/gen/http/updown/server/encode_decode.go +++ b/upload_download/gen/http/updown/server/encode_decode.go @@ -92,6 +92,19 @@ func EncodeUploadError(encoder func(context.Context, http.ResponseWriter) goahtt w.Header().Set("goa-error", res.GoaErrorName()) w.WriteHeader(http.StatusBadRequest) return enc.Encode(body) + case "invalid_file_path": + var res *goa.ServiceError + errors.As(v, &res) + enc := encoder(ctx, w) + var body any + if formatter != nil { + body = formatter(ctx, res) + } else { + body = NewUploadInvalidFilePathResponseBody(res) + } + w.Header().Set("goa-error", res.GoaErrorName()) + w.WriteHeader(http.StatusBadRequest) + return enc.Encode(body) case "internal_error": var res *goa.ServiceError errors.As(v, &res) diff --git a/upload_download/gen/http/updown/server/types.go b/upload_download/gen/http/updown/server/types.go index e6b0982c9..9bd05c3cd 100644 --- a/upload_download/gen/http/updown/server/types.go +++ b/upload_download/gen/http/updown/server/types.go @@ -49,6 +49,24 @@ type UploadInvalidMultipartRequestResponseBody struct { Fault bool `form:"fault" json:"fault" xml:"fault"` } +// UploadInvalidFilePathResponseBody is the type of the "updown" service +// "upload" endpoint HTTP response body for the "invalid_file_path" error. +type UploadInvalidFilePathResponseBody struct { + // Name is the name of this class of errors. + Name string `form:"name" json:"name" xml:"name"` + // ID is a unique identifier for this particular occurrence of the problem. + ID string `form:"id" json:"id" xml:"id"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message string `form:"message" json:"message" xml:"message"` + // Is the error temporary? + Temporary bool `form:"temporary" json:"temporary" xml:"temporary"` + // Is the error a timeout? + Timeout bool `form:"timeout" json:"timeout" xml:"timeout"` + // Is the error a server-side fault? + Fault bool `form:"fault" json:"fault" xml:"fault"` +} + // UploadInternalErrorResponseBody is the type of the "updown" service "upload" // endpoint HTTP response body for the "internal_error" error. type UploadInternalErrorResponseBody struct { @@ -131,6 +149,20 @@ func NewUploadInvalidMultipartRequestResponseBody(res *goa.ServiceError) *Upload return body } +// NewUploadInvalidFilePathResponseBody builds the HTTP response body from the +// result of the "upload" endpoint of the "updown" service. +func NewUploadInvalidFilePathResponseBody(res *goa.ServiceError) *UploadInvalidFilePathResponseBody { + body := &UploadInvalidFilePathResponseBody{ + Name: res.Name, + ID: res.ID, + Message: res.Message, + Temporary: res.Temporary, + Timeout: res.Timeout, + Fault: res.Fault, + } + return body +} + // NewUploadInternalErrorResponseBody builds the HTTP response body from the // result of the "upload" endpoint of the "updown" service. func NewUploadInternalErrorResponseBody(res *goa.ServiceError) *UploadInternalErrorResponseBody { diff --git a/upload_download/gen/updown/client.go b/upload_download/gen/updown/client.go index 8a0f89b4c..d98bcabed 100644 --- a/upload_download/gen/updown/client.go +++ b/upload_download/gen/updown/client.go @@ -32,6 +32,7 @@ func NewClient(upload, download goa.Endpoint) *Client { // Upload may return the following errors: // - "invalid_media_type" (type *goa.ServiceError): Error returned when the Content-Type header does not define a multipart request. // - "invalid_multipart_request" (type *goa.ServiceError): Error returned when the request body is not a valid multipart content. +// - "invalid_file_path" (type *goa.ServiceError): Error returned when the upload directory path is invalid. // - "internal_error" (type *goa.ServiceError): Fault while processing upload. // - error: internal error func (c *Client) Upload(ctx context.Context, p *UploadPayload, req io.ReadCloser) (err error) { diff --git a/upload_download/gen/updown/service.go b/upload_download/gen/updown/service.go index bd473f28c..7d724a78f 100644 --- a/upload_download/gen/updown/service.go +++ b/upload_download/gen/updown/service.go @@ -81,12 +81,12 @@ func MakeInvalidMultipartRequest(err error) *goa.ServiceError { return goa.NewServiceError(err, "invalid_multipart_request", false, false, false) } -// MakeInternalError builds a goa.ServiceError from an error. -func MakeInternalError(err error) *goa.ServiceError { - return goa.NewServiceError(err, "internal_error", false, false, false) -} - // MakeInvalidFilePath builds a goa.ServiceError from an error. func MakeInvalidFilePath(err error) *goa.ServiceError { return goa.NewServiceError(err, "invalid_file_path", false, false, false) } + +// MakeInternalError builds a goa.ServiceError from an error. +func MakeInternalError(err error) *goa.ServiceError { + return goa.NewServiceError(err, "internal_error", false, false, false) +} diff --git a/upload_download/updown.go b/upload_download/updown.go index 324cfe6a7..56eaef0c7 100644 --- a/upload_download/updown.go +++ b/upload_download/updown.go @@ -1,97 +1,217 @@ +// Package uploaddownload implements the updown service using an os.Root +// directory handle so uploaded and downloaded paths cannot escape the +// configured base directory. package uploaddownload import ( "context" + "errors" + "fmt" "io" + "io/fs" "log" "mime" "mime/multipart" "os" + "path" "path/filepath" + "strings" + "syscall" - updown "goa.design/examples/upload_download/gen/updown" + genupdown "goa.design/examples/upload_download/gen/updown" ) -// updown service example implementation. -// The example methods log the requests and return zero values. -type updownsrvc struct { - dir string // Path to download and upload directory - logger *log.Logger -} - -// NewUpdown returns the updown service implementation. -func NewUpdown(dir string, logger *log.Logger) (updown.Service, error) { - abs, err := filepath.Abs(dir) - if err != nil { - return nil, err - } - if _, err := os.Stat(abs); err != nil { - return nil, err +type ( + // updownsrvc streams multipart uploads into and file downloads from a + // confined root directory. + updownsrvc struct { + // root confines all directory creation, file writes, and file reads to + // the service's configured base directory. + root *os.Root + // logger records completed file writes and close failures. + logger *log.Logger } +) - return &updownsrvc{abs, logger}, nil +// NewUpdown returns the updown service implementation confined to root. The +// caller owns root and must close it when the service stops serving requests. +func NewUpdown(root *os.Root, logger *log.Logger) genupdown.Service { + return &updownsrvc{ + root: root, + logger: logger, + } } -// Upload implements upload. -func (s *updownsrvc) Upload(ctx context.Context, p *updown.UploadPayload, req io.ReadCloser) error { - // Don't forget to close the body reader! - defer req.Close() +// Upload streams each multipart file part from req into p.Dir beneath s.root, +// creating missing subdirectories with mode 0750 and overwriting existing files +// with the same base name. It returns invalid_media_type when p.ContentType +// lacks a valid multipart boundary, invalid_file_path when p.Dir is invalid, +// names a non-directory, or escapes s.root, invalid_multipart_request when the +// multipart stream is malformed or a part lacks a valid file name, and +// internal_error on storage or permission failures. +func (s *updownsrvc) Upload( + ctx context.Context, + p *genupdown.UploadPayload, + req io.ReadCloser, +) (err error) { + defer func() { + if closeErr := req.Close(); closeErr != nil && err == nil { + err = genupdown.MakeInternalError(closeErr) + } + }() - // Make sure upload directory exists - uploadDir := filepath.Join(s.dir, p.Dir) - if err := os.MkdirAll(uploadDir, 0777); err != nil { - return updown.MakeInternalError(err) + _, params, parseErr := mime.ParseMediaType(p.ContentType) + if parseErr != nil { + return genupdown.MakeInvalidMediaType(parseErr) + } + boundary := params["boundary"] + if boundary == "" { + return genupdown.MakeInvalidMediaType(fmt.Errorf("missing multipart boundary")) } - // Createa multipart request reader - _, params, err := mime.ParseMediaType(p.ContentType) - if err != nil { - return updown.MakeInvalidMediaType(err) + dir, cleanDir, dirErr := localizeUploadDir(p.Dir) + if dirErr != nil { + return genupdown.MakeInvalidFilePath(dirErr) + } + if mkErr := s.root.MkdirAll(dir, 0o750); mkErr != nil { + if isClientPathError(mkErr) { + return genupdown.MakeInvalidFilePath(mkErr) + } + return genupdown.MakeInternalError(mkErr) } - mr := multipart.NewReader(req, params["boundary"]) - // Go through each part and save the corresponding content to disk. + mr := multipart.NewReader(req, boundary) for { - part, err := mr.NextPart() - if err == io.EOF { - // We're done! + part, partErr := mr.NextPart() + if errors.Is(partErr, io.EOF) { return nil } - if err != nil { - return updown.MakeInvalidMultipartRequest(err) + if partErr != nil { + return genupdown.MakeInvalidMultipartRequest(partErr) } - // Create uploaded file, potentially overridding existing file. - fpath := filepath.Join(uploadDir, part.FileName()) - f, err := os.Create(fpath) - if err != nil { - return updown.MakeInternalError(err) + name := part.FileName() + if !isValidPartFileName(name) { + return genupdown.MakeInvalidMultipartRequest(fmt.Errorf("invalid part filename %q", name)) } - defer f.Close() - // Stream content to disk. - n, err := io.Copy(f, part) - if err != nil { - return updown.MakeInternalError(err) + n, writeErr := writePart(s.root, filepath.Join(dir, name), part) + if writeErr != nil { + if errors.Is(writeErr, io.ErrUnexpectedEOF) { + return genupdown.MakeInvalidMultipartRequest(writeErr) + } + if isClientPathError(writeErr) { + return genupdown.MakeInvalidFilePath(writeErr) + } + return genupdown.MakeInternalError(writeErr) } - s.logger.Printf("Written %d bytes to %q", n, fpath) + s.logger.Printf("Written %d bytes to %q", n, path.Join(cleanDir, name)) } } -// Download implements download. -func (s *updownsrvc) Download(ctx context.Context, p string) (*updown.DownloadResult, io.ReadCloser, error) { - // Locate file to download. - abs := filepath.Join(s.dir, p) - fi, err := os.Stat(abs) +// Download opens the file at p beneath s.root and returns its byte length and +// an open reader for streaming the response body. It returns invalid_file_path +// when p is invalid, missing, names a directory, or escapes s.root, and +// internal_error on storage or permission failures. +func (s *updownsrvc) Download( + ctx context.Context, + p string, +) (*genupdown.DownloadResult, io.ReadCloser, error) { + if p == "." { + return nil, nil, genupdown.MakeInvalidFilePath(fmt.Errorf("%q is a directory", p)) + } + localized, locErr := filepath.Localize(p) + if locErr != nil { + return nil, nil, genupdown.MakeInvalidFilePath(locErr) + } + + f, err := s.root.Open(localized) if err != nil { - return nil, nil, updown.MakeInvalidFilePath(err) + if isClientPathError(err) { + return nil, nil, genupdown.MakeInvalidFilePath(err) + } + return nil, nil, genupdown.MakeInternalError(err) + } + fi, err := f.Stat() + if err != nil { + if closeErr := f.Close(); closeErr != nil { + s.logger.Printf("failed to close %q: %v", p, closeErr) + } + return nil, nil, genupdown.MakeInternalError(err) + } + if fi.IsDir() { + if closeErr := f.Close(); closeErr != nil { + s.logger.Printf("failed to close %q: %v", p, closeErr) + } + return nil, nil, genupdown.MakeInvalidFilePath(fmt.Errorf("%q is a directory", p)) } - // Open file for streaming - f, err := os.Open(abs) + return &genupdown.DownloadResult{Length: fi.Size()}, f, nil +} + +// localizeUploadDir validates the slash-separated HTTP wildcard directory raw +// and converts it into an OS-local relative path alongside its clean slash form. +func localizeUploadDir(raw string) (string, string, error) { + if raw == "" { + return ".", ".", nil + } + if strings.HasPrefix(raw, "/") { + return "", "", fmt.Errorf("upload directory %q must be relative", raw) + } + trimmed := strings.TrimRight(raw, "/") + localized, err := filepath.Localize(trimmed) if err != nil { - return nil, nil, updown.MakeInternalError(err) + return "", "", err } + return localized, trimmed, nil +} - return &updown.DownloadResult{Length: fi.Size()}, f, nil +// isClientPathError reports whether err indicates an invalid, missing, +// non-directory, or root-escaping client path rather than a server storage or +// permission failure. +func isClientPathError(err error) bool { + if errors.Is(err, fs.ErrNotExist) || + errors.Is(err, fs.ErrExist) || + errors.Is(err, fs.ErrInvalid) || + errors.Is(err, syscall.EINVAL) || + errors.Is(err, syscall.ENOTDIR) || + errors.Is(err, syscall.EISDIR) || + errors.Is(err, syscall.ELOOP) || + errors.Is(err, syscall.ENAMETOOLONG) { + return true + } + var pathErr *os.PathError + if errors.As(err, &pathErr) { + leaf := error(pathErr) + for { + next := errors.Unwrap(leaf) + if next == nil { + break + } + leaf = next + } + msg := leaf.Error() + return msg == "path escapes from parent" || msg == "empty path" + } + return false +} + +// isValidPartFileName reports whether name is a single local file name suitable +// for creating inside an upload directory. +func isValidPartFileName(name string) bool { + return filepath.IsLocal(name) && name != "." && !strings.ContainsAny(name, `/\`) +} + +// writePart creates or truncates relPath inside root, copies src into it, and +// closes the file before returning. +func writePart(root *os.Root, relPath string, src io.Reader) (int64, error) { + f, err := root.Create(relPath) + if err != nil { + return 0, err + } + n, err := io.Copy(f, src) + if closeErr := f.Close(); err == nil { + err = closeErr + } + return n, err } diff --git a/upload_download/updown_test.go b/upload_download/updown_test.go new file mode 100644 index 000000000..659104d00 --- /dev/null +++ b/upload_download/updown_test.go @@ -0,0 +1,474 @@ +// These tests verify that the updown HTTP server confines uploaded and +// downloaded paths to the configured os.Root directory and returns the declared +// Goa error responses for invalid paths, multipart inputs, and storage faults. +package uploaddownload_test + +import ( + "bytes" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + + uploaddownload "goa.design/examples/upload_download" + genupdownsvr "goa.design/examples/upload_download/gen/http/updown/server" + genupdown "goa.design/examples/upload_download/gen/updown" + goahttp "goa.design/goa/v3/http" +) + +type ( + // multipartItem describes one raw Content-Disposition part in a test + // multipart request body. + multipartItem struct { + // disposition is the complete Content-Disposition header value. + disposition string + // content is the part payload written after the part headers. + content string + } +) + +// TestUpload verifies multipart streaming inside the root directory and +// rejection of escaping directories, escaping symlinks, non-directory paths, +// invalid part names, malformed Content-Type headers, and read-only directories. +func TestUpload(t *testing.T) { + parentDir := t.TempDir() + rootDir := filepath.Join(parentDir, "public") + outsideDir := filepath.Join(parentDir, "outside") + if err := os.MkdirAll(rootDir, 0o750); err != nil { + t.Fatalf("create root dir: %v", err) + } + if err := os.MkdirAll(outsideDir, 0o750); err != nil { + t.Fatalf("create outside dir: %v", err) + } + existingFile := filepath.Join(rootDir, "occupied.txt") + if err := os.WriteFile(existingFile, []byte("keep"), 0o600); err != nil { + t.Fatalf("write occupied.txt: %v", err) + } + readOnlyDir := filepath.Join(rootDir, "readonly") + if err := os.MkdirAll(readOnlyDir, 0o500); err != nil { + t.Fatalf("create readonly dir: %v", err) + } + t.Cleanup(func() { + if err := os.Chmod(readOnlyDir, 0o750); err != nil { + t.Errorf("restore readonly dir permissions: %v", err) + } + }) + + symlinkSupported := true + if err := os.Symlink(outsideDir, filepath.Join(rootDir, "escape_link")); err != nil { + if runtime.GOOS == "windows" { + symlinkSupported = false + } else { + t.Fatalf("create symlink: %v", err) + } + } + + mux := newTestServer(t, rootDir) + boundary := "testboundary" + validType := "multipart/form-data; boundary=" + boundary + validParts := []multipartItem{ + { + disposition: `form-data; name="file1"; filename="first.txt"`, + content: "hello world", + }, + { + disposition: `form-data; name="file2"; filename="second.txt"`, + content: "second payload", + }, + } + + tests := []struct { + name string + target string + headerType string + parts []multipartItem + wantStatus int + wantError string + skip bool + verify func(t *testing.T) + }{ + { + name: "uploads multiple parts into nested directory with restrictive permissions", + target: "/upload/nested/sub", + headerType: validType, + parts: validParts, + wantStatus: http.StatusNoContent, + verify: func(t *testing.T) { + firstPath := filepath.Join(rootDir, "nested", "sub", "first.txt") + gotFirst, err := os.ReadFile(firstPath) + if err != nil { + t.Fatalf("read first.txt: %v", err) + } + if string(gotFirst) != "hello world" { + t.Errorf("first.txt = %q, want %q", string(gotFirst), "hello world") + } + secondPath := filepath.Join(rootDir, "nested", "sub", "second.txt") + gotSecond, err := os.ReadFile(secondPath) + if err != nil { + t.Fatalf("read second.txt: %v", err) + } + if string(gotSecond) != "second payload" { + t.Errorf("second.txt = %q, want %q", string(gotSecond), "second payload") + } + if runtime.GOOS != "windows" { + fi, statErr := os.Stat(filepath.Join(rootDir, "nested", "sub")) + if statErr != nil { + t.Fatalf("stat nested/sub: %v", statErr) + } + if perm := fi.Mode().Perm(); perm&0o027 != 0 { + t.Errorf("directory mode = %04o, want no group-write or world bits", perm) + } + } + }, + }, + { + name: "uploads when directory URL has trailing slash", + target: "/upload/nested/trailing/", + headerType: validType, + parts: []multipartItem{ + { + disposition: `form-data; name="file"; filename="trail.txt"`, + content: "trailing ok", + }, + }, + wantStatus: http.StatusNoContent, + verify: func(t *testing.T) { + trailPath := filepath.Join(rootDir, "nested", "trailing", "trail.txt") + got, err := os.ReadFile(trailPath) + if err != nil { + t.Fatalf("read trail.txt: %v", err) + } + if string(got) != "trailing ok" { + t.Errorf("trail.txt = %q, want %q", string(got), "trailing ok") + } + }, + }, + { + name: "uploads directly into root directory when wildcard is empty", + target: "/upload/", + headerType: validType, + parts: []multipartItem{ + { + disposition: `form-data; name="file"; filename="root.txt"`, + content: "in root", + }, + }, + wantStatus: http.StatusNoContent, + verify: func(t *testing.T) { + got, err := os.ReadFile(filepath.Join(rootDir, "root.txt")) + if err != nil { + t.Fatalf("read root.txt: %v", err) + } + if string(got) != "in root" { + t.Errorf("root.txt = %q, want %q", string(got), "in root") + } + }, + }, + { + name: "rejects URL-encoded parent traversal in dir", + target: "/upload/..%2Foutside", + headerType: validType, + parts: []multipartItem{ + { + disposition: `form-data; name="file"; filename="pwned.txt"`, + content: "escaped", + }, + }, + wantStatus: http.StatusBadRequest, + wantError: "invalid_file_path", + verify: func(t *testing.T) { + escapedPath := filepath.Join(outsideDir, "pwned.txt") + if _, err := os.Stat(escapedPath); !os.IsNotExist(err) { + t.Errorf("outside pwned.txt stat err = %v, want not exist", err) + } + }, + }, + { + name: "rejects traversal after nonexistent prefix without creating prefix", + target: "/upload/transient/..%2F..%2Foutside", + headerType: validType, + parts: validParts, + wantStatus: http.StatusBadRequest, + wantError: "invalid_file_path", + verify: func(t *testing.T) { + transientPath := filepath.Join(rootDir, "transient") + if _, err := os.Stat(transientPath); !os.IsNotExist(err) { + t.Errorf("transient dir stat err = %v, want not exist", err) + } + }, + }, + { + name: "rejects symlink escaping root directory", + target: "/upload/escape_link", + headerType: validType, + parts: []multipartItem{ + { + disposition: `form-data; name="file"; filename="via_link.txt"`, + content: "escaped", + }, + }, + wantStatus: http.StatusBadRequest, + wantError: "invalid_file_path", + skip: !symlinkSupported, + verify: func(t *testing.T) { + viaLink := filepath.Join(outsideDir, "via_link.txt") + if _, err := os.Stat(viaLink); !os.IsNotExist(err) { + t.Errorf("outside via_link.txt stat err = %v, want not exist", err) + } + }, + }, + { + name: "rejects upload directory occupied by existing regular file", + target: "/upload/occupied.txt", + headerType: validType, + parts: validParts, + wantStatus: http.StatusBadRequest, + wantError: "invalid_file_path", + }, + { + name: "returns internal_error when parent directory denies write permission", + target: "/upload/readonly/sub", + headerType: validType, + parts: validParts, + wantStatus: http.StatusInternalServerError, + wantError: "internal_error", + skip: runtime.GOOS == "windows" || os.Geteuid() == 0, + }, + { + name: "rejects multipart part without filename", + target: "/upload/forms", + headerType: validType, + parts: []multipartItem{ + { + disposition: `form-data; name="field"`, + content: "value", + }, + }, + wantStatus: http.StatusBadRequest, + wantError: "invalid_multipart_request", + }, + { + name: "rejects dot filename in multipart part", + target: "/upload/forms", + headerType: validType, + parts: []multipartItem{ + { + disposition: `form-data; name="file"; filename="."`, + content: "value", + }, + }, + wantStatus: http.StatusBadRequest, + wantError: "invalid_multipart_request", + }, + { + name: "rejects dot-dot filename in multipart part", + target: "/upload/forms", + headerType: validType, + parts: []multipartItem{ + { + disposition: `form-data; name="file"; filename=".."`, + content: "value", + }, + }, + wantStatus: http.StatusBadRequest, + wantError: "invalid_multipart_request", + }, + { + name: "rejects unparseable Content-Type parameter before creating directory", + target: "/upload/uncreated", + headerType: `multipart/form-data; boundary="unterminated`, + parts: validParts, + wantStatus: http.StatusBadRequest, + wantError: "invalid_media_type", + verify: func(t *testing.T) { + uncreatedPath := filepath.Join(rootDir, "uncreated") + if _, err := os.Stat(uncreatedPath); !os.IsNotExist(err) { + t.Errorf("uncreated dir stat err = %v, want not exist", err) + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.skip { + t.Skip("precondition unavailable in test environment") + } + body := buildMultipartBody(boundary, tc.parts) + req := httptest.NewRequest(http.MethodPost, tc.target, body) + req.Header.Set("Content-Type", tc.headerType) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != tc.wantStatus { + t.Errorf( + "status = %d, want %d (body: %s)", + rec.Code, + tc.wantStatus, + rec.Body.String(), + ) + } + if tc.wantError != "" { + if gotErr := rec.Header().Get("goa-error"); gotErr != tc.wantError { + t.Errorf("goa-error header = %q, want %q", gotErr, tc.wantError) + } + } + if tc.verify != nil { + tc.verify(t) + } + }) + } +} + +// TestDownload verifies streaming files inside the root directory and rejecting +// escaping paths, escaping symlinks, directories, and missing files. +func TestDownload(t *testing.T) { + parentDir := t.TempDir() + rootDir := filepath.Join(parentDir, "public") + if err := os.MkdirAll(filepath.Join(rootDir, "sub"), 0o750); err != nil { + t.Fatalf("create sub dir: %v", err) + } + secretPath := filepath.Join(parentDir, "secret.txt") + if err := os.WriteFile(secretPath, []byte("top-secret"), 0o600); err != nil { + t.Fatalf("write secret.txt: %v", err) + } + wantPayload := "streamed content" + helloPath := filepath.Join(rootDir, "sub", "hello.txt") + if err := os.WriteFile(helloPath, []byte(wantPayload), 0o600); err != nil { + t.Fatalf("write hello.txt: %v", err) + } + symlinkSupported := true + if err := os.Symlink(secretPath, filepath.Join(rootDir, "secret_link.txt")); err != nil { + if runtime.GOOS == "windows" { + symlinkSupported = false + } else { + t.Fatalf("create secret symlink: %v", err) + } + } + + mux := newTestServer(t, rootDir) + + tests := []struct { + name string + target string + wantStatus int + wantError string + wantBody string + skip bool + }{ + { + name: "downloads nested file with Content-Length", + target: "/download/sub/hello.txt", + wantStatus: http.StatusOK, + wantBody: wantPayload, + }, + { + name: "rejects URL-encoded parent traversal", + target: "/download/..%2Fsecret.txt", + wantStatus: http.StatusNotFound, + wantError: "invalid_file_path", + }, + { + name: "rejects symlink escaping root directory", + target: "/download/secret_link.txt", + wantStatus: http.StatusNotFound, + wantError: "invalid_file_path", + skip: !symlinkSupported, + }, + { + name: "rejects directory path", + target: "/download/sub", + wantStatus: http.StatusNotFound, + wantError: "invalid_file_path", + }, + { + name: "rejects missing file", + target: "/download/sub/missing.txt", + wantStatus: http.StatusNotFound, + wantError: "invalid_file_path", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.skip { + t.Skip("symlinks unavailable in test environment") + } + req := httptest.NewRequest(http.MethodGet, tc.target, nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != tc.wantStatus { + t.Errorf( + "status = %d, want %d (body: %s)", + rec.Code, + tc.wantStatus, + rec.Body.String(), + ) + } + if tc.wantError != "" { + if gotErr := rec.Header().Get("goa-error"); gotErr != tc.wantError { + t.Errorf("goa-error header = %q, want %q", gotErr, tc.wantError) + } + } + if tc.wantStatus == http.StatusOK { + if gotBody := rec.Body.String(); gotBody != tc.wantBody { + t.Errorf("body = %q, want %q", gotBody, tc.wantBody) + } + wantLen := fmt.Sprintf("%d", len(tc.wantBody)) + if gotLen := rec.Header().Get("Content-Length"); gotLen != wantLen { + t.Errorf("Content-Length = %q, want %q", gotLen, wantLen) + } + } + }) + } +} + +// newTestServer mounts an updown HTTP server backed by an os.Root at rootDir. +func newTestServer(t *testing.T, rootDir string) goahttp.Muxer { + t.Helper() + root, err := os.OpenRoot(rootDir) + if err != nil { + t.Fatalf("open root %q: %v", rootDir, err) + } + t.Cleanup(func() { + if closeErr := root.Close(); closeErr != nil { + t.Errorf("close root %q: %v", rootDir, closeErr) + } + }) + + svc := uploaddownload.NewUpdown(root, log.New(io.Discard, "", 0)) + endpoints := genupdown.NewEndpoints(svc) + mux := goahttp.NewMuxer() + srv := genupdownsvr.New( + endpoints, + mux, + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + nil, + nil, + ) + genupdownsvr.Mount(mux, srv) + return mux +} + +// buildMultipartBody serializes raw Content-Disposition parts using boundary. +func buildMultipartBody(boundary string, parts []multipartItem) *bytes.Buffer { + var buf bytes.Buffer + for _, p := range parts { + fmt.Fprintf( + &buf, + "--%s\r\nContent-Disposition: %s\r\n\r\n%s\r\n", + boundary, + p.disposition, + p.content, + ) + } + fmt.Fprintf(&buf, "--%s--\r\n", boundary) + return &buf +}