diff --git a/.vscode/launch.json b/.vscode/launch.json index 84e6a4e454..30bba8a584 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -138,12 +138,17 @@ "OC_SERVICE_ACCOUNT_SECRET": "service-account-secret", "OC_ADD_RUN_SERVICES": "auth-api,groupware", + "GROUPWARE_LOG_LEVEL": "trace", - + "GROUPWARE_HTTP_TRACE_REQUESTS": "true", + "GROUPWARE_HTTP_TRACE_MAX_REQUEST_BODY_SIZE": "8192", + "GROUPWARE_HTTP_TRACE_RESPONSES": "true", + "GROUPWARE_HTTP_TRACE_MAX_RESPONSE_BODY_SIZE": "8192", "GROUPWARE_JMAP_MASTER_USERNAME": "admin@example.org", "GROUPWARE_JMAP_MASTER_PASSWORD": "admin", - "GROUPWARE_SEND_DURATIONS_RESPONSE": "true", + "GROUPWARE_ALLOW_INSECURE_TLS": "true", + "GROUPWARE_ENABLE_MOCK_DATA": "true", "AUTHAPI_HTTP_ADDR": "0.0.0.0:10000", "AUTHAPI_AUTH_REQUIRE_SHARED_SECRET": "true", @@ -151,9 +156,7 @@ "WEB_ASSET_CORE_PATH": "${workspaceFolder}/../web/dist", "WEB_UI_CONFIG_FILE": "${workspaceFolder}/../web/dev/docker/opencloud.web.config.json", - "FRONTEND_GROUPWARE_ENABLED": "true", - - "GROUPWARE_ENABLE_MOCK_DATA": "true" + "FRONTEND_GROUPWARE_ENABLED": "true" } }, { diff --git a/pkg/jmap/api.go b/pkg/jmap/api.go index 3c9bc20dbf..b5910057e4 100644 --- a/pkg/jmap/api.go +++ b/pkg/jmap/api.go @@ -26,7 +26,7 @@ func (c Context) WithContext(newContext context.Context) Context { } type ApiClient interface { - Command(request Request, ctx Context) ([]byte, Language, Error) + Command(request Request, ctx Context) (io.ReadCloser, Language, Error) io.Closer } diff --git a/pkg/jmap/api_blob.go b/pkg/jmap/api_blob.go index 87be34a1ae..8d31d1e405 100644 --- a/pkg/jmap/api_blob.go +++ b/pkg/jmap/api_blob.go @@ -3,6 +3,7 @@ package jmap import ( "encoding/base64" "io" + "net/url" "strings" "github.com/opencloud-eu/opencloud/pkg/log" @@ -44,20 +45,21 @@ type UploadedBlobWithHash struct { func (j *Client) UploadBlobStream(accountId AccountId, contentType string, body io.Reader, ctx Context) (UploadedBlob, Language, error) { logger := log.From(ctx.Logger.With().Str(logEndpoint, ctx.Session.UploadEndpoint)) ctx = ctx.WithLogger(logger) - // TODO(pbleser-oc) use a library for proper URL template parsing - uploadUrl := strings.ReplaceAll(ctx.Session.UploadUrlTemplate, "{accountId}", string(accountId)) + uploadUrl := strings.NewReplacer( + "{accountId}", url.PathEscape(string(accountId)), + ).Replace(ctx.Session.UploadUrlTemplate) return j.blob.UploadBinary(uploadUrl, ctx.Session.UploadEndpoint, contentType, body, ctx) } func (j *Client) DownloadBlobStream(accountId AccountId, blobId string, name string, typ string, ctx Context) (*BlobDownload, Language, error) { //NOSONAR logger := log.From(ctx.Logger.With().Str(logEndpoint, ctx.Session.DownloadEndpoint)) ctx = ctx.WithLogger(logger) - // TODO(pbleser-oc) use a library for proper URL template parsing - downloadUrl := ctx.Session.DownloadUrlTemplate - downloadUrl = strings.ReplaceAll(downloadUrl, "{accountId}", string(accountId)) - downloadUrl = strings.ReplaceAll(downloadUrl, "{blobId}", blobId) - downloadUrl = strings.ReplaceAll(downloadUrl, "{name}", name) - downloadUrl = strings.ReplaceAll(downloadUrl, "{type}", typ) + downloadUrl := strings.NewReplacer( + "{accountId}", url.PathEscape(string(accountId)), + "{blobId}", url.PathEscape(blobId), + "{name}", url.PathEscape(name), + "{type}", url.PathEscape(typ), + ).Replace(ctx.Session.DownloadUrlTemplate) logger = log.From(logger.With().Str(logDownloadUrl, downloadUrl).Str(logBlobId, blobId)) return j.blob.DownloadBinary(downloadUrl, ctx.Session.DownloadEndpoint, ctx) } diff --git a/pkg/jmap/error.go b/pkg/jmap/error.go index d632b7bbfd..1274256249 100644 --- a/pkg/jmap/error.go +++ b/pkg/jmap/error.go @@ -41,6 +41,7 @@ const ( JmapErrorInvalidObjectState JmapErrorPatchObjectSerialization JmapErrorInvalidProperties + JmapErrorRequestTracing ) var ( diff --git a/pkg/jmap/export_integration_test.go b/pkg/jmap/export_integration_test.go index 6abc5018d6..83bed572b4 100644 --- a/pkg/jmap/export_integration_test.go +++ b/pkg/jmap/export_integration_test.go @@ -660,7 +660,7 @@ func createJmapClient(container *testcontainers.DockerContainer, ctx context.Con eventListener := nullHttpJmapApiClientEventListener{} - api := NewHttpJmapClient(&jh, auth, eventListener) + api := NewHttpJmapClient(&jh, auth, eventListener, true, 8192, true, 8192) wscf, err := NewHttpWsClientFactory(wsd, auth, logger, eventListener) if err != nil { diff --git a/pkg/jmap/http.go b/pkg/jmap/http.go index 9edc9227a8..aeecfd2d28 100644 --- a/pkg/jmap/http.go +++ b/pkg/jmap/http.go @@ -14,6 +14,8 @@ import ( "net/url" "slices" "strconv" + "strings" + "time" "github.com/gorilla/websocket" "github.com/opencloud-eu/opencloud/pkg/log" @@ -23,10 +25,14 @@ import ( // Implementation of ApiClient, SessionClient and BlobClient that uses // HTTP to perform JMAP operations. type HttpJmapClient struct { - client *http.Client - userAgent string - authenticator HttpJmapClientAuthenticator - listener HttpJmapApiClientEventListener + client *http.Client + userAgent string + authenticator HttpJmapClientAuthenticator + listener HttpJmapApiClientEventListener + traceRequests bool + traceMaxRequestBodySize int64 + traceResponses bool + traceMaxResponseBodySize int64 } var ( @@ -37,6 +43,8 @@ var ( const ( logEndpoint = "endpoint" + logUri = "uri" + logMethod = "method" logHttpStatus = "status" logHttpStatusCode = "status-code" logHttpUrl = "url" @@ -47,6 +55,8 @@ const ( logTypeRequest = "request" logTypeResponse = "response" logTypePush = "push" + logDuration = "duration" + logBodyTruncated = "truncated" ) // Record JMAP HTTP execution events that may occur, e.g. using metrics. @@ -103,21 +113,30 @@ func NewMasterAuthHttpJmapClientAuthenticator(masterUser string, masterPassword var _ HttpJmapClientAuthenticator = &MasterAuthHttpJmapClientAuthenticator{} -func (h *MasterAuthHttpJmapClientAuthenticator) Authenticate(ctx context.Context, username string, _ *log.Logger, req *http.Request) Error { - u := username +func (h *MasterAuthHttpJmapClientAuthenticator) auth(username string, headers http.Header) { + // not nice to read, but heavily optimized to prevent needless memory allocations, since + // this hot path will be used all the time + var sb strings.Builder + sb.WriteString("Basic ") + enc := base64.NewEncoder(base64.StdEncoding, &sb) + enc.Write([]byte(username)) if username != h.masterUser { - u = username + "%" + h.masterUser + enc.Write([]byte("%")) + enc.Write([]byte(h.masterUser)) } - req.SetBasicAuth(u, h.masterPassword) + enc.Write([]byte(":")) + enc.Write([]byte(h.masterPassword)) + enc.Close() + headers.Set("Authorization", sb.String()) +} + +func (h *MasterAuthHttpJmapClientAuthenticator) Authenticate(_ context.Context, username string, _ *log.Logger, req *http.Request) Error { + h.auth(username, req.Header) return nil } -func (h *MasterAuthHttpJmapClientAuthenticator) AuthenticateWS(ctx context.Context, username string, _ *log.Logger, headers http.Header) Error { - u := username - if username != h.masterUser { - u = username + "%" + h.masterUser - } - headers.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(u+":"+h.masterPassword))) +func (h *MasterAuthHttpJmapClientAuthenticator) AuthenticateWS(_ context.Context, username string, _ *log.Logger, headers http.Header) Error { + h.auth(username, headers) return nil } @@ -126,12 +145,19 @@ func NullHttpJmapApiClientEventListener() HttpJmapApiClientEventListener { return nullHttpJmapApiClientEventListener{} } -func NewHttpJmapClient(client *http.Client, authenticator HttpJmapClientAuthenticator, listener HttpJmapApiClientEventListener) *HttpJmapClient { +func NewHttpJmapClient(client *http.Client, authenticator HttpJmapClientAuthenticator, listener HttpJmapApiClientEventListener, + traceRequests bool, traceMaxRequestBodySize int64, + traceResponses bool, traceMaxResponseBodySize int64, +) *HttpJmapClient { return &HttpJmapClient{ - client: client, - authenticator: authenticator, - userAgent: "OpenCloud/" + version.GetString(), - listener: listener, + client: client, + authenticator: authenticator, + userAgent: "OpenCloud/" + version.GetString(), + listener: listener, + traceRequests: traceRequests, + traceMaxRequestBodySize: traceMaxRequestBodySize, + traceResponses: traceResponses, + traceMaxResponseBodySize: traceMaxResponseBodySize, } } @@ -159,6 +185,102 @@ var ( errNilBaseUrl = errors.New("sessionUrl is nil") ) +func (h *HttpJmapClient) beforeRequest(_ context.Context, logger *log.Logger, _ string, endpoint string, req *http.Request) Error { + l := logger.Trace() + if h.traceRequests && l.Enabled() { + var logBuilder bytes.Buffer + for key, values := range req.Header { + if key == "Authorization" { + continue + } + for _, value := range values { + fmt.Fprintf(&logBuilder, "%s: %s\n", key, value) + } + } + if h.traceMaxRequestBodySize >= 0 && req.Body != nil && req.Body != http.NoBody { + peekBuffer := new(bytes.Buffer) + limitedReader := io.LimitReader(req.Body, h.traceMaxRequestBodySize) + tee := io.TeeReader(limitedReader, peekBuffer) + if _, err := io.Copy(io.Discard, tee); err != nil { + return jmapError(fmt.Errorf("failed to peek at the request body for tracing: %v", err), JmapErrorRequestTracing) + } else { + logBuilder.Write(peekBuffer.Bytes()) + if int64(peekBuffer.Len()) >= h.traceMaxRequestBodySize { + l = l.Bool(logBodyTruncated, true) + } + fullBodyReader := io.MultiReader(peekBuffer, req.Body) + req.Body = io.NopCloser(fullBodyReader) + } + } + l.Str(logMethod, req.Method).Str(logUri, req.URL.String()). + Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeRequest). + Msg(logBuilder.String()) + } + return nil +} + +type httpResponseReadCloser struct { + io.Reader + body io.ReadCloser +} + +func (c httpResponseReadCloser) Close() error { + if c.body != nil { + return c.body.Close() + } + return nil +} + +func (h *HttpJmapClient) response(_ context.Context, logger *log.Logger, _ string, endpoint string, duration time.Duration, req *http.Request, resp *http.Response) (io.ReadCloser, error) { + l := logger.Trace() + if h.traceResponses && l.Enabled() { + var logBuilder bytes.Buffer + for key, values := range resp.Header { + for _, value := range values { + fmt.Fprintf(&logBuilder, "%s: %s\n", key, value) + } + } + + var peekBuffer *bytes.Buffer = nil + if h.traceMaxResponseBodySize > 0 { + peekBuffer = new(bytes.Buffer) + limitedReader := io.LimitReader(resp.Body, h.traceMaxResponseBodySize) + tee := io.TeeReader(limitedReader, peekBuffer) + if _, err := io.Copy(io.Discard, tee); err != nil { + if resp.Body != nil { + if err := resp.Body.Close(); err != nil { + logger.Error().Err(err).Msg("failed to close response body") //NOSONAR + } + } + return nil, err + } + + if int64(peekBuffer.Len()) >= h.traceMaxResponseBodySize { + l = l.Bool(logBodyTruncated, true) + } + + logBuilder.Write(peekBuffer.Bytes()) + } + + l.Str(logProto, logProtoJmap).Str(logType, logTypeResponse).Str(logEndpoint, endpoint). + Str(logMethod, req.Method).Str(logUri, req.URL.String()). + Str(logHttpStatus, log.SafeString(resp.Status)).Int(logHttpStatusCode, resp.StatusCode). + Dur(logDuration, duration). + Msg(logBuilder.String()) + + if peekBuffer != nil { + return httpResponseReadCloser{ + body: resp.Body, + Reader: io.MultiReader(peekBuffer, resp.Body), + }, nil + } else { + return resp.Body, nil + } + } else { + return resp.Body, nil + } +} + func (h *HttpJmapClient) GetSession(ctx context.Context, sessionUrl *url.URL, username string, logger *log.Logger) (SessionResponse, Error) { if sessionUrl == nil { logger.Error().Msg("sessionUrl is nil") @@ -179,17 +301,38 @@ func (h *HttpJmapClient) GetSession(ctx context.Context, sessionUrl *url.URL, us logger.Error().Err(err).Msgf("failed to create GET request for %v", sessionUrl) return SessionResponse{}, jmapError(err, JmapErrorInvalidHttpRequest) } + req.Header.Add("Cache-Control", "no-cache, no-store, must-revalidate") // spec recommendation + req.Header.Add("Content-Type", "application/json") //NOSONAR + req.Header.Add("User-Agent", h.userAgent) //NOSONAR + + if err := h.beforeRequest(ctx, logger, username, endpoint, req); err != nil { + return SessionResponse{}, err + } + if err := h.auth(ctx, username, logger, req); err != nil { return SessionResponse{}, err } - req.Header.Add("Cache-Control", "no-cache, no-store, must-revalidate") // spec recommendation + before := time.Now() res, err := h.client.Do(req) + duration := time.Since(before) if err != nil { h.listener.OnFailedRequest(endpoint, err) logger.Error().Err(err).Msgf("failed to perform GET %v", sessionUrl) return SessionResponse{}, jmapError(err, JmapErrorInvalidHttpRequest) } + + // dump the response regardless of the status code + body, err := h.response(ctx, logger, username, endpoint, duration, req, 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 + defer func() { + if err := body.Close(); err != nil { + logger.Error().Err(err).Msg("failed to close response body") //NOSONAR + } + }() + if res.StatusCode < 200 || res.StatusCode > 299 { h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode) logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 200") @@ -197,16 +340,6 @@ func (h *HttpJmapClient) GetSession(ctx context.Context, sessionUrl *url.URL, us } h.listener.OnSuccessfulRequest(endpoint, res.StatusCode) - if res.Body != nil { - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - logger.Error().Err(err).Msg("failed to close response body") //NOSONAR - } - }(res.Body) - } - - body, err := io.ReadAll(res.Body) if err != nil { logger.Error().Err(err).Msg("failed to read response body") //NOSONAR h.listener.OnResponseBodyReadingError(endpoint, err) @@ -214,8 +347,7 @@ func (h *HttpJmapClient) GetSession(ctx context.Context, sessionUrl *url.URL, us } var data SessionResponse - err = json.Unmarshal(body, &data) - if err != nil { + if err := json.NewDecoder(body).Decode(&data); err != nil { logger.Error().Str(logHttpUrl, log.SafeString(sessionUrlStr)).Err(err).Msg("failed to decode JSON payload from .well-known/jmap response") h.listener.OnResponseBodyUnmarshallingError(endpoint, err) return SessionResponse{}, jmapError(err, JmapErrorDecodingResponseBody) @@ -224,7 +356,7 @@ func (h *HttpJmapClient) GetSession(ctx context.Context, sessionUrl *url.URL, us return data, nil } -func (h *HttpJmapClient) Command(request Request, ctx Context) ([]byte, Language, Error) { //NOSONAR +func (h *HttpJmapClient) Command(request Request, ctx Context) (io.ReadCloser, Language, Error) { //NOSONAR session := ctx.Session logger := ctx.Logger acceptLanguage := ctx.AcceptLanguage @@ -255,55 +387,42 @@ func (h *HttpJmapClient) Command(request Request, ctx Context) ([]byte, Language req.Header.Add("Content-Type", "application/json") //NOSONAR req.Header.Add("User-Agent", h.userAgent) //NOSONAR - if logger.Trace().Enabled() { - requestBytes, err := httputil.DumpRequestOut(req, true) - if err == nil { - logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeRequest).Msg(string(requestBytes)) - } - } + h.beforeRequest(cotx, logger, session.Username, endpoint, req) + if err := h.auth(cotx, session.Username, logger, req); err != nil { return nil, "", err } + before := time.Now() res, err := h.client.Do(req) + duration := time.Since(before) if err != nil { h.listener.OnFailedRequest(endpoint, err) logger.Error().Err(err).Msgf("failed to perform POST %v", jmapUrl) return nil, "", jmapError(err, JmapErrorSendingRequest) } - if logger.Trace().Enabled() { - responseBytes, err := httputil.DumpResponse(res, true) - if err == nil { - logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeResponse). - Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode). - Msg(string(responseBytes)) - } - } + // 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 + body, err := h.response(cotx, logger, session.Username, endpoint, duration, req, res) language := Language(res.Header.Get("Content-Language")) //NOSONAR - if res.StatusCode < 200 || res.StatusCode > 299 { - h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode) - logger.Error().Str(logEndpoint, endpoint).Str(logHttpStatus, log.SafeString(res.Status)).Msg("HTTP response status code is not 2xx") //NOSONAR - return nil, language, jmapError(err, JmapErrorServerResponse) - } - if res.Body != nil { - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - logger.Error().Err(err).Msg("failed to close response body") - } - }(res.Body) - } - h.listener.OnSuccessfulRequest(endpoint, res.StatusCode) - - body, err := io.ReadAll(res.Body) if err != nil { logger.Error().Err(err).Msg("failed to read response body") h.listener.OnResponseBodyReadingError(endpoint, err) return nil, language, jmapError(err, JmapErrorServerResponse) } + if res.StatusCode < 200 || res.StatusCode > 299 { + h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode) + logger.Error().Str(logEndpoint, endpoint).Str(logHttpStatus, log.SafeString(res.Status)).Msg("HTTP response status code is not 2xx") //NOSONAR + return nil, language, jmapError(err, JmapErrorServerResponse) + } + + h.listener.OnSuccessfulRequest(endpoint, res.StatusCode) + return body, language, nil } @@ -325,58 +444,51 @@ func (h *HttpJmapClient) UploadBinary(uploadUrl string, endpoint string, content if acceptLanguage != "" { req.Header.Add("Accept-Language", acceptLanguage) } - if logger.Trace().Enabled() { - requestBytes, err := httputil.DumpRequestOut(req, false) - if err == nil { - logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeRequest).Msg(string(requestBytes)) - } - } + h.beforeRequest(cotx, logger, session.Username, endpoint, req) if err := h.auth(cotx, session.Username, logger, req); err != nil { return UploadedBlob{}, "", err } + before := time.Now() res, err := h.client.Do(req) + duration := time.Since(before) if err != nil { h.listener.OnFailedRequest(endpoint, err) logger.Error().Err(err).Msgf("failed to perform POST %v", uploadUrl) return UploadedBlob{}, "", jmapError(err, JmapErrorSendingRequest) } - if logger.Trace().Enabled() { - responseBytes, err := httputil.DumpResponse(res, true) - if err == nil { - logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeResponse). - Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode). - Msg(string(responseBytes)) + + // 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(cotx, logger, session.Username, endpoint, duration, req, 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 + defer func() { + if err := responseBody.Close(); err != nil { + logger.Error().Err(err).Msg("failed to close response body") //NOSONAR } - } - + }() language := Language(res.Header.Get("Content-Language")) - if res.StatusCode < 200 || res.StatusCode > 299 { - h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode) - logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 2xx") - return UploadedBlob{}, language, jmapError(err, JmapErrorServerResponse) - } - if res.Body != nil { - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - logger.Error().Err(err).Msg("failed to close response body") - } - }(res.Body) - } - h.listener.OnSuccessfulRequest(endpoint, res.StatusCode) - - responseBody, err := io.ReadAll(res.Body) if err != nil { logger.Error().Err(err).Msg("failed to read response body") h.listener.OnResponseBodyReadingError(endpoint, err) return UploadedBlob{}, language, jmapError(err, JmapErrorServerResponse) } + if res.StatusCode < 200 || res.StatusCode > 299 { + h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode) + logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 2xx") + return UploadedBlob{}, language, jmapError(err, JmapErrorServerResponse) + } + h.listener.OnSuccessfulRequest(endpoint, res.StatusCode) + var result UploadedBlob - err = json.Unmarshal(responseBody, &result) - if err != nil { + if err := json.NewDecoder(responseBody).Decode(&result); err != nil { logger.Error().Str(logHttpUrl, log.SafeString(uploadUrl)).Err(err).Msg("failed to decode JSON payload from the upload response") h.listener.OnResponseBodyUnmarshallingError(endpoint, err) return UploadedBlob{}, language, jmapError(err, JmapErrorDecodingResponseBody) @@ -402,32 +514,35 @@ func (h *HttpJmapClient) DownloadBinary(downloadUrl string, endpoint string, ctx if acceptLanguage != "" { req.Header.Add("Accept-Language", acceptLanguage) } - if logger.Trace().Enabled() { - requestBytes, err := httputil.DumpRequestOut(req, true) - if err == nil { - logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeRequest).Msg(string(requestBytes)) - } - } + h.beforeRequest(cotx, logger, session.Username, endpoint, req) if err := h.auth(cotx, session.Username, logger, req); err != nil { return nil, "", err } + before := time.Now() res, err := h.client.Do(req) + duration := time.Since(before) if err != nil { h.listener.OnFailedRequest(endpoint, err) logger.Error().Err(err).Msgf("failed to perform GET %v", downloadUrl) return nil, "", jmapError(err, JmapErrorSendingRequest) } - if logger.Trace().Enabled() { - responseBytes, err := httputil.DumpResponse(res, false) - if err == nil { - logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeResponse). - Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode). - Msg(string(responseBytes)) - } - } + + // 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(cotx, logger, session.Username, endpoint, duration, req, res) + language := Language(res.Header.Get("Content-Language")) + if err != nil { + logger.Error().Err(err).Msg("failed to read response body") + h.listener.OnResponseBodyReadingError(endpoint, err) + return nil, language, jmapError(err, JmapErrorServerResponse) + } + if res.StatusCode == http.StatusNotFound { return nil, language, nil } @@ -449,7 +564,7 @@ func (h *HttpJmapClient) DownloadBinary(downloadUrl string, endpoint string, ctx } return &BlobDownload{ - Body: res.Body, + Body: responseBody, Size: size, Type: res.Header.Get("Content-Type"), ContentDisposition: res.Header.Get("Content-Disposition"), diff --git a/pkg/jmap/tools.go b/pkg/jmap/tools.go index 7bb7d8c8c1..ffa7ced3f8 100644 --- a/pkg/jmap/tools.go +++ b/pkg/jmap/tools.go @@ -77,12 +77,18 @@ func command[T any](client Cmdr, //NOSONAR } var response Response - err := json.Unmarshal(responseBody, &response) - if err != nil { + if err := json.NewDecoder(responseBody).Decode(&response); err != nil { logger.Error().Err(err).Msgf("failed to deserialize body JSON payload into a %T", response) + if err := responseBody.Close(); err != nil { + logger.Error().Err(err).Msg("failed to close response body") //NOSONAR + } return ZeroResult[T](single(duration)), jmapError(err, JmapErrorDecodingResponseBody) } + if err := responseBody.Close(); err != nil { + logger.Error().Err(err).Msg("failed to close response body") //NOSONAR + } + if response.SessionState != ctx.Session.State { client.OnSessionOutdated(ctx.Session, response.SessionState) } @@ -126,7 +132,7 @@ func command[T any](client Cmdr, //NOSONAR code = JmapErrorAccountReadOnly } msg := fmt.Sprintf("found method level error in response '%v', type: '%v', description: '%v'", mr.Tag, errorParameters.Type, errorParameters.Description) - err = errors.New(msg) + err := errors.New(msg) logger.Warn().Int("code", code).Str("type", errorParameters.Type).Msg(msg) return newPartialResult[T](response.SessionState, language, single(duration)), jmapResponseError(code, err, errorParameters.Type, errorParameters.Description) } else { diff --git a/services/groupware/pkg/config/defaults/defaultconfig.go b/services/groupware/pkg/config/defaults/defaultconfig.go index 7e2e502029..50bb9a159b 100644 --- a/services/groupware/pkg/config/defaults/defaultconfig.go +++ b/services/groupware/pkg/config/defaults/defaultconfig.go @@ -52,8 +52,13 @@ func DefaultConfig() *config.Config { AllowedHeaders: []string{"Authorization", "Origin", "Content-Type", "Accept", "X-Requested-With", "X-Request-Id", "Trace-Id", "Cache-Control"}, AllowCredentials: true, }, - OpenCloudPublicURL: "https://localhost:9200/", - SendDurationsResponse: false, + OpenCloudPublicURL: "https://localhost:9200/", + SendDurationsResponse: false, + Insecure: false, + TraceRequests: true, + TraceMaxRequestBodySize: 4 * 1024, + TraceResponses: true, + TraceMaxResponseBodySize: 4 * 1024, }, Service: config.Service{ Name: "groupware", diff --git a/services/groupware/pkg/config/http.go b/services/groupware/pkg/config/http.go index d939ed4036..4f4a507459 100644 --- a/services/groupware/pkg/config/http.go +++ b/services/groupware/pkg/config/http.go @@ -12,11 +12,16 @@ type CORS struct { // HTTP defines the available http configuration. type HTTP struct { - Addr string `yaml:"addr" env:"GROUPWARE_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"` - TLS shared.HTTPServiceTLS `yaml:"tls"` - Root string `yaml:"root" env:"GROUPWARE_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"` - Namespace string `yaml:"-"` - CORS CORS `yaml:"cors"` - OpenCloudPublicURL string `yaml:"opencloud_public_url" env:"OC_URL;OC_PUBLIC_URL;GROUPWARE_PUBLIC_URL"` - SendDurationsResponse bool `yaml:"send_durations_response" env:"GROUPWARE_SEND_DURATIONS_RESPONSE"` + Addr string `yaml:"addr" env:"GROUPWARE_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"` + TLS shared.HTTPServiceTLS `yaml:"tls"` + Root string `yaml:"root" env:"GROUPWARE_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"` + Namespace string `yaml:"-"` + CORS CORS `yaml:"cors"` + OpenCloudPublicURL string `yaml:"opencloud_public_url" env:"OC_URL;OC_PUBLIC_URL;GROUPWARE_PUBLIC_URL"` + SendDurationsResponse bool `yaml:"send_durations_response" env:"GROUPWARE_SEND_DURATIONS_RESPONSE"` + Insecure bool `yaml:"insecure" env:"GROUPWARE_ALLOW_INSECURE_TLS"` + TraceRequests bool `yaml:"trace_requests" env:"GROUPWARE_HTTP_TRACE_REQUESTS"` + TraceMaxRequestBodySize int64 `yaml:"trace_max_request_body_size" env:"GROUPWARE_HTTP_TRACE_MAX_REQUEST_BODY_SIZE"` + TraceResponses bool `yaml:"trace_responses" env:"GROUPWARE_HTTP_TRACE_RESPONSES"` + TraceMaxResponseBodySize int64 `yaml:"trace_max_response_body_size" env:"GROUPWARE_HTTP_TRACE_MAX_RESPONSE_BODY_SIZE"` } diff --git a/services/groupware/pkg/groupware/api_blob.go b/services/groupware/pkg/groupware/api_blob.go index 9290e34287..c46219ba38 100644 --- a/services/groupware/pkg/groupware/api_blob.go +++ b/services/groupware/pkg/groupware/api_blob.go @@ -84,8 +84,8 @@ func (r *Request) serveBlob(blobId string, name string, typ string, ctx jmap.Con } blob, lang, jerr := r.g.jmap.DownloadBlobStream(accountId, blobId, name, typ, ctx) if blob != nil && blob.Body != nil { - defer func(Body io.ReadCloser) { - err := Body.Close() + defer func(body io.ReadCloser) { + err := body.Close() if err != nil { ctx.Logger.Error().Err(err).Msg("failed to close response body") } diff --git a/services/groupware/pkg/groupware/framework.go b/services/groupware/pkg/groupware/framework.go index 9215fcd9d1..45c5e750aa 100644 --- a/services/groupware/pkg/groupware/framework.go +++ b/services/groupware/pkg/groupware/framework.go @@ -55,6 +55,7 @@ const ( logNewState = "new-state" logCacheEvictionReason = "reason" logCacheType = "type" + logDuration = "duration" ) // Minimalistic representation of a user, containing only the attributes that are @@ -205,7 +206,7 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome useDnsForSessionResolution := false // TODO configuration setting, although still experimental, needs proper unit tests first - insecureTls := true // TODO make configurable + insecureTls := config.HTTP.Insecure sanitize := true // TODO make configurable @@ -247,6 +248,10 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome &httpClient, auth, jmapMetricsAdapter, + config.HTTP.TraceRequests, + config.HTTP.TraceMaxRequestBodySize, + config.HTTP.TraceResponses, + config.HTTP.TraceMaxResponseBodySize, ) defer func() { if err := api.Close(); err != nil { diff --git a/services/groupware/pkg/groupware/session.go b/services/groupware/pkg/groupware/session.go index 3feac3a763..f29ed7b7f9 100644 --- a/services/groupware/pkg/groupware/session.go +++ b/services/groupware/pkg/groupware/session.go @@ -118,21 +118,26 @@ var _ jmap.SessionEventListener = &ttlcacheSessionCache{} func (c *ttlcacheSessionCache) load(key sessionCacheKey, ctx context.Context) cachedSession { username := key.username() + logger := log.From(c.logger.With().Str(logUsername, username)) sessionUrl, gwerr := c.sessionUrlProvider(ctx, username) if gwerr != nil { - c.logger.Warn().Str(logUsername, username).Str(logErrorCode, gwerr.Code).Msgf("failed to determine session URL for '%v'", key) + logger.Warn().Str(logErrorCode, gwerr.Code).Msgf("failed to determine session URL for '%v'", key) now := time.Now() until := now.Add(c.errorTtl) return failedSession{since: now, until: until, err: gwerr} } - session, jerr := c.sessionSupplier(ctx, sessionUrl, username, c.logger) + before := time.Now() + session, jerr := c.sessionSupplier(ctx, sessionUrl, username, logger) + duration := time.Since(before) if jerr != nil { - c.logger.Warn().Str(logUsername, username).Err(jerr).Msgf("failed to create session for '%v'", key) + logger.Warn().Err(jerr).Msgf("failed to create session for '%v'", key) now := time.Now() until := now.Add(c.errorTtl) return failedSession{since: now, until: until, err: groupwareErrorFromJmap(jerr)} } else { - c.logger.Debug().Str(logUsername, username).Msgf("successfully created session for '%v'", key) + if logger.Debug().Enabled() { + logger.Debug().Dur(logDuration, duration).Msgf("successfully created session for '%v' in %f.2ms", key, float64(duration.Microseconds())/float64(1000.0)) + } now := time.Now() until := now.Add(c.successTtl) return succeededSession{since: now, until: until, session: session}