diff --git a/pkg/http/http.go b/pkg/http/http.go new file mode 100644 index 0000000000..1f78137ba4 --- /dev/null +++ b/pkg/http/http.go @@ -0,0 +1,169 @@ +// Provides common utility functions for HTTP. +package http + +import ( + "bytes" + "fmt" + "io" + "net/http" +) + +type readCloser struct { + io.Reader + closer func() error +} + +func (c readCloser) Close() error { + if c.closer != nil { + return c.closer() + } + return nil +} + +func combinedReader(peekBuffer *bytes.Buffer, body io.Reader) io.Reader { + if body != nil && body != http.NoBody { + if peekBuffer != nil { + return io.MultiReader(peekBuffer, body) + } else { + return body + } + } else { + if peekBuffer != nil { + return peekBuffer + } else { + return http.NoBody + } + } +} + +// Reads up to amount of bytes from the response, and returns a ReadCloser that concatenates over those +// bytes as well as the rest of the bytes streamed in , to ensure that the whole response can still be +// read. +// +// Note that if is 0, then this function does nothing. +// +// Furthermore, this function never closes the Reader, that is left to the caller. +// +// # Parameters +// - body: the HTTP response body, which may be nil or http.NoBody +// - size: the number of bytes that will be read from the beginning of the response body, +// which may be 0 (in which case this function won't do anything) +// +// # Return values +// - a Reader that provides the full response, including the first bytes that were peeked +// up to bytes of the beginning of the response body +// - the first bytes of the response +// - a boolean: whether the peeked bytes contain the full response or whether it's truncated +// - an error if an error occured while reading the body response +func PeekResponse(body io.ReadCloser, size int64) (io.ReadCloser, []byte, bool, error) { + var peekBuffer *bytes.Buffer = nil + truncated := false + peek := []byte{} + if body != nil && body != http.NoBody && size > 0 { + peekBuffer = new(bytes.Buffer) + limitedReader := io.LimitReader(body, size) + tee := io.TeeReader(limitedReader, peekBuffer) + if _, err := io.Copy(io.Discard, tee); err != nil { + return nil, peek, false, err + } + + if int64(peekBuffer.Len()) >= size { + truncated = true + } + + peek = peekBuffer.Bytes() + + return readCloser{ + closer: body.Close, + Reader: combinedReader(peekBuffer, body), + }, peek, truncated, nil + } else { + return body, peek, truncated, nil + } +} + +// Extracts information from an HTTP request and passes it to a function, typically for logging. +// +// It also extracts the first bytes of the request body, if maxBodySize is > 0. +func DumpHttpRequest(req *http.Request, maxBodySize int64, closure func(method string, uri string, content string, truncated bool)) error { + var logBuilder bytes.Buffer + uri := "" + if req.URL != nil { + uri = req.URL.String() + } + fmt.Fprintf(&logBuilder, "%s %s\n", req.Method, uri) + for key, values := range req.Header { + if key == "Authorization" { + continue + } + for _, value := range values { + fmt.Fprintf(&logBuilder, "%s: %s\n", key, value) + } + } + truncated := false + if maxBodySize >= 0 && req.Body != nil && req.Body != http.NoBody { + peekBuffer := new(bytes.Buffer) + limitedReader := io.LimitReader(req.Body, maxBodySize) + tee := io.TeeReader(limitedReader, peekBuffer) + if _, err := io.Copy(io.Discard, tee); err != nil { + return fmt.Errorf("failed to peek at the request body for tracing: %v", err) + } else { + logBuilder.Write(peekBuffer.Bytes()) + if int64(peekBuffer.Len()) >= maxBodySize { + truncated = true + } + fullBodyReader := io.MultiReader(peekBuffer, req.Body) + req.Body = io.NopCloser(fullBodyReader) + } + } + closure(req.Method, uri, logBuilder.String(), truncated) + return nil +} + +// Extracts information from an HTTP response and passes it to a function, typically for logging. +// +// It also extracts the first bytes of the response body, if maxBodySize is > 0. +func DumpHttpResponse(resp *http.Response, body io.ReadCloser, maxBodySize int64, + closure func(method string, uri string, content string, truncated bool), +) (io.ReadCloser, error) { + var logBuilder bytes.Buffer + fmt.Fprintf(&logBuilder, "%s\n", resp.Status) + for key, values := range resp.Header { + for _, value := range values { + fmt.Fprintf(&logBuilder, "%s: %s\n", key, value) + } + } + + var peekBuffer *bytes.Buffer = nil + truncated := false + if body != nil && body != http.NoBody && maxBodySize > 0 { + peekBuffer = new(bytes.Buffer) + limitedReader := io.LimitReader(body, maxBodySize) + tee := io.TeeReader(limitedReader, peekBuffer) + if _, err := io.Copy(io.Discard, tee); err != nil { + return nil, err + } + + if int64(peekBuffer.Len()) >= maxBodySize { + truncated = true + } + + logBuilder.Write(peekBuffer.Bytes()) + } + + req := resp.Request + if req != nil { + closure(req.Method, req.URL.String(), logBuilder.String(), truncated) + } else { + closure("", "", logBuilder.String(), truncated) + } + + if peekBuffer != nil { + return readCloser{ + closer: body.Close, + Reader: combinedReader(peekBuffer, body), + }, nil + } else { + return body, nil + } +} diff --git a/pkg/http/http_test.go b/pkg/http/http_test.go new file mode 100644 index 0000000000..bd242df56a --- /dev/null +++ b/pkg/http/http_test.go @@ -0,0 +1,92 @@ +package http + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPeekResponse(t *testing.T) { + require := require.New(t) + n := 6 + text := "hello, world" + + require.Less(n, len(text)) + body, peek, truncated, err := PeekResponse(io.NopCloser(strings.NewReader(text)), int64(n)) + require.NoError(err) + require.True(truncated) + require.Equal(text[:n], string(peek)) + buf := new(strings.Builder) + _, err = io.Copy(buf, body) + require.NoError(err) + require.Equal(text, buf.String()) +} + +func TestDumpHttpResponse(t *testing.T) { + require := require.New(t) + n := 6 + text := "hello, world" + + h := http.Header{} + h.Set("testing", "true") + h.Add("aaa", "1") + h.Add("aaa", "2") + + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Header: h, + Body: nil, + } + + body, err := DumpHttpResponse(resp, io.NopCloser(strings.NewReader(text)), int64(n), func(method, uri, content string, truncated bool) { + require.True(truncated) + require.Equal(fmt.Sprintf(`200 OK +Testing: true +Aaa: 1 +Aaa: 2 +%s`, text[:n]), content) + }) + require.NoError(err) + buf := new(strings.Builder) + _, err = io.Copy(buf, body) + require.NoError(err) + require.Equal(text, buf.String()) +} + +func TestDumpHttpRequest(t *testing.T) { + require := require.New(t) + n := 6 + text := "hello, world" + + h := http.Header{} + h.Set("testing", "true") + h.Add("aaa", "1") + h.Add("aaa", "2") + + req := &http.Request{ + Method: "GET", + Header: h, + URL: &url.URL{Scheme: "https", Host: "example.com", Path: "/testing"}, + Body: io.NopCloser(strings.NewReader(text)), + } + + err := DumpHttpRequest(req, int64(n), func(method, uri, content string, truncated bool) { + require.True(truncated) + require.Equal(fmt.Sprintf(`GET https://example.com/testing +Testing: true +Aaa: 1 +Aaa: 2 +%s`, text[:n]), content) + }) + require.NoError(err) + buf := new(strings.Builder) + _, err = io.Copy(buf, req.Body) + require.NoError(err) + require.Equal(text, buf.String()) +} diff --git a/pkg/jmap/http.go b/pkg/jmap/http.go index f576d19cec..8910f5e676 100644 --- a/pkg/jmap/http.go +++ b/pkg/jmap/http.go @@ -17,6 +17,7 @@ import ( "time" "github.com/gorilla/websocket" + ochttp "github.com/opencloud-eu/opencloud/pkg/http" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/version" ) @@ -195,7 +196,7 @@ var ( func (h *HttpJmapClient) beforeRequest(_ context.Context, logger *log.Logger, _ string, endpoint string, req *http.Request) Error { l := logger.Trace() if h.traceRequests && l.Enabled() { - if err := dumpHttpRequest(req, h.traceMaxRequestBodySize, func(method string, uri string, content string, truncated bool) { + if err := ochttp.DumpHttpRequest(req, h.traceMaxRequestBodySize, func(method string, uri string, content string, truncated bool) { if truncated { l = l.Bool(logBodyTruncated, true) } @@ -209,39 +210,27 @@ func (h *HttpJmapClient) beforeRequest(_ context.Context, logger *log.Logger, _ return nil } -type readCloser struct { - io.Reader - closer io.ReadCloser -} - -func (c readCloser) Close() error { - if c.closer != nil { - return c.closer.Close() - } - return nil -} - -func (h *HttpJmapClient) response(peekSize int64, _ context.Context, logger *log.Logger, _ string, endpoint string, duration time.Duration, req *http.Request, resp *http.Response) (io.ReadCloser, []byte, bool, error) { +func (h *HttpJmapClient) response(peekSize int64, _ context.Context, logger *log.Logger, _ string, endpoint string, duration time.Duration, resp *http.Response) (io.ReadCloser, []byte, bool, error) { whole := resp.Body peek := []byte{} truncated := false var err error if peekSize > 0 { - whole, peek, truncated, err = peekResponse(resp.Body, peekSize, func(err error) { logger.Error().Err(err).Msg("failed to close response body") }) //NOSONAR + whole, peek, truncated, err = ochttp.PeekResponse(resp.Body, peekSize) if err != nil { return whole, peek, truncated, err } } l := logger.Trace() if h.traceResponses && l.Enabled() { - body, err := dumpHttpResponse(req, resp, whole, h.traceMaxResponseBodySize, func(method, uri, content string, truncated bool) { + body, err := ochttp.DumpHttpResponse(resp, whole, h.traceMaxResponseBodySize, func(method, uri, content string, truncated bool) { l.Str(logProto, logProtoJmap).Str(logType, logTypeResponse).Str(logEndpoint, endpoint). Str(logMethod, method).Str(logUri, uri). Str(logHttpStatus, log.SafeString(resp.Status)).Int(logHttpStatusCode, resp.StatusCode). Dur(logDuration, duration). Msg(content) - }, func(err error) { logger.Error().Err(err).Msg("failed to close response body") }) //NOSONAR + }) if err != nil { return body, peek, truncated, err } else { @@ -296,7 +285,7 @@ func (h *HttpJmapClient) GetSession(ctx context.Context, sessionUrl *url.URL, us } // dump the response regardless of the status code - body, _, _, err := h.response(responseBodyPeekSize, ctx, logger, username, endpoint, duration, req, res) + body, _, _, err := h.response(responseBodyPeekSize, ctx, logger, username, endpoint, duration, res) // since we are not returning a stream from this function, we have to close the response body // before leaving the scope of the function @@ -383,7 +372,7 @@ func (h *HttpJmapClient) Command(operation Operation, request Request, ctx Conte // caller won't be able to read the response; instead, it is up to the caller to close the // ReadCloser we are returning from here - body, _, _, err := h.response(responseBodyPeekSize, cotx, logger, session.Username, endpoint, duration, req, res) + body, _, _, err := h.response(responseBodyPeekSize, cotx, logger, session.Username, endpoint, duration, res) language := Language(res.Header.Get("Content-Language")) //NOSONAR if err != nil { logger.Error().Err(err).Msg("failed to read response body") @@ -442,12 +431,7 @@ func (h *HttpJmapClient) UploadBinary(uploadUrl string, operation Operation, end return UploadedBlob{}, "", jmapError(err, JmapErrorSendingRequest) } - // note that we are omitting the usual deferred response body closer here since we are returning - // an io.ReadCloser to the body and if we close it when leaving the scope of this function, the - // caller won't be able to read the response; instead, it is up to the caller to close the - // ReadCloser we are returning from here - - responseBody, _, _, err := h.response(responseBodyPeekSize, cotx, logger, session.Username, endpoint, duration, req, res) + responseBody, _, _, err := h.response(responseBodyPeekSize, cotx, logger, session.Username, endpoint, duration, res) // since we are not returning a stream from this function, we have to close the response body // before leaving the scope of the function @@ -519,7 +503,7 @@ func (h *HttpJmapClient) DownloadBinary(downloadUrl string, operation Operation, // caller won't be able to read the response; instead, it is up to the caller to close the // ReadCloser we are returning from here - responseBody, _, _, err := h.response(responseBodyPeekSize, cotx, logger, session.Username, endpoint, duration, req, res) + responseBody, _, _, err := h.response(responseBodyPeekSize, cotx, logger, session.Username, endpoint, duration, res) language := Language(res.Header.Get("Content-Language")) if err != nil { @@ -656,14 +640,12 @@ func (w *HttpWsClientFactory) connect(operation Operation, ctx context.Context, { l := logger.Trace() if w.traceWsResponses && l.Enabled() { - body, err = dumpHttpResponse(nil, res, res.Body, w.traceMaxResponseBodySize, func(method, uri, content string, truncated bool) { + body, err = ochttp.DumpHttpResponse(res, res.Body, w.traceMaxResponseBodySize, func(method, uri, content string, truncated bool) { l.Str(logProto, logProtoJmap).Str(logType, logTypeResponse).Str(logEndpoint, endpoint). Str(logMethod, method).Str(logUri, uri). Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode). Dur(logDuration, duration). Msg(content) - }, func(err error) { - logger.Error().Err(err).Msg("failed to close response body") //NOSONAR }) } } diff --git a/pkg/jmap/http_test.go b/pkg/jmap/http_test.go index a8e1d4a534..362fd237b9 100644 --- a/pkg/jmap/http_test.go +++ b/pkg/jmap/http_test.go @@ -13,49 +13,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestPeekResponse(t *testing.T) { - require := require.New(t) - n := 6 - text := "hello, world" - - require.Less(n, len(text)) - body, peek, truncated, err := peekResponse(io.NopCloser(strings.NewReader(text)), int64(n), func(err error) { - require.NoError(err) - }) - require.NoError(err) - require.True(truncated) - require.Equal(text[:n], string(peek)) - buf := new(strings.Builder) - _, err = io.Copy(buf, body) - require.NoError(err) - require.Equal(text, buf.String()) -} - -func TestDumpHttpResponse(t *testing.T) { - require := require.New(t) - n := 6 - text := "hello, world" - - resp := &http.Response{ - Status: "200 OK", - StatusCode: 200, - Header: http.Header{}, - Body: nil, - } - - body, err := dumpHttpResponse(nil, resp, io.NopCloser(strings.NewReader(text)), int64(n), func(method, uri, content string, truncated bool) { - require.True(truncated) - require.Equal(fmt.Sprintf("200 OK\n%s", text[:n]), content) - }, func(err error) { - require.NoError(err) - }) - require.NoError(err) - buf := new(strings.Builder) - _, err = io.Copy(buf, body) - require.NoError(err) - require.Equal(text, buf.String()) -} - type testNopReadCloser struct { io.Reader closed bool @@ -119,7 +76,7 @@ func TestResponse(t *testing.T) { traceResponses: true, traceMaxResponseBodySize: int64(tr.traceSize), } - body, peek, truncated, err := client.response(int64(tt.peekSize), context.TODO(), &logger, "username", "https://stalwart", time.Duration(1*time.Second), nil, resp) + body, peek, truncated, err := client.response(int64(tt.peekSize), context.TODO(), &logger, "username", "https://stalwart", time.Duration(1*time.Second), resp) require.NoError(err) if b.withBody { diff --git a/pkg/jmap/tools.go b/pkg/jmap/tools.go index 720e6f3285..3890774c42 100644 --- a/pkg/jmap/tools.go +++ b/pkg/jmap/tools.go @@ -1,12 +1,9 @@ package jmap import ( - "bytes" "encoding/json" "errors" "fmt" - "io" - "net/http" "reflect" "slices" "strings" @@ -450,142 +447,3 @@ func ns(namespaces ...JmapNamespace) []JmapNamespace { func ptr[T any | int | uint | bool | string](t T) *T { return &t } - -type nullReader struct { -} - -func (r nullReader) Read(p []byte) (n int, err error) { - return 0, nil -} - -func (r nullReader) Close() error { - return nil -} - -var _ io.ReadCloser = nullReader{} - -func multiReader(peekBuffer *bytes.Buffer, body io.Reader) io.Reader { - readers := []io.Reader{} - if peekBuffer != nil { - readers = append(readers, peekBuffer) - } - if body != nil && body != http.NoBody { - readers = append(readers, body) - } - switch len(readers) { - case 0: - return http.NoBody - case 1: - return readers[0] - default: - return io.MultiReader(readers...) - } -} - -func peekResponse(body io.ReadCloser, size int64, - closeErrorClosure func(err error)) (io.ReadCloser, []byte, bool, error) { - var peekBuffer *bytes.Buffer = nil - truncated := false - peek := []byte{} - if body != nil && body != http.NoBody && size > 0 { - peekBuffer = new(bytes.Buffer) - limitedReader := io.LimitReader(body, size) - tee := io.TeeReader(limitedReader, peekBuffer) - if _, err := io.Copy(io.Discard, tee); err != nil { - if err := body.Close(); err != nil { - closeErrorClosure(err) - } - return nil, peek, false, err - } - - if int64(peekBuffer.Len()) >= size { - truncated = true - } - - peek = peekBuffer.Bytes() - } - - return readCloser{ - closer: body, - Reader: multiReader(peekBuffer, body), - }, peek, truncated, nil -} - -func dumpHttpRequest(req *http.Request, maxBodySize int64, closure func(method string, uri string, content string, truncated bool)) error { - var logBuilder bytes.Buffer - uri := req.URL.String() - fmt.Fprintf(&logBuilder, "%s %s\n", req.Method, uri) - for key, values := range req.Header { - if key == "Authorization" { - continue - } - for _, value := range values { - fmt.Fprintf(&logBuilder, "%s: %s\n", key, value) - } - } - truncated := false - if maxBodySize >= 0 && req.Body != nil && req.Body != http.NoBody { - peekBuffer := new(bytes.Buffer) - limitedReader := io.LimitReader(req.Body, maxBodySize) - tee := io.TeeReader(limitedReader, peekBuffer) - if _, err := io.Copy(io.Discard, tee); err != nil { - return fmt.Errorf("failed to peek at the request body for tracing: %v", err) - } else { - logBuilder.Write(peekBuffer.Bytes()) - if int64(peekBuffer.Len()) >= maxBodySize { - truncated = true - } - fullBodyReader := io.MultiReader(peekBuffer, req.Body) - req.Body = io.NopCloser(fullBodyReader) - } - } - closure(req.Method, uri, logBuilder.String(), truncated) - return nil -} - -func dumpHttpResponse(req *http.Request, resp *http.Response, body io.ReadCloser, maxBodySize int64, - closure func(method string, uri string, content string, truncated bool), - closeErrorClosure func(err error)) (io.ReadCloser, error) { - var logBuilder bytes.Buffer - fmt.Fprintf(&logBuilder, "%s\n", resp.Status) - for key, values := range resp.Header { - for _, value := range values { - fmt.Fprintf(&logBuilder, "%s: %s\n", key, value) - } - } - - var peekBuffer *bytes.Buffer = nil - truncated := false - if body != nil && body != http.NoBody && maxBodySize > 0 { - peekBuffer = new(bytes.Buffer) - limitedReader := io.LimitReader(body, maxBodySize) - tee := io.TeeReader(limitedReader, peekBuffer) - if _, err := io.Copy(io.Discard, tee); err != nil { - if err := body.Close(); err != nil { - closeErrorClosure(err) - } - return nil, err - } - - if int64(peekBuffer.Len()) >= maxBodySize { - truncated = true - } - - logBuilder.Write(peekBuffer.Bytes()) - } - - if req != nil { - closure(req.Method, req.URL.String(), logBuilder.String(), truncated) - } else { - closure("", "", logBuilder.String(), truncated) - } - - if peekBuffer != nil { - return readCloser{ - closer: body, - Reader: multiReader(peekBuffer, body), - }, nil - } else { - return body, nil - } -}