From 4dcbb5d8e35404832591bdcd3513ca2d3d92abc2 Mon Sep 17 00:00:00 2001 From: Pascal Bleser Date: Thu, 31 Jul 2025 20:34:01 +0200 Subject: [PATCH] groupware: further implementation and improvements --- pkg/jmap/jmap.go | 174 ++++++++++++------ pkg/jmap/jmap_error.go | 1 + pkg/jmap/jmap_http.go | 12 +- pkg/jmap/jmap_model.go | 89 ++++++--- pkg/jmap/jmap_test.go | 6 +- pkg/jmap/jmap_tools.go | 46 ++++- .../pkg/groupware/groupware_api_account.go | 12 +- .../pkg/groupware/groupware_api_identity.go | 4 +- .../pkg/groupware/groupware_api_index.go | 1 + .../pkg/groupware/groupware_api_mailbox.go | 49 +---- .../pkg/groupware/groupware_api_messages.go | 77 ++++++++ .../pkg/groupware/groupware_api_vacation.go | 4 +- .../pkg/groupware/groupware_error.go | 112 ++++++++++- .../pkg/groupware/groupware_framework.go | 174 +++++++++++++++--- .../pkg/groupware/groupware_route.go | 15 +- .../pkg/groupware/groupware_session.go | 2 +- .../pkg/groupware/groupware_tools.go | 30 --- 17 files changed, 605 insertions(+), 203 deletions(-) create mode 100644 services/groupware/pkg/groupware/groupware_api_messages.go delete mode 100644 services/groupware/pkg/groupware/groupware_tools.go diff --git a/pkg/jmap/jmap.go b/pkg/jmap/jmap.go index 363f00eb7..65580d0f8 100644 --- a/pkg/jmap/jmap.go +++ b/pkg/jmap/jmap.go @@ -10,9 +10,14 @@ import ( "github.com/rs/zerolog" ) +type SessionEventListener interface { + OnSessionOutdated(session *Session) +} + type Client struct { - wellKnown SessionClient - api ApiClient + wellKnown SessionClient + api ApiClient + sessionEventListeners *eventListeners[SessionEventListener] io.Closer } @@ -22,8 +27,9 @@ func (j *Client) Close() error { func NewClient(wellKnown SessionClient, api ApiClient) Client { return Client{ - wellKnown: wellKnown, - api: api, + wellKnown: wellKnown, + api: api, + sessionEventListeners: newEventListeners[SessionEventListener](), } } @@ -52,19 +58,31 @@ type Session struct { // The base URL to use for JMAP operations towards Stalwart JmapUrl url.URL // TODO - MailAccountId string + DefaultMailAccountId string SessionResponse } +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.DefaultMailAccountId +} + const ( - logOperation = "operation" - logUsername = "username" - logAccountId = "account-id" - logMailboxId = "mailbox-id" - logFetchBodies = "fetch-bodies" - logOffset = "offset" - logLimit = "limit" + logOperation = "operation" + logUsername = "username" + logAccountId = "account-id" + logMailboxId = "mailbox-id" + logFetchBodies = "fetch-bodies" + logOffset = "offset" + logLimit = "limit" + logApiUrl = "apiurl" + logSessionState = "session-state" + + defaultAccountId = "*" emailSortByReceivedAt = "receivedAt" emailSortBySize = "size" @@ -79,13 +97,25 @@ const ( // 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.Logger{ - Logger: l.With().Str(logUsername, s.Username).Str(logAccountId, s.MailAccountId).Logger(), - } + return log.Logger{Logger: l.With(). + Str(logUsername, s.Username). + Str(logApiUrl, s.ApiUrl). + Str(logSessionState, s.State). + Logger()} +} + +func (j *Client) AddSessionEventListener(listener SessionEventListener) { + j.sessionEventListeners.add(listener) +} + +func (j *Client) onSessionOutdated(session *Session) { + j.sessionEventListeners.signal(func(listener SessionEventListener) { + listener.OnSessionOutdated(session) + }) } // Create a new Session from a WellKnownResponse. -func NewSession(sessionResponse SessionResponse) (Session, Error) { +func newSession(sessionResponse SessionResponse) (Session, Error) { username := sessionResponse.Username if username == "" { return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a username")} @@ -103,10 +133,10 @@ func NewSession(sessionResponse SessionResponse) (Session, Error) { return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response provides an invalid API URL")} } return Session{ - Username: username, - MailAccountId: mailAccountId, - JmapUrl: *apiUrl, - SessionResponse: sessionResponse, + Username: username, + DefaultMailAccountId: mailAccountId, + JmapUrl: *apiUrl, + SessionResponse: sessionResponse, }, nil } @@ -116,26 +146,34 @@ func (j *Client) FetchSession(username string, logger *log.Logger) (Session, Err if err != nil { return Session{}, err } - return NewSession(wk) + return newSession(wk) } -func (j *Client) logger(operation string, session *Session, logger *log.Logger) *log.Logger { - return &log.Logger{Logger: logger.With().Str(logOperation, operation).Str(logUsername, session.Username).Str(logAccountId, session.MailAccountId).Logger()} +func (j *Client) logger(accountId string, operation string, session *Session, logger *log.Logger) *log.Logger { + zc := logger.With().Str(logOperation, operation).Str(logUsername, session.Username) + if accountId != "" { + zc = zc.Str(logAccountId, accountId) + } + return &log.Logger{Logger: zc.Logger()} } -func (j *Client) loggerParams(operation string, session *Session, logger *log.Logger, params func(zerolog.Context) zerolog.Context) *log.Logger { - base := logger.With().Str(logOperation, operation).Str(logUsername, session.Username).Str(logAccountId, session.MailAccountId) - return &log.Logger{Logger: params(base).Logger()} +func (j *Client) loggerParams(accountId string, operation string, session *Session, logger *log.Logger, params func(zerolog.Context) zerolog.Context) *log.Logger { + zc := logger.With().Str(logOperation, operation).Str(logUsername, session.Username) + if accountId != "" { + zc = zc.Str(logAccountId, accountId) + } + return &log.Logger{Logger: params(zc).Logger()} } // https://jmap.io/spec-mail.html#identityget -func (j *Client) GetIdentity(session *Session, ctx context.Context, logger *log.Logger) (IdentityGetResponse, Error) { - logger = j.logger("GetIdentity", session, logger) - cmd, err := request(invocation(IdentityGet, IdentityGetCommand{AccountId: session.MailAccountId}, "0")) +func (j *Client) GetIdentity(accountId string, session *Session, ctx context.Context, logger *log.Logger) (IdentityGetResponse, Error) { + aid := session.MailAccountId(accountId) + logger = j.logger(aid, "GetIdentity", session, logger) + cmd, err := request(invocation(IdentityGet, IdentityGetCommand{AccountId: aid}, "0")) if err != nil { return IdentityGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } - return command(j.api, logger, ctx, session, cmd, func(body *Response) (IdentityGetResponse, Error) { + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (IdentityGetResponse, Error) { var response IdentityGetResponse err = retrieveResponseMatchParameters(body, IdentityGet, "0", &response) return response, simpleError(err, JmapErrorInvalidJmapResponsePayload) @@ -143,13 +181,14 @@ func (j *Client) GetIdentity(session *Session, ctx context.Context, logger *log. } // https://jmap.io/spec-mail.html#vacationresponseget -func (j *Client) GetVacationResponse(session *Session, ctx context.Context, logger *log.Logger) (VacationResponseGetResponse, Error) { - logger = j.logger("GetVacationResponse", session, logger) - cmd, err := request(invocation(VacationResponseGet, VacationResponseGetCommand{AccountId: session.MailAccountId}, "0")) +func (j *Client) GetVacationResponse(accountId string, session *Session, ctx context.Context, logger *log.Logger) (VacationResponseGetResponse, Error) { + aid := session.MailAccountId(accountId) + logger = j.logger(aid, "GetVacationResponse", session, logger) + cmd, err := request(invocation(VacationResponseGet, VacationResponseGetCommand{AccountId: aid}, "0")) if err != nil { return VacationResponseGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } - return command(j.api, logger, ctx, session, cmd, func(body *Response) (VacationResponseGetResponse, Error) { + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (VacationResponseGetResponse, Error) { var response VacationResponseGetResponse err = retrieveResponseMatchParameters(body, VacationResponseGet, "0", &response) return response, simpleError(err, JmapErrorInvalidJmapResponsePayload) @@ -157,31 +196,33 @@ func (j *Client) GetVacationResponse(session *Session, ctx context.Context, logg } // https://jmap.io/spec-mail.html#mailboxget -func (j *Client) GetMailbox(session *Session, ctx context.Context, logger *log.Logger, ids []string) (MailboxGetResponse, Error) { - logger = j.logger("GetMailbox", session, logger) - cmd, err := request(invocation(MailboxGet, MailboxGetCommand{AccountId: session.MailAccountId, Ids: ids}, "0")) +func (j *Client) GetMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, ids []string) (MailboxGetResponse, Error) { + aid := session.MailAccountId(accountId) + logger = j.logger(aid, "GetMailbox", session, logger) + cmd, err := request(invocation(MailboxGet, MailboxGetCommand{AccountId: aid, Ids: ids}, "0")) if err != nil { return MailboxGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } - return command(j.api, logger, ctx, session, cmd, func(body *Response) (MailboxGetResponse, Error) { + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (MailboxGetResponse, Error) { var response MailboxGetResponse err = retrieveResponseMatchParameters(body, MailboxGet, "0", &response) return response, simpleError(err, JmapErrorInvalidJmapResponsePayload) }) } -func (j *Client) GetAllMailboxes(session *Session, ctx context.Context, logger *log.Logger) (MailboxGetResponse, Error) { - return j.GetMailbox(session, ctx, logger, nil) +func (j *Client) GetAllMailboxes(accountId string, session *Session, ctx context.Context, logger *log.Logger) (MailboxGetResponse, Error) { + return j.GetMailbox(accountId, session, ctx, logger, nil) } // https://jmap.io/spec-mail.html#mailboxquery -func (j *Client) QueryMailbox(session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (MailboxQueryResponse, Error) { - logger = j.logger("QueryMailbox", session, logger) - cmd, err := request(invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: session.MailAccountId, Filter: filter}, "0")) +func (j *Client) QueryMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (MailboxQueryResponse, Error) { + aid := session.MailAccountId(accountId) + logger = j.logger(aid, "QueryMailbox", session, logger) + cmd, err := request(invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: aid, Filter: filter}, "0")) if err != nil { return MailboxQueryResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } - return command(j.api, logger, ctx, session, cmd, func(body *Response) (MailboxQueryResponse, Error) { + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (MailboxQueryResponse, Error) { var response MailboxQueryResponse err = retrieveResponseMatchParameters(body, MailboxQuery, "0", &response) return response, simpleError(err, JmapErrorInvalidJmapResponsePayload) @@ -193,13 +234,14 @@ type Mailboxes struct { State string `json:"state,omitempty"` } -func (j *Client) SearchMailboxes(session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (Mailboxes, Error) { - logger = j.logger("SearchMailboxes", session, logger) +func (j *Client) SearchMailboxes(accountId string, session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (Mailboxes, Error) { + aid := session.MailAccountId(accountId) + logger = j.logger(aid, "SearchMailboxes", session, logger) cmd, err := request( - invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: session.MailAccountId, Filter: filter}, "0"), + invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: aid, Filter: filter}, "0"), invocation(MailboxGet, MailboxGetRefCommand{ - AccountId: session.MailAccountId, + AccountId: aid, IdRef: &Ref{Name: MailboxQuery, Path: "/ids/*", ResultOf: "0"}, }, "1"), ) @@ -207,7 +249,7 @@ func (j *Client) SearchMailboxes(session *Session, ctx context.Context, logger * return Mailboxes{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } - return command(j.api, logger, ctx, session, cmd, func(body *Response) (Mailboxes, Error) { + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (Mailboxes, Error) { var response MailboxGetResponse err = retrieveResponseMatchParameters(body, MailboxGet, "1", &response) if err != nil { @@ -222,13 +264,37 @@ type Emails struct { State string `json:"state,omitempty"` } -func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Logger, mailboxId string, offset int, limit int, fetchBodies bool, maxBodyValueBytes int) (Emails, Error) { - logger = j.loggerParams("GetEmails", session, logger, func(z zerolog.Context) zerolog.Context { +func (j *Client) GetEmails(accountId string, session *Session, ctx context.Context, logger *log.Logger, ids []string, fetchBodies bool, maxBodyValueBytes int) (Emails, Error) { + aid := session.MailAccountId(accountId) + logger = j.logger(aid, "GetEmails", session, logger) + + get := EmailGetCommand{AccountId: aid, Ids: ids, FetchAllBodyValues: fetchBodies} + if maxBodyValueBytes >= 0 { + get.MaxBodyValueBytes = maxBodyValueBytes + } + + cmd, err := request(invocation(EmailGet, get, "0")) + if err != nil { + return Emails{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} + } + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (Emails, Error) { + var response EmailGetResponse + err = retrieveResponseMatchParameters(body, EmailGet, "0", &response) + if err != nil { + return Emails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + return Emails{Emails: response.List, State: body.SessionState}, nil + }) +} + +func (j *Client) GetAllEmails(accountId string, session *Session, ctx context.Context, logger *log.Logger, mailboxId string, offset int, limit int, fetchBodies bool, maxBodyValueBytes int) (Emails, Error) { + aid := session.MailAccountId(accountId) + logger = j.loggerParams(aid, "GetAllEmails", session, logger, func(z zerolog.Context) zerolog.Context { return z.Bool(logFetchBodies, fetchBodies).Int(logOffset, offset).Int(logLimit, limit) }) query := EmailQueryCommand{ - AccountId: session.MailAccountId, + AccountId: aid, Filter: &MessageFilter{InMailbox: mailboxId}, Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}}, CollapseThreads: true, @@ -242,7 +308,7 @@ func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Lo } get := EmailGetRefCommand{ - AccountId: session.MailAccountId, + AccountId: aid, FetchAllBodyValues: fetchBodies, IdRef: &Ref{Name: EmailQuery, Path: "/ids/*", ResultOf: "0"}, } @@ -258,7 +324,7 @@ func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Lo return Emails{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } - return command(j.api, logger, ctx, session, cmd, func(body *Response) (Emails, Error) { + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (Emails, Error) { var response EmailGetResponse err = retrieveResponseMatchParameters(body, EmailGet, "1", &response) if err != nil { diff --git a/pkg/jmap/jmap_error.go b/pkg/jmap/jmap_error.go index 2a39d9481..df72d7fed 100644 --- a/pkg/jmap/jmap_error.go +++ b/pkg/jmap/jmap_error.go @@ -12,6 +12,7 @@ const ( JmapErrorInvalidSessionResponse JmapErrorInvalidJmapRequestPayload JmapErrorInvalidJmapResponsePayload + JmapErrorMethodLevel ) type Error interface { diff --git a/pkg/jmap/jmap_http.go b/pkg/jmap/jmap_http.go index 34c24a02c..ba2fb23ee 100644 --- a/pkg/jmap/jmap_http.go +++ b/pkg/jmap/jmap_http.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "net/http" - "net/http/httputil" "net/url" "github.com/opencloud-eu/opencloud/pkg/log" @@ -59,7 +58,7 @@ func (e AuthenticationError) Unwrap() error { return e.Err } -func (h *HttpJmapApiClient) auth(username string, logger *log.Logger, req *http.Request) error { +func (h *HttpJmapApiClient) auth(username string, _ *log.Logger, req *http.Request) error { masterUsername := username + "%" + h.masterUser req.SetBasicAuth(masterUsername, h.masterPassword) return nil @@ -128,15 +127,6 @@ func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses req.Header.Add("User-Agent", h.userAgent) h.auth(session.Username, logger, req) - { - if logger.Trace().Enabled() { - safereq := req.Clone(ctx) - safereq.Header.Set("Authorization", "***") - bytes, _ := httputil.DumpRequest(safereq, false) - logger.Info().Msgf("sending command: %s", string(bytes)) - } - } - res, err := h.client.Do(req) if err != nil { logger.Error().Err(err).Msgf("failed to perform POST %v", jmapUrl) diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index 808b52cbb..2dcd7ff1c 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -75,21 +75,35 @@ type SessionAccountCapabilities struct { } type SessionAccount struct { - Name string `json:"name,omitempty"` - IsPersonal bool `json:"isPersonal"` + // A user-friendly string to show when presenting content from this account, e.g., the email address representing the owner of the account. + Name string `json:"name,omitempty"` + // This is true if the account belongs to the authenticated user rather than a group account or a personal account of another user that has been shared with them. + IsPersonal bool `json:"isPersonal"` + // This is true if the entire account is read-only. IsReadOnly bool `json:"isReadOnly"` AccountCapabilities SessionAccountCapabilities `json:"accountCapabilities,omitempty"` } type SessionCoreCapabilities struct { - MaxSizeUpload int `json:"maxSizeUpload"` - MaxConcurrentUpload int `json:"maxConcurrentUpload"` - MaxSizeRequest int `json:"maxSizeRequest"` - MaxConcurrentRequests int `json:"maxConcurrentRequests"` - MaxCallsInRequest int `json:"maxCallsInRequest"` - MaxObjectsInGet int `json:"maxObjectsInGet"` - MaxObjectsInSet int `json:"maxObjectsInSet"` - CollationAlgorithms []string `json:"collationAlgorithms"` + // The maximum file size, in octets, that the server will accept for a single file upload (for any purpose) + MaxSizeUpload int `json:"maxSizeUpload"` + // The maximum number of concurrent requests the server will accept to the upload endpoint. + MaxConcurrentUpload int `json:"maxConcurrentUpload"` + // The maximum size, in octets, that the server will accept for a single request to the API endpoint. + MaxSizeRequest int `json:"maxSizeRequest"` + // The maximum number of concurrent requests the server will accept to the API endpoint. + MaxConcurrentRequests int `json:"maxConcurrentRequests"` + // The maximum number of method calls the server will accept in a single request to the API endpoint. + MaxCallsInRequest int `json:"maxCallsInRequest"` + // The maximum number of objects that the client may request in a single /get type method call. + MaxObjectsInGet int `json:"maxObjectsInGet"` + // The maximum number of objects the client may send to create, update, or destroy in a single /set type method call. + // This is the combined total, e.g., if the maximum is 10, you could not create 7 objects and destroy 6, as this would be 13 actions, + // which exceeds the limit. + MaxObjectsInSet int `json:"maxObjectsInSet"` + // A list of identifiers for algorithms registered in the collation registry, as defined in [@!RFC4790], that the server + // supports for sorting when querying records. + CollationAlgorithms []string `json:"collationAlgorithms"` } type SessionMailCapabilities struct { @@ -138,15 +152,33 @@ type SessionPrimaryAccounts struct { } type SessionResponse struct { - Capabilities SessionCapabilities `json:"capabilities,omitempty"` - Accounts map[string]SessionAccount `json:"accounts,omitempty"` - PrimaryAccounts SessionPrimaryAccounts `json:"primaryAccounts,omitempty"` - Username string `json:"username,omitempty"` - ApiUrl string `json:"apiUrl,omitempty"` - DownloadUrl string `json:"downloadUrl,omitempty"` - UploadUrl string `json:"uploadUrl,omitempty"` - EventSourceUrl string `json:"eventSourceUrl,omitempty"` - State string `json:"state,omitempty"` + Capabilities SessionCapabilities `json:"capabilities"` + Accounts map[string]SessionAccount `json:"accounts,omitempty"` + // A map of capability URIs (as found in accountCapabilities) to the account id that is considered to be the user’s main or default + // account for data pertaining to that capability. + // If no account being returned belongs to the user, or in any other way there is no appropriate way to determine a default account, + // there MAY be no entry for a particular URI, even though that capability is supported by the server (and in the capabilities object). + // urn:ietf:params:jmap:core SHOULD NOT be present. + PrimaryAccounts SessionPrimaryAccounts `json:"primaryAccounts"` + // The username associated with the given credentials, or the empty string if none. + Username string `json:"username,omitempty"` + // The URL to use for JMAP API requests. + ApiUrl string `json:"apiUrl,omitempty"` + // The URL endpoint to use when downloading files, in URI Template (level 1) format [@!RFC6570]. + // The URL MUST contain variables called accountId, blobId, type, and name. + DownloadUrl string `json:"downloadUrl,omitempty"` + // The URL endpoint to use when uploading files, in URI Template (level 1) format [@!RFC6570]. + // The URL MUST contain a variable called accountId. + UploadUrl string `json:"uploadUrl,omitempty"` + // The URL to connect to for push events, as described in Section 7.3, in URI Template (level 1) format [@!RFC6570]. + // The URL MUST contain variables called types, closeafter, and ping. + EventSourceUrl string `json:"eventSourceUrl,omitempty"` + // A (preferably short) string representing the state of this object on the server. + // If the value of any other property on the Session object changes, this string will change. + // The current value is also returned on the API Response object (see Section 3.4), allowing clients to quickly + // determine if the session information has changed (e.g., an account has been added or removed), + // so they need to refetch the object. + State string `json:"state,omitempty"` } type Mailbox struct { @@ -154,12 +186,12 @@ type Mailbox struct { Name string `json:"name,omitempty"` ParentId string `json:"parentId,omitempty"` Role string `json:"role,omitempty"` - SortOrder int `json:"sortOrder,omitempty"` - IsSubscribed bool `json:"isSubscribed,omitempty"` - TotalEmails int `json:"totalEmails,omitempty"` - UnreadEmails int `json:"unreadEmails,omitempty"` - TotalThreads int `json:"totalThreads,omitempty"` - UnreadThreads int `json:"unreadThreads,omitempty"` + SortOrder int `json:"sortOrder"` + IsSubscribed bool `json:"isSubscribed"` + TotalEmails int `json:"totalEmails"` + UnreadEmails int `json:"unreadEmails"` + TotalThreads int `json:"totalThreads"` + UnreadThreads int `json:"unreadThreads"` MyRights map[string]bool `json:"myRights,omitempty"` } @@ -234,6 +266,13 @@ type EmailQueryCommand struct { CalculateTotal bool `json:"calculateTotal,omitempty"` } +type EmailGetCommand struct { + AccountId string `json:"accountId"` + FetchAllBodyValues bool `json:"fetchAllBodyValues,omitempty"` + MaxBodyValueBytes int `json:"maxBodyValueBytes,omitempty"` + Ids []string `json:"ids,omitempty"` +} + type Ref struct { Name Command `json:"name"` Path string `json:"path,omitempty"` diff --git a/pkg/jmap/jmap_test.go b/pkg/jmap/jmap_test.go index d4f3a7970..ed531f404 100644 --- a/pkg/jmap/jmap_test.go +++ b/pkg/jmap/jmap_test.go @@ -109,13 +109,13 @@ func TestRequests(t *testing.T) { jmapUrl, err := url.Parse("http://localhost/jmap") require.NoError(err) - session := Session{MailAccountId: "123", Username: "user123", JmapUrl: *jmapUrl} + session := Session{DefaultMailAccountId: "123", Username: "user123", JmapUrl: *jmapUrl} - folders, err := client.GetAllMailboxes(&session, ctx, &logger) + folders, err := client.GetAllMailboxes("a", &session, ctx, &logger) require.NoError(err) require.Len(folders.List, 5) - emails, err := client.GetEmails(&session, ctx, &logger, "Inbox", 0, 0, true, 0) + emails, err := client.GetAllEmails("a", &session, ctx, &logger, "Inbox", 0, 0, true, 0) require.NoError(err) require.Len(emails.Emails, 3) diff --git a/pkg/jmap/jmap_tools.go b/pkg/jmap/jmap_tools.go index 8556c2d8e..a975b7f09 100644 --- a/pkg/jmap/jmap_tools.go +++ b/pkg/jmap/jmap_tools.go @@ -5,16 +5,43 @@ import ( "encoding/json" "fmt" "reflect" + "sync" "time" "github.com/mitchellh/mapstructure" "github.com/opencloud-eu/opencloud/pkg/log" ) +type eventListeners[T any] struct { + listeners []T + m sync.Mutex +} + +func (e *eventListeners[T]) add(listener T) { + e.m.Lock() + defer e.m.Unlock() + e.listeners = append(e.listeners, listener) +} + +func (e *eventListeners[T]) signal(signal func(T)) { + e.m.Lock() + defer e.m.Unlock() + for _, listener := range e.listeners { + signal(listener) + } +} + +func newEventListeners[T any]() *eventListeners[T] { + return &eventListeners[T]{ + listeners: []T{}, + } +} + func command[T any](api ApiClient, logger *log.Logger, ctx context.Context, session *Session, + sessionOutdatedHandler func(session *Session), request Request, mapper func(body *Response) (T, Error)) (T, Error) { @@ -33,7 +60,24 @@ func command[T any](api ApiClient, } if data.SessionState != session.State { - // TODO(pbleser-oc) handle session renewal + if sessionOutdatedHandler != nil { + sessionOutdatedHandler(session) + } + } + + // search for an "error" response + // https://jmap.io/spec-core.html#method-level-errors + for _, mr := range data.MethodResponses { + if mr.Command == "error" { + err := fmt.Errorf("found method level error in response '%v'", mr.Tag) + if payload, ok := mr.Parameters.(map[string]any); ok { + if errorType, ok := payload["type"]; ok { + err = fmt.Errorf("found method level error in response '%v', type: '%v'", mr.Tag, errorType) + } + } + var zero T + return zero, SimpleError{code: JmapErrorMethodLevel, err: err} + } } return mapper(&data) diff --git a/services/groupware/pkg/groupware/groupware_api_account.go b/services/groupware/pkg/groupware/groupware_api_account.go index f1b62dfd6..82673e0b1 100644 --- a/services/groupware/pkg/groupware/groupware_api_account.go +++ b/services/groupware/pkg/groupware/groupware_api_account.go @@ -6,10 +6,16 @@ import ( func (g Groupware) GetAccount(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) (any, string, *Error) { - account, ok := req.GetAccount() - if !ok { - return nil, "", nil + account, err := req.GetAccount() + if err != nil { + return nil, "", err } return account, req.session.State, nil }) } + +func (g Groupware) GetAccounts(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) (any, string, *Error) { + return req.session.Accounts, req.session.State, nil + }) +} diff --git a/services/groupware/pkg/groupware/groupware_api_identity.go b/services/groupware/pkg/groupware/groupware_api_identity.go index a472813bb..2645e2acb 100644 --- a/services/groupware/pkg/groupware/groupware_api_identity.go +++ b/services/groupware/pkg/groupware/groupware_api_identity.go @@ -6,7 +6,7 @@ import ( func (g Groupware) GetIdentity(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) (any, string, *Error) { - res, err := g.jmap.GetIdentity(req.session, req.ctx, req.logger) - return res, res.State, apiErrorFromJmap(err) + res, err := g.jmap.GetIdentity(req.GetAccountId(), req.session, req.ctx, req.logger) + return res, res.State, req.apiErrorFromJmap(err) }) } diff --git a/services/groupware/pkg/groupware/groupware_api_index.go b/services/groupware/pkg/groupware/groupware_api_index.go index 3bc8edba0..c37c1758e 100644 --- a/services/groupware/pkg/groupware/groupware_api_index.go +++ b/services/groupware/pkg/groupware/groupware_api_index.go @@ -5,6 +5,7 @@ import ( ) type IndexLimits struct { + // The maximum file size, in octets, that the server will accept for a single file upload (for any purpose). MaxSizeUpload int `json:"maxSizeUpload"` MaxConcurrentUpload int `json:"maxConcurrentUpload"` MaxSizeRequest int `json:"maxSizeRequest"` diff --git a/services/groupware/pkg/groupware/groupware_api_mailbox.go b/services/groupware/pkg/groupware/groupware_api_mailbox.go index b2aa4d3e3..0ec91b522 100644 --- a/services/groupware/pkg/groupware/groupware_api_mailbox.go +++ b/services/groupware/pkg/groupware/groupware_api_mailbox.go @@ -7,7 +7,6 @@ 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. @@ -32,23 +31,23 @@ type SwaggerGetMailboxById200 struct { // 400: ErrorResponse400 // 404: ErrorResponse404 // 500: ErrorResponse500 -func (g Groupware) GetMailboxById(w http.ResponseWriter, r *http.Request) { - mailboxId := chi.URLParam(r, "mailbox") +func (g Groupware) GetMailbox(w http.ResponseWriter, r *http.Request) { + mailboxId := chi.URLParam(r, UriParamMailboxId) if mailboxId == "" { w.WriteHeader(http.StatusBadRequest) return } g.respond(w, r, func(req Request) (any, string, *Error) { - res, err := g.jmap.GetMailbox(req.session, req.ctx, req.logger, []string{mailboxId}) + res, err := g.jmap.GetMailbox(req.GetAccountId(), req.session, req.ctx, req.logger, []string{mailboxId}) if err != nil { - return res, "", apiErrorFromJmap(err) + return res, "", req.apiErrorFromJmap(err) } if len(res.List) == 1 { - return res.List[0], res.State, apiErrorFromJmap(err) + return res.List[0], res.State, req.apiErrorFromJmap(err) } else { - return nil, res.State, apiErrorFromJmap(err) + return nil, res.State, req.apiErrorFromJmap(err) } }) } @@ -116,45 +115,17 @@ func (g Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) (any, string, *Error) { if hasCriteria { - mailboxes, err := g.jmap.SearchMailboxes(req.session, req.ctx, req.logger, filter) + mailboxes, err := g.jmap.SearchMailboxes(req.GetAccountId(), req.session, req.ctx, req.logger, filter) if err != nil { - return nil, "", apiErrorFromJmap(err) + return nil, "", req.apiErrorFromJmap(err) } return mailboxes.Mailboxes, mailboxes.State, nil } else { - mailboxes, err := g.jmap.GetAllMailboxes(req.session, req.ctx, req.logger) + mailboxes, err := g.jmap.GetAllMailboxes(req.GetAccountId(), req.session, req.ctx, req.logger) if err != nil { - return nil, "", apiErrorFromJmap(err) + return nil, "", req.apiErrorFromJmap(err) } return mailboxes.List, mailboxes.State, nil } }) } - -func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) { - mailboxId := chi.URLParam(r, "mailbox") - g.respond(w, r, func(req Request) (any, string, *Error) { - page, ok, _ := ParseNumericParam(r, "page", -1) - logger := req.logger - if ok { - logger = &log.Logger{Logger: logger.With().Int("page", page).Logger()} - } - size, ok, _ := ParseNumericParam(r, "size", -1) - if ok { - logger = &log.Logger{Logger: logger.With().Int("size", size).Logger()} - } - - offset := page * size - limit := size - if limit < 0 { - limit = g.defaultEmailLimit - } - - emails, err := g.jmap.GetEmails(req.session, req.ctx, logger, mailboxId, offset, limit, true, g.maxBodyValueBytes) - if err != nil { - return nil, "", apiErrorFromJmap(err) - } - - return emails, emails.State, nil - }) -} diff --git a/services/groupware/pkg/groupware/groupware_api_messages.go b/services/groupware/pkg/groupware/groupware_api_messages.go new file mode 100644 index 000000000..d9451fcb3 --- /dev/null +++ b/services/groupware/pkg/groupware/groupware_api_messages.go @@ -0,0 +1,77 @@ +package groupware + +import ( + "fmt" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/opencloud-eu/opencloud/pkg/log" +) + +func (g Groupware) GetAllMessages(w http.ResponseWriter, r *http.Request) { + mailboxId := chi.URLParam(r, UriParamMailboxId) + g.respond(w, r, func(req Request) (any, string, *Error) { + if mailboxId == "" { + errorId := req.errorId() + msg := fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId) + return nil, "", apiError(errorId, ErrorInvalidRequestParameter, + withDetail(msg), + withSource(&ErrorSource{Parameter: UriParamMailboxId}), + ) + } + page, ok, err := req.parseNumericParam(QueryParamPage, -1) + if err != nil { + return nil, "", err + } + logger := req.logger + if ok { + logger = &log.Logger{Logger: logger.With().Int(QueryParamPage, page).Logger()} + } + + size, ok, err := req.parseNumericParam(QueryParamSize, -1) + if err != nil { + return nil, "", err + } + if ok { + logger = &log.Logger{Logger: logger.With().Int(QueryParamSize, size).Logger()} + } + + offset := page * size + limit := size + if limit < 0 { + limit = g.defaultEmailLimit + } + + emails, jerr := g.jmap.GetAllEmails(req.GetAccountId(), req.session, req.ctx, logger, mailboxId, offset, limit, true, g.maxBodyValueBytes) + if jerr != nil { + return nil, "", req.apiErrorFromJmap(jerr) + } + + return emails, emails.State, nil + }) +} + +func (g Groupware) GetMessagesById(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, UriParamMessagesId) + g.respond(w, r, func(req Request) (any, string, *Error) { + ids := strings.Split(id, ",") + if len(ids) < 1 { + errorId := req.errorId() + msg := fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamMessagesId, logstr(id), "empty list of mail ids") + return nil, "", apiError(errorId, ErrorInvalidRequestParameter, + withDetail(msg), + withSource(&ErrorSource{Parameter: UriParamMessagesId}), + ) + } + + logger := &log.Logger{Logger: req.logger.With().Str("id", logstr(id)).Logger()} + emails, jerr := g.jmap.GetEmails(req.GetAccountId(), req.session, req.ctx, logger, ids, true, g.maxBodyValueBytes) + if jerr != nil { + return nil, "", req.apiErrorFromJmap(jerr) + } + + return emails, emails.State, nil + }) +} diff --git a/services/groupware/pkg/groupware/groupware_api_vacation.go b/services/groupware/pkg/groupware/groupware_api_vacation.go index a55d9864e..a1cbb72b3 100644 --- a/services/groupware/pkg/groupware/groupware_api_vacation.go +++ b/services/groupware/pkg/groupware/groupware_api_vacation.go @@ -30,7 +30,7 @@ type SwaggerVacationResponse200 struct { // 500: ErrorResponse500 func (g Groupware) GetVacation(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) (any, string, *Error) { - res, err := g.jmap.GetVacationResponse(req.session, req.ctx, req.logger) - return res, res.State, apiErrorFromJmap(err) + res, err := g.jmap.GetVacationResponse(req.GetAccountId(), req.session, req.ctx, req.logger) + return res, res.State, req.apiErrorFromJmap(err) }) } diff --git a/services/groupware/pkg/groupware/groupware_error.go b/services/groupware/pkg/groupware/groupware_error.go index 0b096998e..f4fd01225 100644 --- a/services/groupware/pkg/groupware/groupware_error.go +++ b/services/groupware/pkg/groupware/groupware_error.go @@ -1,9 +1,11 @@ package groupware import ( + "context" "net/http" "strconv" + chimiddleware "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/render" "github.com/google/uuid" "github.com/opencloud-eu/opencloud/pkg/jmap" @@ -11,6 +13,8 @@ import ( type Link struct { // A string whose value is a URI-reference [RFC3986 Section 4.1] pointing to the link’s target. + // + // [RFC3986 Section 4.1]: https://datatracker.ietf.org/doc/html/rfc3986#section-4.1 Href string `json:"href"` // A string indicating the link’s relation type. The string MUST be a valid link relation type. // required: false @@ -41,6 +45,8 @@ type ErrorSource struct { // A JSON Pointer [RFC6901] to the value in the request document that caused the error // (e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute). // This MUST point to a value in the request document that exists; if it doesn’t, the client SHOULD simply ignore the pointer. + // + // [RFC6901]: https://datatracker.ietf.org/doc/html/rfc6901 Pointer string `json:"pointer,omitempty"` // A string indicating which URI query parameter caused the error. Parameter string `json:"parameter,omitempty"` @@ -48,7 +54,9 @@ type ErrorSource struct { Header string `json:"header,omitempty"` } -// [Error](https://jsonapi.org/format/#error-objects) +// [Error] describes an error. +// +// [Error]: https://jsonapi.org/format/#error-objects type Error struct { // A unique identifier for this particular occurrence of the problem Id string `json:"id"` @@ -90,6 +98,9 @@ func (e ErrorResponse) Render(w http.ResponseWriter, r *http.Request) error { } const ( + // The [JSON:API] Content Type for errors + // + // [JSON:API]: https://jsonapi.org/ ContentTypeJsonApi = "application/vnd.api+json" ) @@ -134,6 +145,7 @@ func groupwareErrorFromJmap(j jmap.Error) *GroupwareError { const ( ErrorCodeGeneric = "ERRGEN" + ErrorCodeInvalidAuthentication = "AUTINV" ErrorCodeMissingAuthentication = "AUTMIS" ErrorCodeForbiddenGeneric = "AUTFOR" ErrorCodeInvalidRequest = "INVREQ" @@ -146,6 +158,8 @@ const ( ErrorCodeInvalidSessionResponse = "INVSES" ErrorCodeInvalidRequestPayload = "INVRQP" ErrorCodeInvalidResponsePayload = "INVRSP" + ErrorCodeInvalidRequestParameter = "INVPAR" + ErrorCodeNonExistingAccount = "INVACC" ) var ( @@ -155,6 +169,12 @@ var ( Title: "Unspecific Error", Detail: "Error without a specific description.", } + ErrorInvalidAuthentication = GroupwareError{ + Status: http.StatusUnauthorized, + Code: ErrorCodeMissingAuthentication, + Title: "Invalid Authentication", + Detail: "Failed to determine the authentication credentials.", + } ErrorMissingAuthentication = GroupwareError{ Status: http.StatusUnauthorized, Code: ErrorCodeMissingAuthentication, @@ -227,6 +247,18 @@ var ( Title: "Invalid Response Payload", Detail: "The payload of the response received from the mail server is invalid.", } + ErrorInvalidRequestParameter = GroupwareError{ + Status: http.StatusBadRequest, + Code: ErrorCodeInvalidRequestParameter, + Title: "Invalid Request Parameter", + Detail: "At least one of the parameters in the request is invalid.", + } + ErrorNonExistingAccount = GroupwareError{ + Status: http.StatusBadRequest, + Code: ErrorCodeNonExistingAccount, + Title: "Invalid Account Parameter", + Detail: "The account the request is for does not exist.", + } ) type ErrorOpt interface { @@ -241,6 +273,13 @@ func (o ErrorLinksOpt) apply(error *Error) { error.Links = o.links } +var _ = withLinks // unused for now, but will be +func withLinks(links *ErrorLinks) ErrorLinksOpt { + return ErrorLinksOpt{ + links: links, + } +} + type SourceLinksOpt struct { source *ErrorSource } @@ -249,6 +288,12 @@ func (o SourceLinksOpt) apply(error *Error) { error.Source = o.source } +func withSource(source *ErrorSource) SourceLinksOpt { + return SourceLinksOpt{ + source: source, + } +} + type MetaLinksOpt struct { meta map[string]any } @@ -257,6 +302,13 @@ func (o MetaLinksOpt) apply(error *Error) { error.Meta = o.meta } +var _ = withMeta // unused for now, but will be +func withMeta(meta map[string]any) MetaLinksOpt { + return MetaLinksOpt{ + meta: meta, + } +} + type CodeOpt struct { code string } @@ -265,6 +317,13 @@ func (o CodeOpt) apply(error *Error) { error.Code = o.code } +var _ = withCode // unused for now, but will be +func withCode(code string) CodeOpt { + return CodeOpt{ + code: code, + } +} + type TitleOpt struct { title string detail string @@ -275,6 +334,29 @@ func (o TitleOpt) apply(error *Error) { error.Detail = o.detail } +var _ = withTitle // unused for now, but will be +func withTitle(title string, detail string) TitleOpt { + return TitleOpt{ + title: title, + detail: detail, + } +} + +type DetailOpt struct { + detail string +} + +func (o DetailOpt) apply(error *Error) { + error.Detail = o.detail +} + +func withDetail(detail string) DetailOpt { + return DetailOpt{ + detail: detail, + } +} + +/* func errorResponse(id string, error GroupwareError, options ...ErrorOpt) ErrorResponse { err := Error{ Id: id, @@ -293,9 +375,24 @@ func errorResponse(id string, error GroupwareError, options ...ErrorOpt) ErrorRe Errors: []Error{err}, } } +*/ -func apiError(id string, error GroupwareError, options ...ErrorOpt) Error { - err := Error{ +func errorId(r *http.Request, ctx context.Context) string { + requestId := chimiddleware.GetReqID(ctx) + localId := uuid.NewString() + if requestId != "" { + return requestId + "." + localId + } else { + return localId + } +} + +func (r Request) errorId() string { + return errorId(r.r, r.ctx) +} + +func apiError(id string, error GroupwareError, options ...ErrorOpt) *Error { + err := &Error{ Id: id, NumStatus: error.Status, Status: strconv.Itoa(error.Status), @@ -305,13 +402,13 @@ func apiError(id string, error GroupwareError, options ...ErrorOpt) Error { } for _, o := range options { - o.apply(&err) + o.apply(err) } return err } -func apiErrorFromJmap(error jmap.Error) *Error { +func (r Request) apiErrorFromJmap(error jmap.Error) *Error { if error == nil { return nil } @@ -319,8 +416,9 @@ func apiErrorFromJmap(error jmap.Error) *Error { if gwe == nil { return nil } - api := apiError(uuid.NewString(), *gwe) - return &api + + errorId := r.errorId() + return apiError(errorId, *gwe) } func errorResponses(errors ...Error) ErrorResponse { diff --git a/services/groupware/pkg/groupware/groupware_framework.go b/services/groupware/pkg/groupware/groupware_framework.go index a10d3c16d..a81153b46 100644 --- a/services/groupware/pkg/groupware/groupware_framework.go +++ b/services/groupware/pkg/groupware/groupware_framework.go @@ -6,9 +6,11 @@ import ( "fmt" "net/http" "net/url" + "strconv" "github.com/go-chi/chi/v5" "github.com/go-chi/render" + "github.com/rs/zerolog" "github.com/jellydator/ttlcache/v3" @@ -18,8 +20,21 @@ import ( ) const ( - logFolderId = "folder-id" - logQuery = "query" + logUsername = "username" // this should match jmap.logUsername to avoid having the field twice in the logs under different keys + logErrorId = "error-id" + logErrorCode = "code" + logErrorStatus = "status" + logErrorSourceHeader = "source-header" + logErrorSourceParameter = "source-parameter" + logErrorSourcePointer = "source-pointer" + logInvalidQueryParameter = "error-query-param" + logInvalidPathParameter = "error-path-param" + logFolderId = "folder-id" + logQuery = "query" +) + +const ( + logMaxStrLength = 512 ) type Groupware struct { @@ -28,7 +43,7 @@ type Groupware struct { defaultEmailLimit int maxBodyValueBytes int sessionCache *ttlcache.Cache[string, cachedSession] - jmap jmap.Client + jmap *jmap.Client usernameProvider UsernameProvider } @@ -48,6 +63,18 @@ func (e GroupwareInitializationError) Unwrap() error { return e.Err } +type GroupwareSessionEventListener struct { + sessionCache *ttlcache.Cache[string, cachedSession] +} + +func (l GroupwareSessionEventListener) OnSessionOutdated(session *jmap.Session) { + // it's enough to remove the session from the cache, as it will be fetched on-demand + // the next time an operation is performed on behalf of the user + l.sessionCache.Delete(session.Username) +} + +var _ jmap.SessionEventListener = GroupwareSessionEventListener{} + func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux) (*Groupware, error) { baseUrl, err := url.Parse(config.Mail.BaseUrl) if err != nil { @@ -95,7 +122,7 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux) (*Gro { sessionLoader := &sessionCacheLoader{ logger: logger, - jmapClient: jmapClient, + jmapClient: &jmapClient, errorTtl: sessionFailureCacheTtl, } @@ -108,12 +135,15 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux) (*Gro go sessionCache.Start() } + sessionEventListener := GroupwareSessionEventListener{sessionCache: sessionCache} + jmapClient.AddSessionEventListener(&sessionEventListener) + return &Groupware{ mux: mux, logger: logger, sessionCache: sessionCache, usernameProvider: usernameProvider, - jmap: jmapClient, + jmap: &jmapClient, defaultEmailLimit: defaultEmailLimit, maxBodyValueBytes: maxBodyValueBytes, }, nil @@ -123,17 +153,8 @@ func (g Groupware) ServeHTTP(w http.ResponseWriter, r *http.Request) { g.mux.ServeHTTP(w, r) } -func (g Groupware) session(req *http.Request, ctx context.Context, logger *log.Logger) (jmap.Session, bool, error) { - username, ok, err := g.usernameProvider.GetUsername(req, ctx, logger) - if err != nil { - logger.Error().Err(err).Msg("failed to retrieve username") - return jmap.Session{}, false, err - } - if !ok { - logger.Debug().Msg("unauthenticated API access attempt") - return jmap.Session{}, false, nil - } - +// Provide a JMAP Session for the +func (g Groupware) session(username string, req *http.Request, ctx context.Context, logger *log.Logger) (jmap.Session, bool, error) { item := g.sessionCache.Get(username) if item != nil { value := item.Value() @@ -159,23 +180,121 @@ type Request struct { } func (r Request) GetAccountId() string { - return chi.URLParam(r.r, "account") + accountId := chi.URLParam(r.r, UriParamAccount) + return r.session.MailAccountId(accountId) } -func (r Request) GetAccount() (jmap.SessionAccount, bool) { +func (r Request) GetAccount() (jmap.SessionAccount, *Error) { accountId := r.GetAccountId() + account, ok := r.session.Accounts[accountId] if !ok { + errorId := r.errorId() r.logger.Debug().Msgf("failed to find account '%v'", accountId) - return jmap.SessionAccount{}, false + return jmap.SessionAccount{}, apiError(errorId, ErrorNonExistingAccount, + withDetail(fmt.Sprintf("The account '%v' does not exist", logstr(accountId))), + withSource(&ErrorSource{Parameter: UriParamAccount}), + ) } - return account, true + return account, nil +} + +func (r Request) parseNumericParam(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 { + errorId := r.errorId() + msg := fmt.Sprintf("Invalid value for query parameter '%v': '%s': %s", param, logstr(str), err.Error()) + return defaultValue, true, apiError(errorId, ErrorInvalidRequestParameter, + withDetail(msg), + withSource(&ErrorSource{Parameter: param}), + ) + } + return int(value), true, nil +} + +// Safely caps a string to a given size to avoid log bombing. +// Use this function to wrap strings that are user input (HTTP headers, path parameters, URI parameters, HTTP body, ...). +func logstr(text string) string { + runes := []rune(text) + + if len(runes) <= logMaxStrLength { + return text + } else { + return string(runes[0:logMaxStrLength-1]) + `\u2026` // hellip + } +} + +func (g Groupware) log(error *Error) { + var level *zerolog.Event + if error.NumStatus < 300 { + // shouldn't land here, but just in case: 1xx and 2xx are "OK" and should be logged as debug + level = g.logger.Debug() + } else if error.NumStatus == http.StatusUnauthorized || error.NumStatus == http.StatusForbidden { + // security related errors are logged as warnings + level = g.logger.Warn() + } else if error.NumStatus >= 500 { + // internal errors are potentially cause for concerned: bugs or third party systems malfunctioning, log as errors + level = g.logger.Error() + } else { + // everything else should be 4xx which indicates mistakes from the client, log as debug + level = g.logger.Debug() + } + if !level.Enabled() { + return + } + l := level.Str(logErrorCode, error.Code).Str(logErrorId, error.Id).Int(logErrorStatus, error.NumStatus) + if error.Source != nil { + if error.Source.Header != "" { + l.Str(logErrorSourceHeader, logstr(error.Source.Header)) + } + if error.Source.Parameter != "" { + l.Str(logErrorSourceParameter, logstr(error.Source.Parameter)) + } + if error.Source.Pointer != "" { + l.Str(logErrorSourcePointer, logstr(error.Source.Pointer)) + } + } + l.Msg(error.Title) +} + +func (g Groupware) serveError(w http.ResponseWriter, r *http.Request, error *Error) { + if error == nil { + return + } + g.log(error) + w.Header().Add("Content-Type", ContentTypeJsonApi) + render.Status(r, error.NumStatus) + w.WriteHeader(error.NumStatus) + render.Render(w, r, errorResponses(*error)) } func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(r Request) (any, string, *Error)) { ctx := r.Context() logger := g.logger.SubloggerWithRequestID(ctx) - session, ok, err := g.session(r, ctx, &logger) + + username, ok, err := g.usernameProvider.GetUsername(r, ctx, &logger) + if err != nil { + g.serveError(w, r, apiError(errorId(r, ctx), ErrorInvalidAuthentication)) + return + } + if !ok { + g.serveError(w, r, apiError(errorId(r, ctx), ErrorMissingAuthentication)) + return + } + + logger = log.Logger{Logger: logger.With().Str(logUsername, logstr(username)).Logger()} + + session, ok, err := g.session(username, r, ctx, &logger) if err != nil { logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg("failed to determine JMAP session") render.Status(r, http.StatusInternalServerError) @@ -198,7 +317,7 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func( response, state, apierr := handler(req) if apierr != nil { - logger.Warn().Interface("error", apierr).Msgf("API error: %v", apierr) + g.log(apierr) w.Header().Add("Content-Type", ContentTypeJsonApi) render.Status(r, apierr.NumStatus) w.WriteHeader(apierr.NumStatus) @@ -210,7 +329,6 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func( w.Header().Add("ETag", state) } if response == nil { - logger.Debug().Msgf("respond: response is nil, 404") render.Status(r, http.StatusNotFound) w.WriteHeader(http.StatusNotFound) } else { @@ -249,3 +367,13 @@ func (g Groupware) withSession(w http.ResponseWriter, r *http.Request, handler f return response, state, err } */ + +func (g Groupware) NotFound(w http.ResponseWriter, r *http.Request) { + level := g.logger.Debug() + if level.Enabled() { + path := logstr(r.URL.Path) + level.Str("path", path).Int(logErrorStatus, http.StatusNotFound).Msgf("unmatched path: '%v'", path) + } + render.Status(r, http.StatusNotFound) + w.WriteHeader(http.StatusNotFound) +} diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go index 62f19f304..f00e02000 100644 --- a/services/groupware/pkg/groupware/groupware_route.go +++ b/services/groupware/pkg/groupware/groupware_route.go @@ -4,14 +4,25 @@ import ( "github.com/go-chi/chi/v5" ) +const ( + UriParamAccount = "account" + UriParamMailboxId = "mailbox" + QueryParamPage = "page" + QueryParamSize = "size" + UriParamMessagesId = "id" +) + func (g Groupware) Route(r chi.Router) { r.Get("/", g.Index) + r.Get("/accounts", g.GetAccounts) r.Route("/accounts/{account}", func(r chi.Router) { r.Get("/", g.GetAccount) r.Get("/mailboxes", g.GetMailboxes) // ?name=&role=&subcribed= - r.Get("/mailboxes/{id}", g.GetMailboxById) - r.Get("/{mailbox}/messages", g.GetMessages) + r.Get("/mailboxes/{mailbox}", g.GetMailbox) + r.Get("/mailboxes/{mailbox}/messages", g.GetAllMessages) + r.Get("/messages/{id}", g.GetMessagesById) r.Get("/identity", g.GetIdentity) r.Get("/vacation", g.GetVacation) }) + r.NotFound(g.NotFound) } diff --git a/services/groupware/pkg/groupware/groupware_session.go b/services/groupware/pkg/groupware/groupware_session.go index 4ee2ecf68..7a83bdda1 100644 --- a/services/groupware/pkg/groupware/groupware_session.go +++ b/services/groupware/pkg/groupware/groupware_session.go @@ -48,7 +48,7 @@ var _ cachedSession = failedSession{} type sessionCacheLoader struct { logger *log.Logger - jmapClient jmap.Client + jmapClient *jmap.Client errorTtl time.Duration } diff --git a/services/groupware/pkg/groupware/groupware_tools.go b/services/groupware/pkg/groupware/groupware_tools.go deleted file mode 100644 index 6400742d9..000000000 --- a/services/groupware/pkg/groupware/groupware_tools.go +++ /dev/null @@ -1,30 +0,0 @@ -package groupware - -import ( - "net/http" - "strconv" - - "github.com/opencloud-eu/opencloud/pkg/jmap" -) - -func ParseNumericParam(r *http.Request, param string, defaultValue int) (int, bool, error) { - str := r.URL.Query().Get(param) - if str == "" { - return defaultValue, false, nil - } - - value, err := strconv.ParseInt(str, 10, 0) - if err != nil { - return defaultValue, false, nil - } - return int(value), true, nil -} - -func PickInbox(folders []jmap.Mailbox) string { - for _, folder := range folders { - if folder.Role == "inbox" { - return folder.Id - } - } - return "" -}