diff --git a/pkg/jmap/jmap_api.go b/pkg/jmap/jmap_api.go index 8676e2f271..d02276b236 100644 --- a/pkg/jmap/jmap_api.go +++ b/pkg/jmap/jmap_api.go @@ -3,6 +3,7 @@ package jmap import ( "context" "io" + "net/url" "github.com/opencloud-eu/opencloud/pkg/log" ) @@ -13,29 +14,24 @@ type ApiClient interface { } type SessionClient interface { - GetSession(username string, logger *log.Logger) (SessionResponse, Error) + GetSession(baseurl *url.URL, username string, logger *log.Logger) (SessionResponse, Error) } type BlobClient interface { - UploadBinary(ctx context.Context, logger *log.Logger, session *Session, uploadUrl string, contentType string, content io.Reader) (UploadedBlob, Error) - DownloadBinary(ctx context.Context, logger *log.Logger, session *Session, downloadUrl string) (*BlobDownload, Error) + UploadBinary(ctx context.Context, logger *log.Logger, session *Session, uploadUrl string, endpoint string, contentType string, content io.Reader) (UploadedBlob, Error) + DownloadBinary(ctx context.Context, logger *log.Logger, session *Session, downloadUrl string, endpoint string) (*BlobDownload, Error) } const ( - logHttpStatusCode = "status" - logOperation = "operation" - logUsername = "username" - logAccountId = "account-id" - logMailboxId = "mailbox-id" - logFetchBodies = "fetch-bodies" - logOffset = "offset" - logLimit = "limit" - logEndpoint = "endpoint" - logDownloadUrl = "downloadurl" - logBlobId = "blobId" - logUploadUrl = "downloadurl" - logSessionState = "session-state" - logSince = "since" - - defaultAccountId = "*" + logOperation = "operation" + logUsername = "username" + logMailboxId = "mailbox-id" + logFetchBodies = "fetch-bodies" + logOffset = "offset" + logLimit = "limit" + logDownloadUrl = "download-url" + logBlobId = "blob-id" + logUploadUrl = "download-url" + logSessionState = "session-state" + logSince = "since" ) diff --git a/pkg/jmap/jmap_api_blob.go b/pkg/jmap/jmap_api_blob.go index 4c2a7779b3..b53edc1de2 100644 --- a/pkg/jmap/jmap_api_blob.go +++ b/pkg/jmap/jmap_api_blob.go @@ -15,11 +15,9 @@ type BlobResponse struct { } func (j *Client) GetBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, id string) (BlobResponse, SessionState, Error) { - aid := session.BlobAccountId(accountId) - cmd, err := request( invocation(CommandBlobUpload, BlobGetCommand{ - AccountId: aid, + AccountId: accountId, Ids: []string{id}, Properties: []string{BlobPropertyData, BlobPropertyDigestSha512, BlobPropertySize}, }, "0"), @@ -56,32 +54,28 @@ type UploadedBlob struct { func (j *Client) UploadBlobStream(accountId string, session *Session, ctx context.Context, logger *log.Logger, contentType string, body io.Reader) (UploadedBlob, Error) { logger = log.From(logger.With().Str(logEndpoint, session.UploadEndpoint)) - aid := session.BlobAccountId(accountId) // TODO(pbleser-oc) use a library for proper URL template parsing - uploadUrl := strings.ReplaceAll(session.UploadUrlTemplate, "{accountId}", aid) - return j.blob.UploadBinary(ctx, logger, session, uploadUrl, contentType, body) + uploadUrl := strings.ReplaceAll(session.UploadUrlTemplate, "{accountId}", accountId) + return j.blob.UploadBinary(ctx, logger, session, uploadUrl, session.UploadEndpoint, contentType, body) } func (j *Client) DownloadBlobStream(accountId string, blobId string, name string, typ string, session *Session, ctx context.Context, logger *log.Logger) (*BlobDownload, Error) { logger = log.From(logger.With().Str(logEndpoint, session.DownloadEndpoint)) - aid := session.BlobAccountId(accountId) // TODO(pbleser-oc) use a library for proper URL template parsing downloadUrl := session.DownloadUrlTemplate - downloadUrl = strings.ReplaceAll(downloadUrl, "{accountId}", aid) + downloadUrl = strings.ReplaceAll(downloadUrl, "{accountId}", accountId) downloadUrl = strings.ReplaceAll(downloadUrl, "{blobId}", blobId) downloadUrl = strings.ReplaceAll(downloadUrl, "{name}", name) downloadUrl = strings.ReplaceAll(downloadUrl, "{type}", typ) - logger = log.From(logger.With().Str(logDownloadUrl, downloadUrl).Str(logBlobId, blobId).Str(logAccountId, aid)) - return j.blob.DownloadBinary(ctx, logger, session, downloadUrl) + logger = log.From(logger.With().Str(logDownloadUrl, downloadUrl).Str(logBlobId, blobId)) + return j.blob.DownloadBinary(ctx, logger, session, downloadUrl, session.DownloadEndpoint) } func (j *Client) UploadBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, data []byte, contentType string) (UploadedBlob, SessionState, Error) { - aid := session.MailAccountId(accountId) - encoded := base64.StdEncoding.EncodeToString(data) upload := BlobUploadCommand{ - AccountId: aid, + AccountId: accountId, Create: map[string]UploadObject{ "0": { Data: []DataSourceObject{{ @@ -93,7 +87,7 @@ func (j *Client) UploadBlob(accountId string, session *Session, ctx context.Cont } getHash := BlobGetRefCommand{ - AccountId: aid, + AccountId: accountId, IdRef: &ResultReference{ ResultOf: "0", Name: CommandBlobUpload, diff --git a/pkg/jmap/jmap_api_email.go b/pkg/jmap/jmap_api_email.go index 8b69a95cd2..e6c6bab6a8 100644 --- a/pkg/jmap/jmap_api_email.go +++ b/pkg/jmap/jmap_api_email.go @@ -31,10 +31,9 @@ type Emails struct { } func (j *Client) GetEmails(accountId string, session *Session, ctx context.Context, logger *log.Logger, ids []string, fetchBodies bool, maxBodyValueBytes uint) (Emails, SessionState, Error) { - aid := session.MailAccountId(accountId) - logger = j.logger(aid, "GetEmails", session, logger) + logger = j.logger(accountId, "GetEmails", session, logger) - get := EmailGetCommand{AccountId: aid, Ids: ids, FetchAllBodyValues: fetchBodies} + get := EmailGetCommand{AccountId: accountId, Ids: ids, FetchAllBodyValues: fetchBodies} if maxBodyValueBytes > 0 { get.MaxBodyValueBytes = maxBodyValueBytes } @@ -56,13 +55,12 @@ func (j *Client) GetEmails(accountId string, session *Session, ctx context.Conte } func (j *Client) GetAllEmails(accountId string, session *Session, ctx context.Context, logger *log.Logger, mailboxId string, offset uint, limit uint, fetchBodies bool, maxBodyValueBytes uint) (Emails, SessionState, Error) { - aid := session.MailAccountId(accountId) - logger = j.loggerParams(aid, "GetAllEmails", session, logger, func(z zerolog.Context) zerolog.Context { + logger = j.loggerParams(accountId, "GetAllEmails", session, logger, func(z zerolog.Context) zerolog.Context { return z.Bool(logFetchBodies, fetchBodies).Uint(logOffset, offset).Uint(logLimit, limit) }) query := EmailQueryCommand{ - AccountId: aid, + AccountId: accountId, Filter: &EmailFilterCondition{InMailbox: mailboxId}, Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}}, CollapseThreads: true, @@ -76,7 +74,7 @@ func (j *Client) GetAllEmails(accountId string, session *Session, ctx context.Co } get := EmailGetRefCommand{ - AccountId: aid, + AccountId: accountId, FetchAllBodyValues: fetchBodies, IdRef: &ResultReference{Name: CommandEmailQuery, Path: "/ids/*", ResultOf: "0"}, } @@ -127,13 +125,12 @@ type EmailsSince struct { } func (j *Client) GetEmailsInMailboxSince(accountId string, session *Session, ctx context.Context, logger *log.Logger, mailboxId string, since string, fetchBodies bool, maxBodyValueBytes uint, maxChanges uint) (EmailsSince, SessionState, Error) { - aid := session.MailAccountId(accountId) - logger = j.loggerParams(aid, "GetEmailsInMailboxSince", session, logger, func(z zerolog.Context) zerolog.Context { + logger = j.loggerParams(accountId, "GetEmailsInMailboxSince", session, logger, func(z zerolog.Context) zerolog.Context { return z.Bool(logFetchBodies, fetchBodies).Str(logSince, since) }) changes := MailboxChangesCommand{ - AccountId: aid, + AccountId: accountId, SinceState: since, } if maxChanges > 0 { @@ -141,7 +138,7 @@ func (j *Client) GetEmailsInMailboxSince(accountId string, session *Session, ctx } getCreated := EmailGetRefCommand{ - AccountId: aid, + AccountId: accountId, FetchAllBodyValues: fetchBodies, IdRef: &ResultReference{Name: CommandMailboxChanges, Path: "/created", ResultOf: "0"}, } @@ -149,7 +146,7 @@ func (j *Client) GetEmailsInMailboxSince(accountId string, session *Session, ctx getCreated.MaxBodyValueBytes = maxBodyValueBytes } getUpdated := EmailGetRefCommand{ - AccountId: aid, + AccountId: accountId, FetchAllBodyValues: fetchBodies, IdRef: &ResultReference{Name: CommandMailboxChanges, Path: "/updated", ResultOf: "0"}, } @@ -201,13 +198,12 @@ func (j *Client) GetEmailsInMailboxSince(accountId string, session *Session, ctx } func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context.Context, logger *log.Logger, since string, fetchBodies bool, maxBodyValueBytes uint, maxChanges uint) (EmailsSince, SessionState, Error) { - aid := session.MailAccountId(accountId) - logger = j.loggerParams(aid, "GetEmailsSince", session, logger, func(z zerolog.Context) zerolog.Context { + logger = j.loggerParams(accountId, "GetEmailsSince", session, logger, func(z zerolog.Context) zerolog.Context { return z.Bool(logFetchBodies, fetchBodies).Str(logSince, since) }) changes := EmailChangesCommand{ - AccountId: aid, + AccountId: accountId, SinceState: since, } if maxChanges > 0 { @@ -215,7 +211,7 @@ func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context. } getCreated := EmailGetRefCommand{ - AccountId: aid, + AccountId: accountId, FetchAllBodyValues: fetchBodies, IdRef: &ResultReference{Name: CommandEmailChanges, Path: "/created", ResultOf: "0"}, } @@ -223,7 +219,7 @@ func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context. getCreated.MaxBodyValueBytes = maxBodyValueBytes } getUpdated := EmailGetRefCommand{ - AccountId: aid, + AccountId: accountId, FetchAllBodyValues: fetchBodies, IdRef: &ResultReference{Name: CommandEmailChanges, Path: "/updated", ResultOf: "0"}, } @@ -282,13 +278,12 @@ type EmailSnippetQueryResult struct { } func (j *Client) QueryEmailSnippets(accountId string, filter EmailFilterElement, session *Session, ctx context.Context, logger *log.Logger, offset uint, limit uint) (EmailSnippetQueryResult, SessionState, Error) { - aid := session.MailAccountId(accountId) - logger = j.loggerParams(aid, "QueryEmails", session, logger, func(z zerolog.Context) zerolog.Context { + logger = j.loggerParams(accountId, "QueryEmails", session, logger, func(z zerolog.Context) zerolog.Context { return z.Uint(logLimit, limit).Uint(logOffset, offset) }) query := EmailQueryCommand{ - AccountId: aid, + AccountId: accountId, Filter: filter, Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}}, CollapseThreads: true, @@ -302,7 +297,7 @@ func (j *Client) QueryEmailSnippets(accountId string, filter EmailFilterElement, } snippet := SearchSnippetGetRefCommand{ - AccountId: aid, + AccountId: accountId, Filter: filter, EmailIdRef: &ResultReference{ ResultOf: "0", @@ -356,13 +351,12 @@ type EmailQueryResult struct { } func (j *Client) QueryEmails(accountId string, filter EmailFilterElement, session *Session, ctx context.Context, logger *log.Logger, offset uint, limit uint, fetchBodies bool, maxBodyValueBytes uint) (EmailQueryResult, SessionState, Error) { - aid := session.MailAccountId(accountId) - logger = j.loggerParams(aid, "QueryEmails", session, logger, func(z zerolog.Context) zerolog.Context { + logger = j.loggerParams(accountId, "QueryEmails", session, logger, func(z zerolog.Context) zerolog.Context { return z.Bool(logFetchBodies, fetchBodies) }) query := EmailQueryCommand{ - AccountId: aid, + AccountId: accountId, Filter: filter, Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}}, CollapseThreads: true, @@ -376,7 +370,7 @@ func (j *Client) QueryEmails(accountId string, filter EmailFilterElement, sessio } mails := EmailGetRefCommand{ - AccountId: aid, + AccountId: accountId, IdRef: &ResultReference{ ResultOf: "0", Name: CommandEmailQuery, @@ -434,13 +428,12 @@ type EmailQueryWithSnippetsResult struct { } func (j *Client) QueryEmailsWithSnippets(accountId string, filter EmailFilterElement, session *Session, ctx context.Context, logger *log.Logger, offset uint, limit uint, fetchBodies bool, maxBodyValueBytes uint) (EmailQueryWithSnippetsResult, SessionState, Error) { - aid := session.MailAccountId(accountId) - logger = j.loggerParams(aid, "QueryEmailsWithSnippets", session, logger, func(z zerolog.Context) zerolog.Context { + logger = j.loggerParams(accountId, "QueryEmailsWithSnippets", session, logger, func(z zerolog.Context) zerolog.Context { return z.Bool(logFetchBodies, fetchBodies) }) query := EmailQueryCommand{ - AccountId: aid, + AccountId: accountId, Filter: filter, Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}}, CollapseThreads: true, @@ -454,7 +447,7 @@ func (j *Client) QueryEmailsWithSnippets(accountId string, filter EmailFilterEle } snippet := SearchSnippetGetRefCommand{ - AccountId: aid, + AccountId: accountId, Filter: filter, EmailIdRef: &ResultReference{ ResultOf: "0", @@ -464,7 +457,7 @@ func (j *Client) QueryEmailsWithSnippets(accountId string, filter EmailFilterEle } mails := EmailGetRefCommand{ - AccountId: aid, + AccountId: accountId, IdRef: &ResultReference{ ResultOf: "0", Name: CommandEmailQuery, @@ -544,12 +537,10 @@ type UploadedEmail struct { } func (j *Client) ImportEmail(accountId string, session *Session, ctx context.Context, logger *log.Logger, data []byte) (UploadedEmail, SessionState, Error) { - aid := session.MailAccountId(accountId) - encoded := base64.StdEncoding.EncodeToString(data) upload := BlobUploadCommand{ - AccountId: aid, + AccountId: accountId, Create: map[string]UploadObject{ "0": { Data: []DataSourceObject{{ @@ -561,7 +552,7 @@ func (j *Client) ImportEmail(accountId string, session *Session, ctx context.Con } getHash := BlobGetRefCommand{ - AccountId: aid, + AccountId: accountId, IdRef: &ResultReference{ ResultOf: "0", Name: CommandBlobUpload, @@ -625,11 +616,9 @@ type CreatedEmail struct { } func (j *Client) CreateEmail(accountId string, email EmailCreate, session *Session, ctx context.Context, logger *log.Logger) (CreatedEmail, SessionState, Error) { - aid := session.MailAccountId(accountId) - cmd, err := request( invocation(CommandEmailSubmissionSet, EmailSetCommand{ - AccountId: aid, + AccountId: accountId, Create: map[string]EmailCreate{ "c": email, }, @@ -687,11 +676,9 @@ type UpdatedEmails struct { // // To delete mails, use the DeleteEmails function instead. func (j *Client) UpdateEmails(accountId string, updates map[string]EmailUpdate, session *Session, ctx context.Context, logger *log.Logger) (UpdatedEmails, SessionState, Error) { - aid := session.MailAccountId(accountId) - cmd, err := request( invocation(CommandEmailSet, EmailSetCommand{ - AccountId: aid, + AccountId: accountId, Update: updates, }, "0"), ) @@ -723,11 +710,9 @@ type DeletedEmails struct { } func (j *Client) DeleteEmails(accountId string, destroy []string, session *Session, ctx context.Context, logger *log.Logger) (DeletedEmails, SessionState, Error) { - aid := session.MailAccountId(accountId) - cmd, err := request( invocation(CommandEmailSet, EmailSetCommand{ - AccountId: aid, + AccountId: accountId, Destroy: destroy, }, "0"), ) @@ -777,10 +762,8 @@ type SubmittedEmail struct { } func (j *Client) SubmitEmail(accountId string, identityId string, emailId string, session *Session, ctx context.Context, logger *log.Logger, data []byte) (SubmittedEmail, SessionState, Error) { - aid := session.SubmissionAccountId(accountId) - set := EmailSubmissionSetCommand{ - AccountId: aid, + AccountId: accountId, Create: map[string]EmailSubmissionCreate{ "s0": { IdentityId: identityId, @@ -795,7 +778,7 @@ func (j *Client) SubmitEmail(accountId string, identityId string, emailId string } get := EmailSubmissionGetRefCommand{ - AccountId: aid, + AccountId: accountId, IdRef: &ResultReference{ ResultOf: "0", Name: CommandEmailSubmissionSet, @@ -865,18 +848,17 @@ func (j *Client) SubmitEmail(accountId string, identityId string, emailId string } func (j *Client) EmailsInThread(accountId string, threadId string, session *Session, ctx context.Context, logger *log.Logger, fetchBodies bool, maxBodyValueBytes uint) ([]Email, SessionState, Error) { - aid := session.MailAccountId(accountId) - logger = j.loggerParams(aid, "EmailsInThread", session, logger, func(z zerolog.Context) zerolog.Context { + logger = j.loggerParams(accountId, "EmailsInThread", session, logger, func(z zerolog.Context) zerolog.Context { return z.Bool(logFetchBodies, fetchBodies).Str("threadId", log.SafeString(threadId)) }) cmd, err := request( invocation(CommandThreadGet, ThreadGetCommand{ - AccountId: aid, + AccountId: accountId, Ids: []string{threadId}, }, "0"), invocation(CommandEmailGet, EmailGetRefCommand{ - AccountId: aid, + AccountId: accountId, IdRef: &ResultReference{ ResultOf: "0", Name: CommandThreadGet, diff --git a/pkg/jmap/jmap_api_identity.go b/pkg/jmap/jmap_api_identity.go index 1e283cd54a..768f96c9a4 100644 --- a/pkg/jmap/jmap_api_identity.go +++ b/pkg/jmap/jmap_api_identity.go @@ -6,7 +6,6 @@ import ( "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/structs" - "github.com/rs/zerolog" ) type Identities struct { @@ -16,9 +15,8 @@ type Identities struct { // https://jmap.io/spec-mail.html#identityget func (j *Client) GetIdentity(accountId string, session *Session, ctx context.Context, logger *log.Logger) (Identities, SessionState, Error) { - aid := session.MailAccountId(accountId) - logger = j.logger(aid, "GetIdentity", session, logger) - cmd, err := request(invocation(CommandIdentityGet, IdentityGetCommand{AccountId: aid}, "0")) + logger = j.logger(accountId, "GetIdentity", session, logger) + cmd, err := request(invocation(CommandIdentityGet, IdentityGetCommand{AccountId: accountId}, "0")) if err != nil { logger.Error().Err(err) return Identities{}, "", simpleError(err, JmapErrorInvalidJmapRequestPayload) @@ -46,9 +44,7 @@ type IdentitiesGetResponse struct { func (j *Client) GetIdentities(accountIds []string, session *Session, ctx context.Context, logger *log.Logger) (IdentitiesGetResponse, SessionState, Error) { uniqueAccountIds := structs.Uniq(accountIds) - logger = j.loggerParams("", "GetIdentities", session, logger, func(l zerolog.Context) zerolog.Context { - return l.Array(logAccountId, log.SafeStringArray(uniqueAccountIds)) - }) + logger = j.logger("", "GetIdentities", session, logger) calls := make([]Invocation, len(uniqueAccountIds)) for i, accountId := range uniqueAccountIds { @@ -95,9 +91,7 @@ type IdentitiesAndMailboxesGetResponse struct { func (j *Client) GetIdentitiesAndMailboxes(mailboxAccountId string, accountIds []string, session *Session, ctx context.Context, logger *log.Logger) (IdentitiesAndMailboxesGetResponse, SessionState, Error) { uniqueAccountIds := structs.Uniq(accountIds) - logger = j.loggerParams("", "GetIdentitiesAndMailboxes", session, logger, func(l zerolog.Context) zerolog.Context { - return l.Array(logAccountId, log.SafeStringArray(uniqueAccountIds)) - }) + logger = j.logger("", "GetIdentitiesAndMailboxes", session, logger) calls := make([]Invocation, len(uniqueAccountIds)+1) calls[0] = invocation(CommandMailboxGet, MailboxGetCommand{AccountId: mailboxAccountId}, "0") diff --git a/pkg/jmap/jmap_api_mailbox.go b/pkg/jmap/jmap_api_mailbox.go index 83a8e79d34..ec89fe07d5 100644 --- a/pkg/jmap/jmap_api_mailbox.go +++ b/pkg/jmap/jmap_api_mailbox.go @@ -14,9 +14,8 @@ type MailboxesResponse struct { // https://jmap.io/spec-mail.html#mailboxget func (j *Client) GetMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, ids []string) (MailboxesResponse, SessionState, Error) { - aid := session.MailAccountId(accountId) - logger = j.logger(aid, "GetMailbox", session, logger) - cmd, err := request(invocation(CommandMailboxGet, MailboxGetCommand{AccountId: aid, Ids: ids}, "0")) + logger = j.logger(accountId, "GetMailbox", session, logger) + cmd, err := request(invocation(CommandMailboxGet, MailboxGetCommand{AccountId: accountId, Ids: ids}, "0")) if err != nil { logger.Error().Err(err) return MailboxesResponse{}, "", simpleError(err, JmapErrorInvalidJmapRequestPayload) @@ -62,13 +61,12 @@ type Mailboxes struct { } func (j *Client) SearchMailboxes(accountId string, session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterElement) (Mailboxes, SessionState, Error) { - aid := session.MailAccountId(accountId) - logger = j.logger(aid, "SearchMailboxes", session, logger) + logger = j.logger(accountId, "SearchMailboxes", session, logger) cmd, err := request( - invocation(CommandMailboxQuery, MailboxQueryCommand{AccountId: aid, Filter: filter}, "0"), + invocation(CommandMailboxQuery, MailboxQueryCommand{AccountId: accountId, Filter: filter}, "0"), invocation(CommandMailboxGet, MailboxGetRefCommand{ - AccountId: aid, + AccountId: accountId, IdRef: &ResultReference{Name: CommandMailboxQuery, Path: "/ids/*", ResultOf: "0"}, }, "1"), ) diff --git a/pkg/jmap/jmap_api_vacation.go b/pkg/jmap/jmap_api_vacation.go index af695d1f10..9c4dd15f0e 100644 --- a/pkg/jmap/jmap_api_vacation.go +++ b/pkg/jmap/jmap_api_vacation.go @@ -14,9 +14,8 @@ const ( // https://jmap.io/spec-mail.html#vacationresponseget func (j *Client) GetVacationResponse(accountId string, session *Session, ctx context.Context, logger *log.Logger) (VacationResponseGetResponse, SessionState, Error) { - aid := session.MailAccountId(accountId) - logger = j.logger(aid, "GetVacationResponse", session, logger) - cmd, err := request(invocation(CommandVacationResponseGet, VacationResponseGetCommand{AccountId: aid}, "0")) + logger = j.logger(accountId, "GetVacationResponse", session, logger) + cmd, err := request(invocation(CommandVacationResponseGet, VacationResponseGetCommand{AccountId: accountId}, "0")) if err != nil { logger.Error().Err(err) return VacationResponseGetResponse{}, "", simpleError(err, JmapErrorInvalidJmapRequestPayload) @@ -62,12 +61,11 @@ type VacationResponseChange struct { } func (j *Client) SetVacationResponse(accountId string, vacation VacationResponsePayload, session *Session, ctx context.Context, logger *log.Logger) (VacationResponseChange, SessionState, Error) { - aid := session.MailAccountId(accountId) - logger = j.logger(aid, "SetVacationResponse", session, logger) + logger = j.logger(accountId, "SetVacationResponse", session, logger) cmd, err := request( invocation(CommandVacationResponseSet, VacationResponseSetCommand{ - AccountId: aid, + AccountId: accountId, Create: map[string]VacationResponse{ vacationResponseId: { IsEnabled: vacation.IsEnabled, @@ -81,7 +79,7 @@ func (j *Client) SetVacationResponse(accountId string, vacation VacationResponse }, "0"), // chain a second request to get the current complete VacationResponse object // after performing the changes, as that makes for a better API - invocation(CommandVacationResponseGet, VacationResponseGetCommand{AccountId: aid}, "1"), + invocation(CommandVacationResponseGet, VacationResponseGetCommand{AccountId: accountId}, "1"), ) if err != nil { logger.Error().Err(err) diff --git a/pkg/jmap/jmap_client.go b/pkg/jmap/jmap_client.go index 8531705240..1fad905a1e 100644 --- a/pkg/jmap/jmap_client.go +++ b/pkg/jmap/jmap_client.go @@ -2,6 +2,7 @@ package jmap import ( "io" + "net/url" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/rs/zerolog" @@ -39,8 +40,8 @@ func (j *Client) onSessionOutdated(session *Session, newSessionState SessionStat } // Retrieve JMAP well-known data from the Stalwart server and create a Session from that. -func (j *Client) FetchSession(username string, logger *log.Logger) (Session, Error) { - wk, err := j.session.GetSession(username, logger) +func (j *Client) FetchSession(sessionUrl *url.URL, username string, logger *log.Logger) (Session, Error) { + wk, err := j.session.GetSession(sessionUrl, username, logger) if err != nil { return Session{}, err } @@ -48,18 +49,14 @@ func (j *Client) FetchSession(username string, logger *log.Logger) (Session, Err } func (j *Client) logger(accountId string, operation string, _ *Session, logger *log.Logger) *log.Logger { + var _ string = accountId l := logger.With().Str(logOperation, operation) - if accountId != "" { - l = l.Str(logAccountId, accountId) - } return log.From(l) } func (j *Client) loggerParams(accountId string, operation string, _ *Session, logger *log.Logger, params func(zerolog.Context) zerolog.Context) *log.Logger { + var _ string = accountId l := logger.With().Str(logOperation, operation) - if accountId != "" { - l = l.Str(logAccountId, accountId) - } if params != nil { l = params(l) } diff --git a/pkg/jmap/jmap_http.go b/pkg/jmap/jmap_http.go index d7830ca4ea..56d1a1ade2 100644 --- a/pkg/jmap/jmap_http.go +++ b/pkg/jmap/jmap_http.go @@ -4,31 +4,38 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" "strconv" - "github.com/prometheus/client_golang/prometheus" - "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/version" ) -type HttpJmapApiClient struct { - baseurl url.URL +// Implementation of ApiClient, SessionClient and BlobClient that uses +// HTTP to perform JMAP operations. +type HttpJmapClient struct { client *http.Client masterUser string masterPassword string userAgent string - metrics HttpJmapApiClientMetrics + listener HttpJmapApiClientEventListener } var ( - _ ApiClient = &HttpJmapApiClient{} - _ SessionClient = &HttpJmapApiClient{} - _ BlobClient = &HttpJmapApiClient{} + _ ApiClient = &HttpJmapClient{} + _ SessionClient = &HttpJmapClient{} + _ BlobClient = &HttpJmapClient{} +) + +const ( + logEndpoint = "endpoint" + logHttpStatus = "status" + logHttpStatusCode = "status-code" + logHttpUrl = "url" ) /* @@ -37,24 +44,47 @@ func bearer(req *http.Request, token string) { } */ -type HttpJmapApiClientMetrics struct { - SuccessfulRequestPerEndpointCounter *prometheus.CounterVec - FailedRequestPerEndpointCounter *prometheus.CounterVec - FailedRequestStatusPerEndpointCounter *prometheus.CounterVec +// Record JMAP HTTP execution events that may occur, e.g. using metrics. +type HttpJmapApiClientEventListener interface { + OnSuccessfulRequest(endpoint string, status int) + OnFailedRequest(endpoint string, err error) + OnFailedRequestWithStatus(endpoint string, status int) + OnResponseBodyReadingError(endpoint string, err error) + OnResponseBodyUnmarshallingError(endpoint string, err error) } -func NewHttpJmapApiClient(baseurl url.URL, client *http.Client, masterUser string, masterPassword string, metrics HttpJmapApiClientMetrics) *HttpJmapApiClient { - return &HttpJmapApiClient{ - baseurl: baseurl, +type nullHttpJmapApiClientEventListener struct { +} + +func (l nullHttpJmapApiClientEventListener) OnSuccessfulRequest(endpoint string, status int) { +} +func (l nullHttpJmapApiClientEventListener) OnFailedRequest(endpoint string, err error) { +} +func (l nullHttpJmapApiClientEventListener) OnFailedRequestWithStatus(endpoint string, status int) { +} +func (l nullHttpJmapApiClientEventListener) OnResponseBodyReadingError(endpoint string, err error) { +} +func (l nullHttpJmapApiClientEventListener) OnResponseBodyUnmarshallingError(endpoint string, err error) { +} + +var _ HttpJmapApiClientEventListener = nullHttpJmapApiClientEventListener{} + +// An implementation of HttpJmapApiClientMetricsRecorder that does nothing. +func NullHttpJmapApiClientEventListener() HttpJmapApiClientEventListener { + return nullHttpJmapApiClientEventListener{} +} + +func NewHttpJmapClient(client *http.Client, masterUser string, masterPassword string, listener HttpJmapApiClientEventListener) *HttpJmapClient { + return &HttpJmapClient{ client: client, masterUser: masterUser, masterPassword: masterPassword, userAgent: "OpenCloud/" + version.GetString(), - metrics: metrics, + listener: listener, } } -func (h *HttpJmapApiClient) Close() error { +func (h *HttpJmapClient) Close() error { h.client.CloseIdleConnections() return nil } @@ -70,19 +100,33 @@ func (e AuthenticationError) Unwrap() error { return e.Err } -func (h *HttpJmapApiClient) auth(username string, _ *log.Logger, req *http.Request) error { +func (h *HttpJmapClient) auth(username string, _ *log.Logger, req *http.Request) error { masterUsername := username + "%" + h.masterUser req.SetBasicAuth(masterUsername, h.masterPassword) return nil } -func (h *HttpJmapApiClient) GetSession(username string, logger *log.Logger) (SessionResponse, Error) { - sessionUrl := h.baseurl.JoinPath(".well-known", "jmap") +var ( + errNilBaseUrl = errors.New("sessionUrl is nil") +) + +func (h *HttpJmapClient) GetSession(sessionUrl *url.URL, username string, logger *log.Logger) (SessionResponse, Error) { + if sessionUrl == nil { + logger.Error().Msg("sessionUrl is nil") + return SessionResponse{}, SimpleError{code: JmapErrorInvalidHttpRequest, err: errNilBaseUrl} + } + // See the JMAP specification on Service Autodiscovery: https://jmap.io/spec-core.html#service-autodiscovery + // There are two standardised autodiscovery methods in use for Internet protocols: + // - DNS SRV (see [@!RFC2782], [@!RFC6186], and [@!RFC6764]) + // - .well-known/servicename (see [@!RFC8615]) + // We are currently only supporting RFC8615, using the baseurl that was configured in this HttpJmapApiClient. + //sessionUrl := baseurl.JoinPath(".well-known", "jmap") sessionUrlStr := sessionUrl.String() + endpoint := endpointOf(sessionUrl) req, err := http.NewRequest(http.MethodGet, sessionUrlStr, nil) if err != nil { - logger.Error().Err(err).Msgf("failed to create GET request for %v", sessionUrl) + logger.Error().Err(err).Str(logEndpoint, endpoint).Msgf("failed to create GET request for %v", sessionUrl) return SessionResponse{}, SimpleError{code: JmapErrorInvalidHttpRequest, err: err} } h.auth(username, logger, req) @@ -90,22 +134,16 @@ func (h *HttpJmapApiClient) GetSession(username string, logger *log.Logger) (Ses res, err := h.client.Do(req) if err != nil { - if h.metrics.FailedRequestPerEndpointCounter != nil { - h.metrics.FailedRequestPerEndpointCounter.WithLabelValues(endpointOf(sessionUrl)).Inc() - } - logger.Error().Err(err).Msgf("failed to perform GET %v", sessionUrl) + h.listener.OnFailedRequest(endpoint, err) + logger.Error().Err(err).Str(logEndpoint, endpoint).Msgf("failed to perform GET %v", sessionUrl) return SessionResponse{}, SimpleError{code: JmapErrorInvalidHttpRequest, err: err} } if res.StatusCode < 200 || res.StatusCode > 299 { - if h.metrics.FailedRequestStatusPerEndpointCounter != nil { - h.metrics.FailedRequestStatusPerEndpointCounter.WithLabelValues(endpointOf(sessionUrl), strconv.Itoa(res.StatusCode)).Inc() - } - logger.Error().Str(logHttpStatusCode, res.Status).Msg("HTTP response status code is not 200") + h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode) + logger.Error().Str(logEndpoint, endpoint).Str(logHttpStatus, res.Status).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 200") return SessionResponse{}, SimpleError{code: JmapErrorServerResponse, err: fmt.Errorf("JMAP API response status is %v", res.Status)} } - if h.metrics.SuccessfulRequestPerEndpointCounter != nil { - h.metrics.SuccessfulRequestPerEndpointCounter.WithLabelValues(endpointOf(sessionUrl)).Inc() - } + h.listener.OnSuccessfulRequest(endpoint, res.StatusCode) if res.Body != nil { defer func(Body io.ReadCloser) { @@ -118,32 +156,35 @@ func (h *HttpJmapApiClient) GetSession(username string, logger *log.Logger) (Ses body, err := io.ReadAll(res.Body) if err != nil { - logger.Error().Err(err).Msg("failed to read response body") + logger.Error().Err(err).Str(logEndpoint, endpoint).Msg("failed to read response body") + h.listener.OnResponseBodyReadingError(endpoint, err) return SessionResponse{}, SimpleError{code: JmapErrorReadingResponseBody, err: err} } var data SessionResponse err = json.Unmarshal(body, &data) if err != nil { - logger.Error().Str("url", sessionUrlStr).Err(err).Msg("failed to decode JSON payload from .well-known/jmap response") + logger.Error().Str(logEndpoint, endpoint).Str(logHttpUrl, sessionUrlStr).Err(err).Msg("failed to decode JSON payload from .well-known/jmap response") + h.listener.OnResponseBodyUnmarshallingError(endpoint, err) return SessionResponse{}, SimpleError{code: JmapErrorDecodingResponseBody, err: err} } return data, nil } -func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, Error) { +func (h *HttpJmapClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, Error) { jmapUrl := session.JmapUrl.String() + endpoint := session.JmapEndpoint bodyBytes, err := json.Marshal(request) if err != nil { - logger.Error().Err(err).Msg("failed to marshall JSON payload") + logger.Error().Err(err).Str(logEndpoint, endpoint).Msg("failed to marshall JSON payload") return nil, SimpleError{code: JmapErrorEncodingRequestBody, err: err} } req, err := http.NewRequestWithContext(ctx, http.MethodPost, jmapUrl, bytes.NewBuffer(bodyBytes)) if err != nil { - logger.Error().Err(err).Msgf("failed to create POST request for %v", jmapUrl) + logger.Error().Err(err).Str(logEndpoint, endpoint).Msgf("failed to create POST request for %v", jmapUrl) return nil, SimpleError{code: JmapErrorCreatingRequest, err: err} } req.Header.Add("Content-Type", "application/json") @@ -152,17 +193,13 @@ func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses res, err := h.client.Do(req) if err != nil { - if h.metrics.FailedRequestPerEndpointCounter != nil { - h.metrics.FailedRequestPerEndpointCounter.WithLabelValues(session.JmapEndpoint).Inc() - } - logger.Error().Err(err).Msgf("failed to perform POST %v", jmapUrl) + h.listener.OnFailedRequest(endpoint, err) + logger.Error().Err(err).Str(logEndpoint, endpoint).Msgf("failed to perform POST %v", jmapUrl) return nil, SimpleError{code: JmapErrorSendingRequest, err: err} } if res.StatusCode < 200 || res.StatusCode > 299 { - if h.metrics.FailedRequestStatusPerEndpointCounter != nil { - h.metrics.FailedRequestStatusPerEndpointCounter.WithLabelValues(session.JmapEndpoint, strconv.Itoa(res.StatusCode)).Inc() - } - logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 2xx") + h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode) + logger.Error().Str(logEndpoint, endpoint).Str(logHttpStatus, res.Status).Msg("HTTP response status code is not 2xx") return nil, SimpleError{code: JmapErrorServerResponse, err: err} } if res.Body != nil { @@ -173,23 +210,22 @@ func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses } }(res.Body) } - if h.metrics.SuccessfulRequestPerEndpointCounter != nil { - h.metrics.SuccessfulRequestPerEndpointCounter.WithLabelValues(session.JmapEndpoint).Inc() - } + 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") + logger.Error().Err(err).Str(logEndpoint, endpoint).Msg("failed to read response body") + h.listener.OnResponseBodyReadingError(endpoint, err) return nil, SimpleError{code: JmapErrorServerResponse, err: err} } return body, nil } -func (h *HttpJmapApiClient) UploadBinary(ctx context.Context, logger *log.Logger, session *Session, uploadUrl string, contentType string, body io.Reader) (UploadedBlob, Error) { +func (h *HttpJmapClient) UploadBinary(ctx context.Context, logger *log.Logger, session *Session, uploadUrl string, endpoint string, contentType string, body io.Reader) (UploadedBlob, Error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadUrl, body) if err != nil { - logger.Error().Err(err).Msgf("failed to create POST request for %v", uploadUrl) + logger.Error().Err(err).Str(logEndpoint, endpoint).Msgf("failed to create POST request for %v", uploadUrl) return UploadedBlob{}, SimpleError{code: JmapErrorCreatingRequest, err: err} } req.Header.Add("Content-Type", contentType) @@ -198,17 +234,13 @@ func (h *HttpJmapApiClient) UploadBinary(ctx context.Context, logger *log.Logger res, err := h.client.Do(req) if err != nil { - if h.metrics.FailedRequestPerEndpointCounter != nil { - h.metrics.FailedRequestPerEndpointCounter.WithLabelValues(session.UploadEndpoint).Inc() - } - logger.Error().Err(err).Msgf("failed to perform POST %v", uploadUrl) + h.listener.OnFailedRequest(endpoint, err) + logger.Error().Err(err).Str(logEndpoint, endpoint).Msgf("failed to perform POST %v", uploadUrl) return UploadedBlob{}, SimpleError{code: JmapErrorSendingRequest, err: err} } if res.StatusCode < 200 || res.StatusCode > 299 { - if h.metrics.FailedRequestStatusPerEndpointCounter != nil { - h.metrics.FailedRequestStatusPerEndpointCounter.WithLabelValues(session.UploadEndpoint, strconv.Itoa(res.StatusCode)).Inc() - } - logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 2xx") + h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode) + logger.Error().Str(logEndpoint, endpoint).Str(logHttpStatus, res.Status).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 2xx") return UploadedBlob{}, SimpleError{code: JmapErrorServerResponse, err: err} } if res.Body != nil { @@ -219,30 +251,30 @@ func (h *HttpJmapApiClient) UploadBinary(ctx context.Context, logger *log.Logger } }(res.Body) } - if h.metrics.SuccessfulRequestPerEndpointCounter != nil { - h.metrics.SuccessfulRequestPerEndpointCounter.WithLabelValues(session.UploadEndpoint).Inc() - } + 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") + logger.Error().Err(err).Str(logEndpoint, endpoint).Msg("failed to read response body") + h.listener.OnResponseBodyReadingError(endpoint, err) return UploadedBlob{}, SimpleError{code: JmapErrorServerResponse, err: err} } var result UploadedBlob err = json.Unmarshal(responseBody, &result) if err != nil { - logger.Error().Str("url", uploadUrl).Err(err).Msg("failed to decode JSON payload from the upload response") + logger.Error().Str(logEndpoint, endpoint).Str(logHttpUrl, uploadUrl).Err(err).Msg("failed to decode JSON payload from the upload response") + h.listener.OnResponseBodyUnmarshallingError(endpoint, err) return UploadedBlob{}, SimpleError{code: JmapErrorDecodingResponseBody, err: err} } return result, nil } -func (h *HttpJmapApiClient) DownloadBinary(ctx context.Context, logger *log.Logger, session *Session, downloadUrl string) (*BlobDownload, Error) { +func (h *HttpJmapClient) DownloadBinary(ctx context.Context, logger *log.Logger, session *Session, downloadUrl string, endpoint string) (*BlobDownload, Error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadUrl, nil) if err != nil { - logger.Error().Err(err).Msgf("failed to create GET request for %v", downloadUrl) + logger.Error().Err(err).Str(logEndpoint, endpoint).Msgf("failed to create GET request for %v", downloadUrl) return nil, SimpleError{code: JmapErrorCreatingRequest, err: err} } req.Header.Add("User-Agent", h.userAgent) @@ -250,32 +282,26 @@ func (h *HttpJmapApiClient) DownloadBinary(ctx context.Context, logger *log.Logg res, err := h.client.Do(req) if err != nil { - if h.metrics.FailedRequestPerEndpointCounter != nil { - h.metrics.FailedRequestPerEndpointCounter.WithLabelValues(session.DownloadEndpoint).Inc() - } - logger.Error().Err(err).Msgf("failed to perform GET %v", downloadUrl) + h.listener.OnFailedRequest(endpoint, err) + logger.Error().Err(err).Str(logEndpoint, endpoint).Msgf("failed to perform GET %v", downloadUrl) return nil, SimpleError{code: JmapErrorSendingRequest, err: err} } if res.StatusCode == http.StatusNotFound { return nil, nil } if res.StatusCode < 200 || res.StatusCode > 299 { - if h.metrics.FailedRequestStatusPerEndpointCounter != nil { - h.metrics.FailedRequestStatusPerEndpointCounter.WithLabelValues(session.DownloadEndpoint, strconv.Itoa(res.StatusCode)).Inc() - } - logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 2xx") + h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode) + logger.Error().Str(logEndpoint, endpoint).Str(logHttpStatus, res.Status).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 2xx") return nil, SimpleError{code: JmapErrorServerResponse, err: err} } - if h.metrics.SuccessfulRequestPerEndpointCounter != nil { - h.metrics.SuccessfulRequestPerEndpointCounter.WithLabelValues(session.DownloadEndpoint).Inc() - } + h.listener.OnSuccessfulRequest(endpoint, res.StatusCode) sizeStr := res.Header.Get("Content-Length") size := -1 if sizeStr != "" { size, err = strconv.Atoi(sizeStr) if err != nil { - logger.Warn().Err(err).Msgf("failed to parse Content-Length blob download response header value '%v'", sizeStr) + logger.Warn().Err(err).Str(logEndpoint, endpoint).Msgf("failed to parse Content-Length blob download response header value '%v'", sizeStr) size = -1 } } diff --git a/pkg/jmap/jmap_session.go b/pkg/jmap/jmap_session.go index f9208c12d4..3b2bd703ed 100644 --- a/pkg/jmap/jmap_session.go +++ b/pkg/jmap/jmap_session.go @@ -101,30 +101,6 @@ func newSession(sessionResponse SessionResponse) (Session, Error) { }, nil } -func (s *Session) MailAccountId(accountId string) string { - if accountId != "" && accountId != defaultAccountId { - return accountId - } - // TODO(pbleser-oc) handle case where there is no default mail account - return s.PrimaryAccounts.Mail -} - -func (s *Session) BlobAccountId(accountId string) string { - if accountId != "" && accountId != defaultAccountId { - return accountId - } - // TODO(pbleser-oc) handle case where there is no default blob account - return s.PrimaryAccounts.Blob -} - -func (s *Session) SubmissionAccountId(accountId string) string { - if accountId != "" && accountId != defaultAccountId { - return accountId - } - // TODO(pbleser-oc) handle case where there is no default submission account - return s.PrimaryAccounts.Submission -} - // Create a new log.Logger that is decorated with fields containing information about the Session. func (s Session) DecorateLogger(l log.Logger) *log.Logger { return log.From(l.With(). diff --git a/pkg/jmap/jmap_test.go b/pkg/jmap/jmap_test.go index e428b1415c..2bd3cc0cc8 100644 --- a/pkg/jmap/jmap_test.go +++ b/pkg/jmap/jmap_test.go @@ -32,7 +32,7 @@ func (t *TestJmapWellKnownClient) Close() error { return nil } -func (t *TestJmapWellKnownClient) GetSession(username string, logger *log.Logger) (SessionResponse, Error) { +func (t *TestJmapWellKnownClient) GetSession(sessionUrl *url.URL, username string, logger *log.Logger) (SessionResponse, Error) { pa := generateRandomString(2 + seededRand.Intn(10)) return SessionResponse{ Username: generateRandomString(8), @@ -70,7 +70,7 @@ func NewTestJmapBlobClient(t *testing.T) BlobClient { return &TestJmapBlobClient{t: t} } -func (t TestJmapBlobClient) UploadBinary(ctx context.Context, logger *log.Logger, session *Session, uploadUrl string, contentType string, body io.Reader) (UploadedBlob, Error) { +func (t TestJmapBlobClient) UploadBinary(ctx context.Context, logger *log.Logger, session *Session, uploadUrl string, endpoint string, contentType string, body io.Reader) (UploadedBlob, Error) { bytes, err := io.ReadAll(body) if err != nil { return UploadedBlob{}, SimpleError{code: 0, err: err} @@ -85,7 +85,7 @@ func (t TestJmapBlobClient) UploadBinary(ctx context.Context, logger *log.Logger }, nil } -func (h *TestJmapBlobClient) DownloadBinary(ctx context.Context, logger *log.Logger, session *Session, downloadUrl string) (*BlobDownload, Error) { +func (h *TestJmapBlobClient) DownloadBinary(ctx context.Context, logger *log.Logger, session *Session, downloadUrl string, endpoint string) (*BlobDownload, Error) { return &BlobDownload{ Body: io.NopCloser(strings.NewReader("")), Size: -1, diff --git a/services/groupware/pkg/config/defaults/defaultconfig.go b/services/groupware/pkg/config/defaults/defaultconfig.go index c9bae68aa2..d40f26eac0 100644 --- a/services/groupware/pkg/config/defaults/defaultconfig.go +++ b/services/groupware/pkg/config/defaults/defaultconfig.go @@ -46,8 +46,8 @@ func DefaultConfig() *config.Config { Namespace: "eu.opencloud.web", CORS: config.CORS{ AllowedOrigins: []string{"*"}, - AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Authorization", "Origin", "Content-Type", "Accept", "X-Requested-With", "X-Request-Id", "Cache-Control"}, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "REPORT"}, + AllowedHeaders: []string{"Authorization", "Origin", "Content-Type", "Accept", "X-Requested-With", "X-Request-Id", "Trace-Id", "Cache-Control"}, AllowCredentials: true, }, }, diff --git a/services/groupware/pkg/groupware/groupware_api_account.go b/services/groupware/pkg/groupware/groupware_api_account.go index 341fc56912..15c7b552f7 100644 --- a/services/groupware/pkg/groupware/groupware_api_account.go +++ b/services/groupware/pkg/groupware/groupware_api_account.go @@ -4,12 +4,13 @@ import ( "net/http" "github.com/opencloud-eu/opencloud/pkg/jmap" + "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/structs" ) func (g *Groupware) GetAccount(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { - account, err := req.GetAccount() + account, err := req.GetAccountForMail() if err != nil { return errorResponse(err) } @@ -56,10 +57,14 @@ type SwaggerAccountBootstrapResponse struct { func (g *Groupware) GetAccountBootstrap(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { - mailAccountId := req.GetAccountId() + mailAccountId, err := req.GetAccountIdForMail() + if err != nil { + return errorResponse(err) + } + logger := log.From(req.logger.With().Str(logAccountId, mailAccountId)) accountIds := structs.Keys(req.session.Accounts) - resp, sessionState, jerr := g.jmap.GetIdentitiesAndMailboxes(mailAccountId, accountIds, req.session, req.ctx, req.logger) + resp, sessionState, jerr := g.jmap.GetIdentitiesAndMailboxes(mailAccountId, accountIds, req.session, req.ctx, logger) if jerr != nil { return req.errorResponseFromJmap(jerr) } diff --git a/services/groupware/pkg/groupware/groupware_api_blob.go b/services/groupware/pkg/groupware/groupware_api_blob.go index 2ce7dd35ef..bff79d6eff 100644 --- a/services/groupware/pkg/groupware/groupware_api_blob.go +++ b/services/groupware/pkg/groupware/groupware_api_blob.go @@ -8,6 +8,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/opencloud-eu/opencloud/pkg/jmap" + "github.com/opencloud-eu/opencloud/pkg/log" ) const ( @@ -21,9 +22,15 @@ func (g *Groupware) GetBlob(w http.ResponseWriter, r *http.Request) { return req.parameterErrorResponse(UriParamBlobId, fmt.Sprintf("Invalid value for path parameter '%v': empty", UriParamBlobId)) } - res, _, err := g.jmap.GetBlob(req.GetAccountId(), req.session, req.ctx, req.logger, blobId) + accountId, err := req.GetAccountIdForBlob() if err != nil { - return req.errorResponseFromJmap(err) + return errorResponse(err) + } + logger := log.From(req.logger.With().Str(logAccountId, accountId)) + + res, _, jerr := g.jmap.GetBlob(accountId, req.session, req.ctx, logger, blobId) + if jerr != nil { + return req.errorResponseFromJmap(jerr) } blob := res.Blob if blob == nil { @@ -46,9 +53,15 @@ func (g *Groupware) UploadBlob(w http.ResponseWriter, r *http.Request) { }(body) } - resp, err := g.jmap.UploadBlobStream(req.GetAccountId(), req.session, req.ctx, req.logger, contentType, body) + accountId, err := req.GetAccountIdForBlob() if err != nil { - return req.errorResponseFromJmap(err) + return errorResponse(err) + } + logger := log.From(req.logger.With().Str(logAccountId, accountId)) + + resp, jerr := g.jmap.UploadBlobStream(accountId, req.session, req.ctx, logger, contentType, body) + if jerr != nil { + return req.errorResponseFromJmap(jerr) } return etagOnlyResponse(resp, jmap.State(resp.Sha512)) @@ -60,17 +73,23 @@ func (g *Groupware) DownloadBlob(w http.ResponseWriter, r *http.Request) { blobId := chi.URLParam(req.r, UriParamBlobId) name := chi.URLParam(req.r, UriParamBlobName) q := req.r.URL.Query() - tipe := q.Get(QueryParamBlobType) - if tipe == "" { - tipe = DefaultBlobDownloadType + typ := q.Get(QueryParamBlobType) + if typ == "" { + typ = DefaultBlobDownloadType } - blob, jerr := g.jmap.DownloadBlobStream(req.GetAccountId(), blobId, name, tipe, req.session, req.ctx, req.logger) + accountId, gwerr := req.GetAccountIdForBlob() + if gwerr != nil { + return gwerr + } + logger := log.From(req.logger.With().Str(logAccountId, accountId)) + + blob, jerr := g.jmap.DownloadBlobStream(accountId, blobId, name, typ, req.session, req.ctx, logger) if blob != nil && blob.Body != nil { defer func(Body io.ReadCloser) { err := Body.Close() if err != nil { - req.logger.Error().Err(err).Msg("failed to close response body") + logger.Error().Err(err).Msg("failed to close response body") } }(blob.Body) } diff --git a/services/groupware/pkg/groupware/groupware_api_identity.go b/services/groupware/pkg/groupware/groupware_api_identity.go index 0b4cebc3a9..da8d7118b5 100644 --- a/services/groupware/pkg/groupware/groupware_api_identity.go +++ b/services/groupware/pkg/groupware/groupware_api_identity.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/opencloud-eu/opencloud/pkg/jmap" + "github.com/opencloud-eu/opencloud/pkg/log" ) // When the request suceeds. @@ -26,9 +27,14 @@ type SwaggerGetIdentitiesResponse struct { // 500: ErrorResponse500 func (g *Groupware) GetIdentities(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { - res, sessionState, err := g.jmap.GetIdentity(req.GetAccountId(), req.session, req.ctx, req.logger) + accountId, err := req.GetAccountIdWithoutFallback() if err != nil { - return req.errorResponseFromJmap(err) + return errorResponse(err) + } + logger := log.From(req.logger.With().Str(logAccountId, accountId)) + res, sessionState, jerr := g.jmap.GetIdentity(accountId, req.session, req.ctx, logger) + if jerr != nil { + return req.errorResponseFromJmap(jerr) } return etagResponse(res, sessionState, res.State) }) diff --git a/services/groupware/pkg/groupware/groupware_api_mailbox.go b/services/groupware/pkg/groupware/groupware_api_mailbox.go index 2953d1b2ab..10da592b9c 100644 --- a/services/groupware/pkg/groupware/groupware_api_mailbox.go +++ b/services/groupware/pkg/groupware/groupware_api_mailbox.go @@ -6,6 +6,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/opencloud-eu/opencloud/pkg/jmap" + "github.com/opencloud-eu/opencloud/pkg/log" ) // When the request succeeds. @@ -33,9 +34,14 @@ type SwaggerGetMailboxById200 struct { func (g *Groupware) GetMailbox(w http.ResponseWriter, r *http.Request) { mailboxId := chi.URLParam(r, UriParamMailboxId) g.respond(w, r, func(req Request) Response { - res, sessionState, err := g.jmap.GetMailbox(req.GetAccountId(), req.session, req.ctx, req.logger, []string{mailboxId}) + accountId, err := req.GetAccountIdForMail() if err != nil { - return req.errorResponseFromJmap(err) + return errorResponse(err) + } + + res, sessionState, jerr := g.jmap.GetMailbox(accountId, req.session, req.ctx, req.logger, []string{mailboxId}) + if jerr != nil { + return req.errorResponseFromJmap(jerr) } if len(res.Mailboxes) == 1 { @@ -107,14 +113,20 @@ func (g *Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) { hasCriteria = true } + accountId, err := req.GetAccountIdForMail() + if err != nil { + return errorResponse(err) + } + logger := log.From(req.logger.With().Str(logAccountId, accountId)) + if hasCriteria { - mailboxes, sessionState, err := g.jmap.SearchMailboxes(req.GetAccountId(), req.session, req.ctx, req.logger, filter) + mailboxes, sessionState, err := g.jmap.SearchMailboxes(accountId, req.session, req.ctx, logger, filter) if err != nil { return req.errorResponseFromJmap(err) } return etagResponse(mailboxes.Mailboxes, sessionState, mailboxes.State) } else { - mailboxes, sessionState, err := g.jmap.GetAllMailboxes(req.GetAccountId(), req.session, req.ctx, req.logger) + mailboxes, sessionState, err := g.jmap.GetAllMailboxes(accountId, req.session, req.ctx, logger) if err != nil { return req.errorResponseFromJmap(err) } diff --git a/services/groupware/pkg/groupware/groupware_api_messages.go b/services/groupware/pkg/groupware/groupware_api_messages.go index 54014ef7b7..ed4270be94 100644 --- a/services/groupware/pkg/groupware/groupware_api_messages.go +++ b/services/groupware/pkg/groupware/groupware_api_messages.go @@ -64,9 +64,15 @@ func (g *Groupware) GetAllMessagesInMailbox(w http.ResponseWriter, r *http.Reque if mailboxId == "" { return req.parameterErrorResponse(UriParamMailboxId, fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId)) } - logger := log.From(req.logger.With().Str(HeaderSince, since)) - emails, sessionState, jerr := g.jmap.GetEmailsInMailboxSince(req.GetAccountId(), req.session, req.ctx, logger, mailboxId, since, true, g.maxBodyValueBytes, maxChanges) + accountId, err := req.GetAccountIdForMail() + if err != nil { + return errorResponse(err) + } + + logger := log.From(req.logger.With().Str(HeaderSince, since).Str(logAccountId, accountId)) + + emails, sessionState, jerr := g.jmap.GetEmailsInMailboxSince(accountId, req.session, req.ctx, logger, mailboxId, since, true, g.maxBodyValueBytes, maxChanges) if jerr != nil { return req.errorResponseFromJmap(jerr) } @@ -95,9 +101,15 @@ func (g *Groupware) GetAllMessagesInMailbox(w http.ResponseWriter, r *http.Reque l = l.Uint(QueryParamLimit, limit) } + accountId, err := req.GetAccountIdForMail() + if err != nil { + return errorResponse(err) + } + l = l.Str(logAccountId, accountId) + logger := log.From(l) - emails, sessionState, jerr := g.jmap.GetAllEmails(req.GetAccountId(), req.session, req.ctx, logger, mailboxId, offset, limit, true, g.maxBodyValueBytes) + emails, sessionState, jerr := g.jmap.GetAllEmails(accountId, req.session, req.ctx, logger, mailboxId, offset, limit, true, g.maxBodyValueBytes) if jerr != nil { return req.errorResponseFromJmap(jerr) } @@ -115,8 +127,14 @@ func (g *Groupware) GetMessagesById(w http.ResponseWriter, r *http.Request) { return req.parameterErrorResponse(UriParamMessageId, fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamMessageId, log.SafeString(id), "empty list of mail ids")) } - logger := log.From(req.logger.With().Str("id", log.SafeString(id))) - emails, sessionState, jerr := g.jmap.GetEmails(req.GetAccountId(), req.session, req.ctx, logger, ids, true, g.maxBodyValueBytes) + accountId, err := req.GetAccountIdForMail() + if err != nil { + return errorResponse(err) + } + + logger := log.From(req.logger.With().Str("id", log.SafeString(id)).Str(logAccountId, log.SafeString(accountId))) + + emails, sessionState, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, ids, true, g.maxBodyValueBytes) if jerr != nil { return req.errorResponseFromJmap(jerr) } @@ -135,9 +153,16 @@ func (g *Groupware) getMessagesSince(w http.ResponseWriter, r *http.Request, sin if ok { l = l.Uint(QueryParamMaxChanges, maxChanges) } + + accountId, err := req.GetAccountIdForMail() + if err != nil { + return errorResponse(err) + } + l = l.Str(logAccountId, log.SafeString(accountId)) + logger := log.From(l) - emails, sessionState, jerr := g.jmap.GetEmailsSince(req.GetAccountId(), req.session, req.ctx, logger, since, true, g.maxBodyValueBytes, maxChanges) + emails, sessionState, jerr := g.jmap.GetEmailsSince(accountId, req.session, req.ctx, logger, since, true, g.maxBodyValueBytes, maxChanges) if jerr != nil { return req.errorResponseFromJmap(jerr) } @@ -328,7 +353,13 @@ func (g *Groupware) searchMessages(w http.ResponseWriter, r *http.Request) { logger = log.From(logger.With().Bool(QueryParamSearchFetchBodies, fetchBodies)) } - results, sessionState, jerr := g.jmap.QueryEmailsWithSnippets(req.GetAccountId(), filter, req.session, req.ctx, logger, offset, limit, fetchBodies, g.maxBodyValueBytes) + accountId, err := req.GetAccountIdForMail() + if err != nil { + return errorResponse(err) + } + logger = log.From(logger.With().Str(logAccountId, accountId)) + + results, sessionState, jerr := g.jmap.QueryEmailsWithSnippets(accountId, filter, req.session, req.ctx, logger, offset, limit, fetchBodies, g.maxBodyValueBytes) if jerr != nil { return req.errorResponseFromJmap(jerr) } @@ -355,7 +386,13 @@ func (g *Groupware) searchMessages(w http.ResponseWriter, r *http.Request) { QueryState: results.QueryState, }, sessionState, results.QueryState) } else { - results, sessionState, jerr := g.jmap.QueryEmailSnippets(req.GetAccountId(), filter, req.session, req.ctx, logger, offset, limit) + accountId, err := req.GetAccountIdForMail() + if err != nil { + return errorResponse(err) + } + logger = log.From(logger.With().Str(logAccountId, accountId)) + + results, sessionState, jerr := g.jmap.QueryEmailSnippets(accountId, filter, req.session, req.ctx, logger, offset, limit) if jerr != nil { return req.errorResponseFromJmap(jerr) } @@ -400,6 +437,12 @@ func (g *Groupware) CreateMessage(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { logger := req.logger + accountId, gwerr := req.GetAccountIdForMail() + if gwerr != nil { + return errorResponse(gwerr) + } + logger = log.From(logger.With().Str(logAccountId, accountId)) + var body MessageCreation err := req.body(&body) if err != nil { @@ -427,7 +470,7 @@ func (g *Groupware) CreateMessage(w http.ResponseWriter, r *http.Request) { BodyValues: body.BodyValues, } - created, sessionState, jerr := g.jmap.CreateEmail(req.GetAccountId(), create, req.session, req.ctx, logger) + created, sessionState, jerr := g.jmap.CreateEmail(accountId, create, req.session, req.ctx, logger) if jerr != nil { return req.errorResponseFromJmap(jerr) } @@ -443,6 +486,12 @@ func (g *Groupware) UpdateMessage(w http.ResponseWriter, r *http.Request) { l := req.logger.With() l.Str(UriParamMessageId, messageId) + accountId, gwerr := req.GetAccountIdForMail() + if gwerr != nil { + return errorResponse(gwerr) + } + l.Str(logAccountId, accountId) + logger := log.From(l) var body map[string]any @@ -455,7 +504,7 @@ func (g *Groupware) UpdateMessage(w http.ResponseWriter, r *http.Request) { messageId: body, } - result, sessionState, jerr := g.jmap.UpdateEmails(req.GetAccountId(), updates, req.session, req.ctx, logger) + result, sessionState, jerr := g.jmap.UpdateEmails(accountId, updates, req.session, req.ctx, logger) if jerr != nil { return req.errorResponseFromJmap(jerr) } @@ -481,9 +530,16 @@ func (g *Groupware) DeleteMessage(w http.ResponseWriter, r *http.Request) { l := req.logger.With() l.Str(UriParamMessageId, messageId) + + accountId, gwerr := req.GetAccountIdForMail() + if gwerr != nil { + return errorResponse(gwerr) + } + l.Str(logAccountId, accountId) + logger := log.From(l) - _, sessionState, jerr := g.jmap.DeleteEmails(req.GetAccountId(), []string{messageId}, req.session, req.ctx, logger) + _, sessionState, jerr := g.jmap.DeleteEmails(accountId, []string{messageId}, req.session, req.ctx, logger) if jerr != nil { return req.errorResponseFromJmap(jerr) } @@ -546,19 +602,33 @@ func (g *Groupware) RelatedToMessage(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, UriParamMessageId) g.respond(w, r, func(req Request) Response { - limit, _, err := req.parseUIntParam(QueryParamLimit, 10) // TODO configurable default limit + l := req.logger.With().Str(logEmailId, log.SafeString(id)) + + limit, ok, err := req.parseUIntParam(QueryParamLimit, 10) // TODO configurable default limit if err != nil { return errorResponse(err) } + if ok { + l = l.Uint("limit", limit) + } - days, _, err := req.parseUIntParam(QueryParamDays, 5) // TODO configurable default days + days, ok, err := req.parseUIntParam(QueryParamDays, 5) // TODO configurable default days if err != nil { return errorResponse(err) } + if ok { + l = l.Uint("days", days) + } + + accountId, gwerr := req.GetAccountIdForMail() + if gwerr != nil { + return errorResponse(gwerr) + } + l = l.Str(logAccountId, accountId) + + logger := log.From(l) reqId := req.GetRequestId() - accountId := req.GetAccountId() - logger := log.From(req.logger.With().Str(logEmailId, log.SafeString(id))) getEmailsBefore := time.Now() emails, sessionState, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, []string{id}, true, g.maxBodyValueBytes) getEmailsDuration := time.Since(getEmailsBefore) diff --git a/services/groupware/pkg/groupware/groupware_api_vacation.go b/services/groupware/pkg/groupware/groupware_api_vacation.go index 9d26060d9c..c30a2202ec 100644 --- a/services/groupware/pkg/groupware/groupware_api_vacation.go +++ b/services/groupware/pkg/groupware/groupware_api_vacation.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/opencloud-eu/opencloud/pkg/jmap" + "github.com/opencloud-eu/opencloud/pkg/log" ) // When the request succeeds. @@ -30,9 +31,15 @@ type SwaggerGetVacationResponse200 struct { // 500: ErrorResponse500 func (g *Groupware) GetVacation(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { - res, sessionState, err := g.jmap.GetVacationResponse(req.GetAccountId(), req.session, req.ctx, req.logger) + accountId, err := req.GetAccountIdForVacationResponse() if err != nil { - return req.errorResponseFromJmap(err) + return errorResponse(err) + } + logger := log.From(req.logger.With().Str(logAccountId, accountId)) + + res, sessionState, jerr := g.jmap.GetVacationResponse(accountId, req.session, req.ctx, logger) + if jerr != nil { + return req.errorResponseFromJmap(jerr) } return etagResponse(res, sessionState, res.State) }) @@ -66,7 +73,13 @@ func (g *Groupware) SetVacation(w http.ResponseWriter, r *http.Request) { return errorResponse(err) } - res, sessionState, jerr := g.jmap.SetVacationResponse(req.GetAccountId(), body, req.session, req.ctx, req.logger) + accountId, err := req.GetAccountIdForVacationResponse() + if err != nil { + return errorResponse(err) + } + logger := log.From(req.logger.With().Str(logAccountId, accountId)) + + res, sessionState, jerr := g.jmap.SetVacationResponse(accountId, body, req.session, req.ctx, logger) if jerr != nil { return req.errorResponseFromJmap(jerr) } diff --git a/services/groupware/pkg/groupware/groupware_error.go b/services/groupware/pkg/groupware/groupware_error.go index c6e1241185..4fba0176dd 100644 --- a/services/groupware/pkg/groupware/groupware_error.go +++ b/services/groupware/pkg/groupware/groupware_error.go @@ -162,6 +162,7 @@ const ( ErrorCodeInvalidRequestParameter = "INVPAR" ErrorCodeInvalidRequestBody = "INVBDY" ErrorCodeNonExistingAccount = "INVACC" + ErrorCodeIndeterminateAccount = "INDACC" ErrorCodeApiInconsistency = "APIINC" ErrorCodeInvalidUserRequest = "INVURQ" ) @@ -275,12 +276,18 @@ var ( Title: "Invalid Request", Detail: "The request is invalid.", } - ErrorNonExistingAccount = GroupwareError{ + ErrorIndeterminateAccount = GroupwareError{ Status: http.StatusBadRequest, Code: ErrorCodeNonExistingAccount, Title: "Invalid Account Parameter", Detail: "The account the request is for does not exist.", } + ErrorNonExistingAccount = GroupwareError{ + Status: http.StatusBadRequest, + Code: ErrorCodeIndeterminateAccount, + Title: "Failed to determine Account", + Detail: "The account the request is for could not be determined.", + } ErrorApiInconsistency = GroupwareError{ Status: http.StatusInternalServerError, Code: ErrorCodeApiInconsistency, diff --git a/services/groupware/pkg/groupware/groupware_framework.go b/services/groupware/pkg/groupware/groupware_framework.go index dd70fb297e..0b0713c07d 100644 --- a/services/groupware/pkg/groupware/groupware_framework.go +++ b/services/groupware/pkg/groupware/groupware_framework.go @@ -5,15 +5,12 @@ import ( "crypto/tls" "encoding/json" "fmt" - "io" "net/http" "net/url" - "strconv" "sync/atomic" "time" "github.com/go-chi/chi/v5" - chimiddleware "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/render" "github.com/r3labs/sse/v2" "github.com/rs/zerolog" @@ -29,12 +26,12 @@ import ( "github.com/opencloud-eu/opencloud/services/groupware/pkg/config" "github.com/opencloud-eu/opencloud/services/groupware/pkg/metrics" - groupwaremiddleware "github.com/opencloud-eu/opencloud/services/groupware/pkg/middleware" ) const ( logUsername = "username" // this should match jmap.logUsername to avoid having the field twice in the logs under different keys logUserId = "user-id" + logAccountId = "account-id" logErrorId = "error-id" logErrorCode = "code" logErrorStatus = "status" @@ -129,6 +126,28 @@ type Event struct { Body any } +type groupwareHttpJmapApiClientMetricsRecorder struct { + m *metrics.Metrics +} + +func (r groupwareHttpJmapApiClientMetricsRecorder) OnSuccessfulRequest(endpoint string, status int) { + r.m.SuccessfulRequestPerEndpointCounter.With(metrics.Endpoint(endpoint)).Inc() +} +func (r groupwareHttpJmapApiClientMetricsRecorder) OnFailedRequest(endpoint string, err error) { + r.m.FailedRequestPerEndpointCounter.With(metrics.Endpoint(endpoint)).Inc() +} +func (r groupwareHttpJmapApiClientMetricsRecorder) OnFailedRequestWithStatus(endpoint string, status int) { + r.m.FailedRequestStatusPerEndpointCounter.With(metrics.EndpointAndStatus(endpoint, status)).Inc() +} +func (r groupwareHttpJmapApiClientMetricsRecorder) OnResponseBodyReadingError(endpoint string, err error) { + r.m.ResponseBodyReadingErrorPerEndpointCounter.With(metrics.Endpoint(endpoint)).Inc() +} +func (r groupwareHttpJmapApiClientMetricsRecorder) OnResponseBodyUnmarshallingError(endpoint string, err error) { + r.m.ResponseBodyUnmarshallingErrorPerEndpointCounter.With(metrics.Endpoint(endpoint)).Inc() +} + +var _ jmap.HttpJmapApiClientEventListener = groupwareHttpJmapApiClientMetricsRecorder{} + func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prometheusRegistry prometheus.Registerer) (*Groupware, error) { baseUrl, err := url.Parse(config.Mail.BaseUrl) if err != nil { @@ -136,6 +155,8 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome return nil, GroupwareInitializationError{Message: fmt.Sprintf("failed to parse configured Mail.BaseUrl '%s'", config.Mail.BaseUrl), Err: err} } + sessionUrl := baseUrl.JoinPath(".well-known", "jmap") + masterUsername := config.Mail.Master.Username if masterUsername == "" { logger.Error().Msg("failed to parse empty Mail.Master.Username") @@ -163,7 +184,7 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome insecureTls := true // TODO make configurable - m := metrics.New(logger) + m := metrics.New(prometheusRegistry, logger) // TODO add timeouts and other meaningful configuration settings for the HTTP client tr := http.DefaultTransport.(*http.Transport).Clone() @@ -177,16 +198,13 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome userProvider := NewRevaContextUsernameProvider() - api := jmap.NewHttpJmapApiClient( - *baseUrl, + jmapMetricsAdapter := groupwareHttpJmapApiClientMetricsRecorder{m: m} + + api := jmap.NewHttpJmapClient( &c, masterUsername, masterPassword, - jmap.HttpJmapApiClientMetrics{ - SuccessfulRequestPerEndpointCounter: m.SuccessfulRequestPerEndpointCounter, - FailedRequestPerEndpointCounter: m.FailedRequestPerEndpointCounter, - FailedRequestStatusPerEndpointCounter: m.FailedRequestStatusPerEndpointCounter, - }, + jmapMetricsAdapter, ) jmapClient := jmap.NewClient(api, api, api) @@ -197,6 +215,10 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome logger: logger, jmapClient: &jmapClient, errorTtl: sessionFailureCacheTtl, + sessionUrlProvider: func(username string) (*url.URL, *GroupwareError) { + // here is where we would implement server sharding + return sessionUrl, nil + }, } sessionCache = ttlcache.New( @@ -238,36 +260,20 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome sessionEventListener := sessionEventListener{ sessionCache: sessionCache, logger: logger, - counter: prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: metrics.Namespace, - Subsystem: metrics.Subsystem, - Name: "outdated_sessions_count", - Help: "Counts outdated session events", - }), + counter: m.OutdatedSessionsCounter, } jmapClient.AddSessionEventListener(&sessionEventListener) // A channel to process SSE Events with a single worker. eventChannel := make(chan Event, eventChannelSize) { - totalWorkerBufferMetric, err := prometheus.NewConstMetric(prometheus.NewDesc( - prometheus.BuildFQName(metrics.Namespace, metrics.Subsystem, "event_buffer_size"), - "Size of the buffer channel for server-sent events to process", - nil, - nil, - ), prometheus.GaugeValue, float64(eventChannelSize)) + eventBufferSizeMetric, err := prometheus.NewConstMetric(m.EventBufferSizeDesc, prometheus.GaugeValue, float64(eventChannelSize)) if err != nil { - logger.Warn().Err(err).Msg("failed to create event_buffer_size metric") + logger.Warn().Err(err).Msgf("failed to create metric %v", m.EventBufferSizeDesc.String()) } else { - prometheusRegistry.Register(metrics.ConstMetricCollector{Metric: totalWorkerBufferMetric}) + prometheusRegistry.Register(metrics.ConstMetricCollector{Metric: eventBufferSizeMetric}) } - - prometheusRegistry.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ - Namespace: metrics.Namespace, - Subsystem: metrics.Subsystem, - Name: "event_buffer_queued", - Help: "Number of queued server-sent events", - }, func() float64 { + prometheusRegistry.Register(prometheus.NewGaugeFunc(m.EventBufferQueuedOpts, func() float64 { return float64(len(eventChannel)) })) } @@ -282,60 +288,35 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome sseServer.OnUnsubscribe = func(streamID string, sub *sse.Subscriber) { sseSubscribers.Add(-1) } - prometheusRegistry.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ - Namespace: metrics.Namespace, - Subsystem: metrics.Subsystem, - Name: "sse_subscribers", - Help: "Number of subscribers for server-sent event streams", - }, func() float64 { + prometheusRegistry.Register(prometheus.NewGaugeFunc(m.SSESubscribersOpts, func() float64 { return float64(sseSubscribers.Load()) })) } jobsChannel := make(chan Job, workerQueueSize) { - totalWorkerBufferMetric, err := prometheus.NewConstMetric(prometheus.NewDesc( - prometheus.BuildFQName(metrics.Namespace, metrics.Subsystem, "workers_buffer_size"), - "Size of the buffer channel for background worker jobs", - nil, - nil, - ), prometheus.GaugeValue, float64(workerQueueSize)) + totalWorkerBufferMetric, err := prometheus.NewConstMetric(m.WorkersBufferSizeDesc, prometheus.GaugeValue, float64(workerQueueSize)) if err != nil { - logger.Warn().Err(err).Msg("failed to create workers_buffer_size metric") + logger.Warn().Err(err).Msgf("failed to create metric %v", m.WorkersBufferSizeDesc.String()) } else { prometheusRegistry.Register(metrics.ConstMetricCollector{Metric: totalWorkerBufferMetric}) } - prometheusRegistry.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ - Namespace: metrics.Namespace, - Subsystem: metrics.Subsystem, - Name: "workers_buffer_queued", - Help: "Number of queued background jobs", - }, func() float64 { + prometheusRegistry.Register(prometheus.NewGaugeFunc(m.WorkersBufferQueuedOpts, func() float64 { return float64(len(jobsChannel)) })) } var busyWorkers atomic.Int32 { - totalWorkersMetric, err := prometheus.NewConstMetric(prometheus.NewDesc( - prometheus.BuildFQName(metrics.Namespace, metrics.Subsystem, "workers_total"), - "Total amount of background job workers", - nil, - nil, - ), prometheus.GaugeValue, float64(workerPoolSize)) + totalWorkersMetric, err := prometheus.NewConstMetric(m.TotalWorkersDesc, prometheus.GaugeValue, float64(workerPoolSize)) if err != nil { - logger.Warn().Err(err).Msg("failed to create workers_total metric") + logger.Warn().Err(err).Msgf("failed to create metric %v", m.TotalWorkersDesc.String()) } else { prometheusRegistry.Register(metrics.ConstMetricCollector{Metric: totalWorkersMetric}) } - prometheusRegistry.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ - Namespace: metrics.Namespace, - Subsystem: metrics.Subsystem, - Name: "workers_busy", - Help: "Number of background job workers that are currently busy executing jobs", - }, func() float64 { + prometheusRegistry.Register(prometheus.NewGaugeFunc(m.BusyWorkersOpts, func() float64 { return float64(busyWorkers.Load()) })) } @@ -382,7 +363,7 @@ func (g *Groupware) worker(jobs <-chan Job, busy *atomic.Int32) { logger := log.From(job.logger.With().Str(logJobDescription, job.description).Uint64(logJobId, job.id)) job.job(job.id, logger) if logger.Trace().Enabled() { - logger.Trace().Msgf("finished job %d [%s] in %v", job.id, job.description, time.Since(before)) // TODO remove + logger.Trace().Msgf("finished job %d [%s] in %v", job.id, job.description, time.Since(before)) } busy.Add(-1) } @@ -469,274 +450,6 @@ func (g *Groupware) session(user User, _ *http.Request, _ context.Context, _ *lo return jmap.Session{}, false, nil } -// using a wrapper class for requests, to group multiple parameters, really to avoid crowding the -// API of handlers but also to make it easier to expand it in the future without having to modify -// the parameter list of every single handler function -type Request struct { - g *Groupware - user User - r *http.Request - ctx context.Context - logger *log.Logger - session *jmap.Session -} - -type Response struct { - body any - status int - err *Error - etag jmap.State - sessionState jmap.SessionState -} - -func errorResponse(err *Error) Response { - return Response{ - body: nil, - err: err, - etag: "", - sessionState: "", - } -} - -func response(body any, sessionState jmap.SessionState) Response { - return Response{ - body: body, - err: nil, - etag: jmap.State(sessionState), - sessionState: sessionState, - } -} - -func etagResponse(body any, sessionState jmap.SessionState, etag jmap.State) Response { - return Response{ - body: body, - err: nil, - etag: etag, - sessionState: sessionState, - } -} - -func etagOnlyResponse(body any, etag jmap.State) Response { - return Response{ - body: body, - err: nil, - etag: etag, - sessionState: "", - } -} - -func noContentResponse(sessionState jmap.SessionState) Response { - return Response{ - body: nil, - status: http.StatusNoContent, - err: nil, - etag: jmap.State(sessionState), - sessionState: sessionState, - } -} - -func acceptedResponse(sessionState jmap.SessionState) Response { - return Response{ - body: nil, - status: http.StatusAccepted, - err: nil, - etag: jmap.State(sessionState), - sessionState: sessionState, - } -} - -func timeoutResponse(sessionState jmap.SessionState) Response { - return Response{ - body: nil, - status: http.StatusRequestTimeout, - err: nil, - etag: "", - sessionState: sessionState, - } -} - -func notFoundResponse(sessionState jmap.SessionState) Response { - return Response{ - body: nil, - status: http.StatusNotFound, - err: nil, - etag: jmap.State(sessionState), - sessionState: sessionState, - } -} - -func (r Request) push(typ string, event any) { - r.g.push(r.user, typ, event) -} - -func (r Request) GetUser() User { - return r.user -} - -func (r Request) GetRequestId() string { - return chimiddleware.GetReqID(r.ctx) -} - -func (r Request) GetTraceId() string { - return groupwaremiddleware.GetTraceID(r.ctx) -} - -func (r Request) GetAccountId() string { - accountId := chi.URLParam(r.r, UriParamAccount) - return r.session.MailAccountId(accountId) -} - -func (r Request) GetAccount() (jmap.SessionAccount, *Error) { - accountId := r.GetAccountId() - - account, ok := r.session.Accounts[accountId] - if !ok { - r.logger.Debug().Msgf("failed to find account '%v'", accountId) - // TODO metric for inexistent accounts - return jmap.SessionAccount{}, apiError(r.errorId(), ErrorNonExistingAccount, - withDetail(fmt.Sprintf("The account '%v' does not exist", log.SafeString(accountId))), - withSource(&ErrorSource{Parameter: UriParamAccount}), - ) - } - return account, nil -} - -func (r Request) parameterError(param string, detail string) *Error { - return r.observedParameterError(ErrorInvalidRequestParameter, - withDetail(detail), - withSource(&ErrorSource{Parameter: param})) -} - -func (r Request) parameterErrorResponse(param string, detail string) Response { - return errorResponse(r.parameterError(param, detail)) -} - -func (r Request) parseIntParam(param string, defaultValue int) (int, bool, *Error) { - q := r.r.URL.Query() - if !q.Has(param) { - return defaultValue, false, nil - } - - str := q.Get(param) - if str == "" { - return defaultValue, false, nil - } - - value, err := strconv.ParseInt(str, 10, 0) - if err != nil { - // don't include the original error, as it leaks too much about our implementation, e.g.: - // strconv.ParseInt: parsing \"a\": invalid syntax - msg := fmt.Sprintf("Invalid numeric value for query parameter '%v': '%s'", param, log.SafeString(str)) - return defaultValue, true, r.observedParameterError(ErrorInvalidRequestParameter, - withDetail(msg), - withSource(&ErrorSource{Parameter: param}), - ) - } - return int(value), true, nil -} - -func (r Request) parseUIntParam(param string, defaultValue uint) (uint, bool, *Error) { - q := r.r.URL.Query() - if !q.Has(param) { - return defaultValue, false, nil - } - - str := q.Get(param) - if str == "" { - return defaultValue, false, nil - } - - value, err := strconv.ParseUint(str, 10, 0) - if err != nil { - // don't include the original error, as it leaks too much about our implementation, e.g.: - // strconv.ParseInt: parsing \"a\": invalid syntax - msg := fmt.Sprintf("Invalid numeric value for query parameter '%v': '%s'", param, log.SafeString(str)) - return defaultValue, true, r.observedParameterError(ErrorInvalidRequestParameter, - withDetail(msg), - withSource(&ErrorSource{Parameter: param}), - ) - } - return uint(value), true, nil -} - -func (r Request) parseDateParam(param string) (time.Time, bool, *Error) { - q := r.r.URL.Query() - if !q.Has(param) { - return time.Time{}, false, nil - } - - str := q.Get(param) - if str == "" { - return time.Time{}, false, nil - } - - t, err := time.Parse(time.RFC3339, str) - if err != nil { - msg := fmt.Sprintf("Invalid RFC3339 value for query parameter '%v': '%s': %s", param, log.SafeString(str), err.Error()) - return time.Time{}, true, r.observedParameterError(ErrorInvalidRequestParameter, - withDetail(msg), - withSource(&ErrorSource{Parameter: param}), - ) - } - return t, true, nil -} - -func (r Request) parseBoolParam(param string, defaultValue bool) (bool, bool, *Error) { - q := r.r.URL.Query() - if !q.Has(param) { - return defaultValue, false, nil - } - - str := q.Get(param) - if str == "" { - return defaultValue, false, nil - } - - b, err := strconv.ParseBool(str) - if err != nil { - msg := fmt.Sprintf("Invalid boolean value for query parameter '%v': '%s': %s", param, log.SafeString(str), err.Error()) - return defaultValue, true, r.observedParameterError(ErrorInvalidRequestParameter, - withDetail(msg), - withSource(&ErrorSource{Parameter: param}), - ) - } - return b, true, nil -} - -func (r Request) body(target any) *Error { - body := r.r.Body - defer func(b io.ReadCloser) { - err := b.Close() - if err != nil { - r.logger.Error().Err(err).Msg("failed to close request body") - } - }(body) - - err := json.NewDecoder(body).Decode(target) - if err != nil { - return r.observedParameterError(ErrorInvalidRequestBody, withSource(&ErrorSource{Pointer: "/"})) // we don't get any details here - } - return nil -} - -func (r Request) observe(obs prometheus.Observer, value float64) { - metrics.WithExemplar(obs, value, r.GetRequestId(), r.GetTraceId()) -} - -func (r Request) observeParameterError(err *Error) *Error { - if err != nil { - r.g.metrics.ParameterErrorCounter.WithLabelValues(err.Code).Inc() - } - return err -} - -func (r Request) observeJmapError(jerr jmap.Error) jmap.Error { - if jerr != nil { - r.g.metrics.JmapErrorCounter.WithLabelValues(r.session.JmapEndpoint, strconv.Itoa(jerr.Code())).Inc() - } - return jerr -} - func (g *Groupware) log(error *Error) { var level *zerolog.Event if error.NumStatus < 300 { diff --git a/services/groupware/pkg/groupware/groupware_request.go b/services/groupware/pkg/groupware/groupware_request.go new file mode 100644 index 0000000000..06bdc0e402 --- /dev/null +++ b/services/groupware/pkg/groupware/groupware_request.go @@ -0,0 +1,259 @@ +package groupware + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/opencloud-eu/opencloud/pkg/jmap" + "github.com/opencloud-eu/opencloud/pkg/log" + + "github.com/opencloud-eu/opencloud/services/groupware/pkg/metrics" + groupwaremiddleware "github.com/opencloud-eu/opencloud/services/groupware/pkg/middleware" +) + +// using a wrapper class for requests, to group multiple parameters, really to avoid crowding the +// API of handlers but also to make it easier to expand it in the future without having to modify +// the parameter list of every single handler function +type Request struct { + g *Groupware + user User + r *http.Request + ctx context.Context + logger *log.Logger + session *jmap.Session +} + +func (r Request) push(typ string, event any) { + r.g.push(r.user, typ, event) +} + +func (r Request) GetUser() User { + return r.user +} + +func (r Request) GetRequestId() string { + return chimiddleware.GetReqID(r.ctx) +} + +func (r Request) GetTraceId() string { + return groupwaremiddleware.GetTraceID(r.ctx) +} + +var ( + errNoPrimaryAccountFallback = errors.New("no primary account fallback") + errNoPrimaryAccountForMail = errors.New("no primary account for mail") + errNoPrimaryAccountForBlob = errors.New("no primary account for blob") + errNoPrimaryAccountForVacationResponse = errors.New("no primary account for vacation response") + errNoPrimaryAccountForSubmission = errors.New("no primary account for submission") + // errNoPrimaryAccountForSieve = errors.New("no primary account for sieve") + // errNoPrimaryAccountForQuota = errors.New("no primary account for quota") + // errNoPrimaryAccountForWebsocket = errors.New("no primary account for websocket") +) + +func (r Request) GetAccountIdWithoutFallback() (string, *Error) { + accountId := chi.URLParam(r.r, UriParamAccountId) + if accountId == "" || accountId == defaultAccountId { + r.logger.Error().Err(errNoPrimaryAccountFallback).Msg("failed to determine the accountId") + return "", apiError(r.errorId(), ErrorNonExistingAccount, + withDetail("Failed to determine the account to use"), + withSource(&ErrorSource{Parameter: UriParamAccountId}), + ) + } + return accountId, nil +} + +func (r Request) getAccountId(fallback string, err error) (string, *Error) { + accountId := chi.URLParam(r.r, UriParamAccountId) + if accountId == "" || accountId == defaultAccountId { + accountId = fallback + } + if accountId == "" { + r.logger.Error().Err(err).Msg("failed to determine the accountId") + return "", apiError(r.errorId(), ErrorNonExistingAccount, + withDetail("Failed to determine the account to use"), + withSource(&ErrorSource{Parameter: UriParamAccountId}), + ) + } + return accountId, nil +} + +func (r Request) GetAccountIdForMail() (string, *Error) { + return r.getAccountId(r.session.PrimaryAccounts.Mail, errNoPrimaryAccountForMail) +} + +func (r Request) GetAccountIdForBlob() (string, *Error) { + return r.getAccountId(r.session.PrimaryAccounts.Blob, errNoPrimaryAccountForBlob) +} + +func (r Request) GetAccountIdForVacationResponse() (string, *Error) { + return r.getAccountId(r.session.PrimaryAccounts.VacationResponse, errNoPrimaryAccountForVacationResponse) +} + +func (r Request) GetAccountIdForSubmission() (string, *Error) { + return r.getAccountId(r.session.PrimaryAccounts.Blob, errNoPrimaryAccountForSubmission) +} + +func (r Request) GetAccountForMail() (jmap.SessionAccount, *Error) { + accountId, err := r.GetAccountIdForMail() + if err != nil { + return jmap.SessionAccount{}, err + } + + account, ok := r.session.Accounts[accountId] + if !ok { + r.logger.Debug().Msgf("failed to find account '%v'", accountId) + // TODO metric for inexistent accounts + return jmap.SessionAccount{}, apiError(r.errorId(), ErrorNonExistingAccount, + withDetail(fmt.Sprintf("The account '%v' does not exist", log.SafeString(accountId))), + withSource(&ErrorSource{Parameter: UriParamAccountId}), + ) + } + return account, nil +} + +func (r Request) parameterError(param string, detail string) *Error { + return r.observedParameterError(ErrorInvalidRequestParameter, + withDetail(detail), + withSource(&ErrorSource{Parameter: param})) +} + +func (r Request) parameterErrorResponse(param string, detail string) Response { + return errorResponse(r.parameterError(param, detail)) +} + +func (r Request) parseIntParam(param string, defaultValue int) (int, bool, *Error) { + q := r.r.URL.Query() + if !q.Has(param) { + return defaultValue, false, nil + } + + str := q.Get(param) + if str == "" { + return defaultValue, false, nil + } + + value, err := strconv.ParseInt(str, 10, 0) + if err != nil { + // don't include the original error, as it leaks too much about our implementation, e.g.: + // strconv.ParseInt: parsing \"a\": invalid syntax + msg := fmt.Sprintf("Invalid numeric value for query parameter '%v': '%s'", param, log.SafeString(str)) + return defaultValue, true, r.observedParameterError(ErrorInvalidRequestParameter, + withDetail(msg), + withSource(&ErrorSource{Parameter: param}), + ) + } + return int(value), true, nil +} + +func (r Request) parseUIntParam(param string, defaultValue uint) (uint, bool, *Error) { + q := r.r.URL.Query() + if !q.Has(param) { + return defaultValue, false, nil + } + + str := q.Get(param) + if str == "" { + return defaultValue, false, nil + } + + value, err := strconv.ParseUint(str, 10, 0) + if err != nil { + // don't include the original error, as it leaks too much about our implementation, e.g.: + // strconv.ParseInt: parsing \"a\": invalid syntax + msg := fmt.Sprintf("Invalid numeric value for query parameter '%v': '%s'", param, log.SafeString(str)) + return defaultValue, true, r.observedParameterError(ErrorInvalidRequestParameter, + withDetail(msg), + withSource(&ErrorSource{Parameter: param}), + ) + } + return uint(value), true, nil +} + +func (r Request) parseDateParam(param string) (time.Time, bool, *Error) { + q := r.r.URL.Query() + if !q.Has(param) { + return time.Time{}, false, nil + } + + str := q.Get(param) + if str == "" { + return time.Time{}, false, nil + } + + t, err := time.Parse(time.RFC3339, str) + if err != nil { + msg := fmt.Sprintf("Invalid RFC3339 value for query parameter '%v': '%s': %s", param, log.SafeString(str), err.Error()) + return time.Time{}, true, r.observedParameterError(ErrorInvalidRequestParameter, + withDetail(msg), + withSource(&ErrorSource{Parameter: param}), + ) + } + return t, true, nil +} + +func (r Request) parseBoolParam(param string, defaultValue bool) (bool, bool, *Error) { + q := r.r.URL.Query() + if !q.Has(param) { + return defaultValue, false, nil + } + + str := q.Get(param) + if str == "" { + return defaultValue, false, nil + } + + b, err := strconv.ParseBool(str) + if err != nil { + msg := fmt.Sprintf("Invalid boolean value for query parameter '%v': '%s': %s", param, log.SafeString(str), err.Error()) + return defaultValue, true, r.observedParameterError(ErrorInvalidRequestParameter, + withDetail(msg), + withSource(&ErrorSource{Parameter: param}), + ) + } + return b, true, nil +} + +func (r Request) body(target any) *Error { + body := r.r.Body + defer func(b io.ReadCloser) { + err := b.Close() + if err != nil { + r.logger.Error().Err(err).Msg("failed to close request body") + } + }(body) + + err := json.NewDecoder(body).Decode(target) + if err != nil { + return r.observedParameterError(ErrorInvalidRequestBody, withSource(&ErrorSource{Pointer: "/"})) // we don't get any details here + } + return nil +} + +func (r Request) observe(obs prometheus.Observer, value float64) { + metrics.WithExemplar(obs, value, r.GetRequestId(), r.GetTraceId()) +} + +func (r Request) observeParameterError(err *Error) *Error { + if err != nil { + r.g.metrics.ParameterErrorCounter.WithLabelValues(err.Code).Inc() + } + return err +} + +func (r Request) observeJmapError(jerr jmap.Error) jmap.Error { + if jerr != nil { + r.g.metrics.JmapErrorCounter.WithLabelValues(r.session.JmapEndpoint, strconv.Itoa(jerr.Code())).Inc() + } + return jerr +} diff --git a/services/groupware/pkg/groupware/groupware_response.go b/services/groupware/pkg/groupware/groupware_response.go new file mode 100644 index 0000000000..c8abb3222f --- /dev/null +++ b/services/groupware/pkg/groupware/groupware_response.go @@ -0,0 +1,95 @@ +package groupware + +import ( + "net/http" + + "github.com/opencloud-eu/opencloud/pkg/jmap" +) + +type Response struct { + body any + status int + err *Error + etag jmap.State + sessionState jmap.SessionState +} + +func errorResponse(err *Error) Response { + return Response{ + body: nil, + err: err, + etag: "", + sessionState: "", + } +} + +func response(body any, sessionState jmap.SessionState) Response { + return Response{ + body: body, + err: nil, + etag: jmap.State(sessionState), + sessionState: sessionState, + } +} + +func etagResponse(body any, sessionState jmap.SessionState, etag jmap.State) Response { + return Response{ + body: body, + err: nil, + etag: etag, + sessionState: sessionState, + } +} + +func etagOnlyResponse(body any, etag jmap.State) Response { + return Response{ + body: body, + err: nil, + etag: etag, + sessionState: "", + } +} + +func noContentResponse(sessionState jmap.SessionState) Response { + return Response{ + body: nil, + status: http.StatusNoContent, + err: nil, + etag: jmap.State(sessionState), + sessionState: sessionState, + } +} + +/* +func acceptedResponse(sessionState jmap.SessionState) Response { + return Response{ + body: nil, + status: http.StatusAccepted, + err: nil, + etag: jmap.State(sessionState), + sessionState: sessionState, + } +} +*/ + +/* +func timeoutResponse(sessionState jmap.SessionState) Response { + return Response{ + body: nil, + status: http.StatusRequestTimeout, + err: nil, + etag: "", + sessionState: sessionState, + } +} +*/ + +func notFoundResponse(sessionState jmap.SessionState) Response { + return Response{ + body: nil, + status: http.StatusNotFound, + err: nil, + etag: jmap.State(sessionState), + sessionState: sessionState, + } +} diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go index f763f86e25..6e97990356 100644 --- a/services/groupware/pkg/groupware/groupware_route.go +++ b/services/groupware/pkg/groupware/groupware_route.go @@ -5,7 +5,9 @@ import ( ) const ( - UriParamAccount = "accountid" + defaultAccountId = "*" + + UriParamAccountId = "accountid" UriParamMailboxId = "mailbox" UriParamMessageId = "messageid" UriParamBlobId = "blobid" diff --git a/services/groupware/pkg/groupware/groupware_session.go b/services/groupware/pkg/groupware/groupware_session.go index f1d7aeba1e..e5b63035ff 100644 --- a/services/groupware/pkg/groupware/groupware_session.go +++ b/services/groupware/pkg/groupware/groupware_session.go @@ -1,6 +1,7 @@ package groupware import ( + "net/url" "time" "github.com/jellydator/ttlcache/v3" @@ -69,17 +70,23 @@ func (s failedSession) Since() time.Time { var _ cachedSession = failedSession{} type sessionCacheLoader struct { - logger *log.Logger - jmapClient *jmap.Client - errorTtl time.Duration + logger *log.Logger + sessionUrlProvider func(username string) (*url.URL, *GroupwareError) + jmapClient *jmap.Client + errorTtl time.Duration } func (l *sessionCacheLoader) Load(c *ttlcache.Cache[sessionKey, cachedSession], key sessionKey) *ttlcache.Item[sessionKey, cachedSession] { username := usernameFromSessionKey(key) - session, err := l.jmapClient.FetchSession(username, l.logger) - if err != nil { - l.logger.Warn().Str("username", username).Err(err).Msgf("failed to create session for '%v'", key) - return c.Set(key, failedSession{since: time.Now(), err: groupwareErrorFromJmap(err)}, l.errorTtl) + sessionUrl, gwerr := l.sessionUrlProvider(username) + if gwerr != nil { + l.logger.Warn().Str("username", username).Str("code", gwerr.Code).Msgf("failed to determine session URL for '%v'", key) + return c.Set(key, failedSession{since: time.Now(), err: gwerr}, l.errorTtl) + } + session, jerr := l.jmapClient.FetchSession(sessionUrl, username, l.logger) + if jerr != nil { + l.logger.Warn().Str("username", username).Err(jerr).Msgf("failed to create session for '%v'", key) + return c.Set(key, failedSession{since: time.Now(), err: groupwareErrorFromJmap(jerr)}, l.errorTtl) } else { l.logger.Debug().Str("username", username).Msgf("successfully created session for '%v'", key) return c.Set(key, succeededSession{since: time.Now(), session: session}, ttlcache.DefaultTTL) // use the TTL configured on the Cache diff --git a/services/groupware/pkg/metrics/metrics.go b/services/groupware/pkg/metrics/metrics.go index 5cb0514337..02ac85f4fd 100644 --- a/services/groupware/pkg/metrics/metrics.go +++ b/services/groupware/pkg/metrics/metrics.go @@ -2,6 +2,7 @@ package metrics import ( "reflect" + "strconv" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/prometheus/client_golang/prometheus" @@ -17,16 +18,27 @@ const ( // Metrics defines the available metrics of this service. type Metrics struct { - SessionCacheDesc *prometheus.Desc + SessionCacheDesc *prometheus.Desc + EventBufferSizeDesc *prometheus.Desc + EventBufferQueuedOpts prometheus.GaugeOpts + SSESubscribersOpts prometheus.GaugeOpts + WorkersBufferSizeDesc *prometheus.Desc + WorkersBufferQueuedOpts prometheus.GaugeOpts + TotalWorkersDesc *prometheus.Desc + BusyWorkersOpts prometheus.GaugeOpts + JmapErrorCounter *prometheus.CounterVec ParameterErrorCounter *prometheus.CounterVec AuthenticationFailureCounter prometheus.Counter SessionFailureCounter prometheus.Counter SSEEventsCounter *prometheus.CounterVec + OutdatedSessionsCounter prometheus.Counter - SuccessfulRequestPerEndpointCounter *prometheus.CounterVec - FailedRequestPerEndpointCounter *prometheus.CounterVec - FailedRequestStatusPerEndpointCounter *prometheus.CounterVec + SuccessfulRequestPerEndpointCounter *prometheus.CounterVec + FailedRequestPerEndpointCounter *prometheus.CounterVec + FailedRequestStatusPerEndpointCounter *prometheus.CounterVec + ResponseBodyReadingErrorPerEndpointCounter *prometheus.CounterVec + ResponseBodyUnmarshallingErrorPerEndpointCounter *prometheus.CounterVec EmailByIdDuration *prometheus.HistogramVec EmailSameSenderDuration *prometheus.HistogramVec @@ -92,7 +104,7 @@ var Values = struct { } // New initializes the available metrics. -func New(logger *log.Logger) *Metrics { +func New(registerer prometheus.Registerer, logger *log.Logger) *Metrics { m := &Metrics{ SessionCacheDesc: prometheus.NewDesc( prometheus.BuildFQName(Namespace, Subsystem, "session_cache"), @@ -100,6 +112,48 @@ func New(logger *log.Logger) *Metrics { []string{Labels.SessionCacheType}, nil, ), + EventBufferSizeDesc: prometheus.NewDesc( + prometheus.BuildFQName(Namespace, Subsystem, "event_buffer_size"), + "Size of the buffer channel for server-sent events to process", + nil, + nil, + ), + EventBufferQueuedOpts: prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "event_buffer_queued", + Help: "Number of queued server-sent events", + }, + SSESubscribersOpts: prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "sse_subscribers", + Help: "Number of subscribers for server-sent event streams", + }, + WorkersBufferSizeDesc: prometheus.NewDesc( + prometheus.BuildFQName(Namespace, Subsystem, "workers_buffer_size"), + "Size of the buffer channel for background worker jobs", + nil, + nil, + ), + WorkersBufferQueuedOpts: prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "workers_buffer_queued", + Help: "Number of queued background jobs", + }, + TotalWorkersDesc: prometheus.NewDesc( + prometheus.BuildFQName(Namespace, Subsystem, "workers_total"), + "Total amount of background job workers", + nil, + nil, + ), + BusyWorkersOpts: prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "workers_busy", + Help: "Number of background job workers that are currently busy executing jobs", + }, AuthenticationFailureCounter: prometheus.NewCounter(prometheus.CounterOpts{ Namespace: Namespace, Subsystem: Subsystem, @@ -148,12 +202,30 @@ func New(logger *log.Logger) *Metrics { Name: "jmap_requests_failures_status_count", Help: "Number of JMAP requests", }, []string{Labels.Endpoint, Labels.HttpStatusCode}), + ResponseBodyReadingErrorPerEndpointCounter: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "jmap_requests_body_reading_errors_count", + Help: "Number of JMAP body reading errors", + }, []string{Labels.Endpoint}), + ResponseBodyUnmarshallingErrorPerEndpointCounter: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "jmap_requests_body_unmarshalling_errors_count", + Help: "Number of JMAP body unmarshalling errors", + }, []string{Labels.Endpoint}), SSEEventsCounter: prometheus.NewCounterVec(prometheus.CounterOpts{ Namespace: Namespace, Subsystem: Subsystem, Name: "sse_events_count", Help: "Number of Server-Side Events that have been sent", }, []string{Labels.SSEType}), + OutdatedSessionsCounter: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "outdated_sessions_count", + Help: "Counts outdated session events", + }), EmailByIdDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: Namespace, Subsystem: Subsystem, @@ -177,7 +249,7 @@ func New(logger *log.Logger) *Metrics { }, []string{Labels.Endpoint}), } - registerAll(m, logger) + registerAll(registerer, m, logger) return m } @@ -186,23 +258,31 @@ func WithExemplar(obs prometheus.Observer, value float64, requestId string, trac obs.(prometheus.ExemplarObserver).ObserveWithExemplar(value, prometheus.Labels{Labels.RequestId: requestId, Labels.TraceId: traceId}) } -func registerAll(m any, logger *log.Logger) { +func registerAll(registerer prometheus.Registerer, m any, logger *log.Logger) { r := reflect.ValueOf(m) if r.Kind() == reflect.Pointer { r = r.Elem() } + total := 0 + succeeded := 0 + failed := 0 for i := 0; i < r.NumField(); i++ { n := r.Type().Field(i).Name f := r.Field(i) v := f.Interface() c, ok := v.(prometheus.Collector) if ok { - err := prometheus.Register(c) + total++ + err := registerer.Register(c) if err != nil { + failed++ logger.Warn().Err(err).Msgf("failed to register metric '%s' (%T)", n, c) + } else { + succeeded++ } } } + logger.Debug().Msgf("registered %d/%d metrics successfully (%d failed)", succeeded, total, failed) } type ConstMetricCollector struct { @@ -221,26 +301,43 @@ type LoggingPrometheusRegisterer struct { logger *log.Logger } -func NewLoggingPrometheusRegisterer(delegate prometheus.Registerer, logger *log.Logger) LoggingPrometheusRegisterer { - return LoggingPrometheusRegisterer{ +func NewLoggingPrometheusRegisterer(delegate prometheus.Registerer, logger *log.Logger) *LoggingPrometheusRegisterer { + return &LoggingPrometheusRegisterer{ delegate: delegate, logger: logger, } } -func (r LoggingPrometheusRegisterer) Register(c prometheus.Collector) error { +func (r *LoggingPrometheusRegisterer) Register(c prometheus.Collector) error { err := r.delegate.Register(c) if err != nil { - r.logger.Warn().Err(err).Msgf("failed to register metric") + switch err.(type) { + case prometheus.AlreadyRegisteredError: + // silently ignore this error, as this case can happen when the suture service decides to restart + err = nil + default: + r.logger.Warn().Err(err).Msgf("failed to register metric") + } } return err } -func (r LoggingPrometheusRegisterer) MustRegister(...prometheus.Collector) { - panic("don't use MustRegister") + +func (r *LoggingPrometheusRegisterer) MustRegister(collectors ...prometheus.Collector) { + for _, c := range collectors { + r.Register(c) + } } -func (r LoggingPrometheusRegisterer) Unregister(c prometheus.Collector) bool { +func (r *LoggingPrometheusRegisterer) Unregister(c prometheus.Collector) bool { return r.delegate.Unregister(c) } -var _ prometheus.Registerer = LoggingPrometheusRegisterer{} +var _ prometheus.Registerer = &LoggingPrometheusRegisterer{} + +func Endpoint(endpoint string) prometheus.Labels { + return prometheus.Labels{Labels.Endpoint: endpoint} +} + +func EndpointAndStatus(endpoint string, status int) prometheus.Labels { + return prometheus.Labels{Labels.Endpoint: endpoint, Labels.HttpStatusCode: strconv.Itoa(status)} +} diff --git a/services/groupware/pkg/middleware/groupware_logger.go b/services/groupware/pkg/middleware/groupware_logger.go index b7e265908b..bdea8fe7a6 100644 --- a/services/groupware/pkg/middleware/groupware_logger.go +++ b/services/groupware/pkg/middleware/groupware_logger.go @@ -11,16 +11,29 @@ import ( func GroupwareLogger(logger log.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - level := logger.Debug() - if !level.Enabled() { - next.ServeHTTP(w, r) - return - } - start := time.Now() wrap := middleware.NewWrapResponseWriter(w, r.ProtoMajor) next.ServeHTTP(wrap, r) + level := logger.Debug() + err := recover() + if err != nil { + level = logger.Error() + } + + if !level.Enabled() { + return + } + + if err != nil { + switch e := err.(type) { + case error: + level = level.Err(e) + default: + level = level.Any("panic", e) + } + } + ctx := r.Context() requestID := middleware.GetReqID(ctx)