From 0247c28d5859d140dfdc583aef2eeffeeb0f78e6 Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Fri, 25 Jul 2025 15:19:46 +0200
Subject: [PATCH] Refactor groupware service after ADR decision on the
Groupware API
* after having decided that the Groupware API should be a standalone
independent custom REST API that is using JMAP data models as much as
possible,
* removed Groupware APIs from the Graph service
* moved Groupware implementation to the Groupware service, and
refactored a few things accordingly
---
.../config/stalwart/config.toml | 10 +-
.../deployments/opencloud_full/stalwart.yml | 1 +
pkg/jmap/jmap.go | 120 ++++++--
pkg/jmap/jmap_http.go | 57 ++--
pkg/jmap/jmap_model.go | 143 +++++----
pkg/jmap/jmap_reva.go | 32 --
pkg/jmap/jmap_test.go | 6 +-
services/graph/pkg/config/config.go | 16 -
.../pkg/config/defaults/defaultconfig.go | 12 -
.../pkg/service/v0/groupware/groupware.go | 237 ---------------
.../service/v0/groupware/groupware_test.go | 51 ----
.../service/v0/groupware/groupware_tools.go | 176 -----------
services/graph/pkg/service/v0/service.go | 6 -
services/groupware/pkg/config/config.go | 20 +-
.../pkg/config/defaults/defaultconfig.go | 27 +-
services/groupware/pkg/config/parser/parse.go | 7 +-
services/groupware/pkg/config/reva.go | 6 +
services/groupware/pkg/groupware/groupware.go | 162 ++++++++++
.../pkg/groupware/groupware_lowlevel.go | 282 ++++++++++++++++++
.../groupware/pkg/groupware/groupware_reva.go | 36 +++
.../pkg/groupware/groupware_tools.go | 30 ++
services/groupware/pkg/middleware/auth.go | 78 +++++
services/groupware/pkg/server/http/server.go | 11 +-
.../groupware/pkg/service/http/v0/service.go | 93 +-----
.../pkg/config/defaults/defaultconfig.go | 5 +-
25 files changed, 861 insertions(+), 763 deletions(-)
delete mode 100644 pkg/jmap/jmap_reva.go
delete mode 100644 services/graph/pkg/service/v0/groupware/groupware.go
delete mode 100644 services/graph/pkg/service/v0/groupware/groupware_test.go
delete mode 100644 services/graph/pkg/service/v0/groupware/groupware_tools.go
create mode 100644 services/groupware/pkg/config/reva.go
create mode 100644 services/groupware/pkg/groupware/groupware.go
create mode 100644 services/groupware/pkg/groupware/groupware_lowlevel.go
create mode 100644 services/groupware/pkg/groupware/groupware_reva.go
create mode 100644 services/groupware/pkg/groupware/groupware_tools.go
create mode 100644 services/groupware/pkg/middleware/auth.go
diff --git a/devtools/deployments/opencloud_full/config/stalwart/config.toml b/devtools/deployments/opencloud_full/config/stalwart/config.toml
index 0441b5d50..44c452e90 100644
--- a/devtools/deployments/opencloud_full/config/stalwart/config.toml
+++ b/devtools/deployments/opencloud_full/config/stalwart/config.toml
@@ -39,11 +39,11 @@ metrics.prometheus.auth.secret = "secret"
metrics.prometheus.auth.username = "metrics"
metrics.prometheus.enable = true
server.hostname = "stalwart.opencloud.test"
-server.http.allowed-endpoint = 200
-server.http.hsts = false
-server.http.permissive-cors = false
-server.http.url = "'https://stalwart.opencloud.test:' + local_port"
-server.http.use-x-forwarded = false
+http.allowed-endpoint = 200
+http.hsts = true
+http.permissive-cors = false
+http.url = "'https://' + config_get('server.hostname')"
+http.use-x-forwarded = true
server.listener.http.bind = "[::]:8080"
server.listener.http.protocol = "http"
server.listener.https.bind = "[::]:443"
diff --git a/devtools/deployments/opencloud_full/stalwart.yml b/devtools/deployments/opencloud_full/stalwart.yml
index c688c9cf1..62fd9616d 100644
--- a/devtools/deployments/opencloud_full/stalwart.yml
+++ b/devtools/deployments/opencloud_full/stalwart.yml
@@ -14,6 +14,7 @@ services:
- "127.0.0.1:143:143"
- "127.0.0.1:993:993"
volumes:
+ - /etc/localtime:/etc/localtime:ro
- ./config/stalwart:/opt/stalwart/etc
- stalwart-data:/opt/stalwart/data
- stalwart-logs:/opt/stalwart/logs
diff --git a/pkg/jmap/jmap.go b/pkg/jmap/jmap.go
index f3e0d8d88..b74913942 100644
--- a/pkg/jmap/jmap.go
+++ b/pkg/jmap/jmap.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
+ "net/url"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/rs/zerolog"
@@ -46,12 +47,9 @@ func NewClient(wellKnown WellKnownClient, api ApiClient) Client {
// instead of being part of the Session, since the username is always part of the request (typically in
// the authentication token payload.)
type Session struct {
- // The name of the user to use to authenticate against Stalwart
- Username string
- // The identifier of the account to use when performing JMAP operations with Stalwart
- AccountId string
- // The base URL to use for JMAP operations towards Stalwart
- JmapUrl string
+ Username string // The name of the user to use to authenticate against Stalwart
+ AccountId string // The identifier of the account to use when performing JMAP operations with Stalwart
+ JmapUrl url.URL // The base URL to use for JMAP operations towards Stalwart
}
const (
@@ -87,6 +85,18 @@ var (
errWellKnownResponseHasNoApiUrl = fmt.Errorf("well-known response has no API URL")
)
+type WellKnownResponseHasInvalidApiUrlError struct {
+ ApiUrl string
+ Err error
+}
+
+func (e WellKnownResponseHasInvalidApiUrlError) Error() string {
+ return fmt.Sprintf("well-known response contains an invalid API URL '%s': %v", e.ApiUrl, e.Err.Error())
+}
+func (e WellKnownResponseHasInvalidApiUrlError) Unwrap() error {
+ return e.Err
+}
+
// Create a new Session from a WellKnownResponse.
func NewSession(wellKnownResponse WellKnownResponse) (Session, error) {
username := wellKnownResponse.Username
@@ -97,14 +107,18 @@ func NewSession(wellKnownResponse WellKnownResponse) (Session, error) {
if accountId == "" {
return Session{}, errWellKnownResponseHasJmapMailPrimaryAccount
}
- apiUrl := wellKnownResponse.ApiUrl
- if apiUrl == "" {
+ apiStr := wellKnownResponse.ApiUrl
+ if apiStr == "" {
return Session{}, errWellKnownResponseHasNoApiUrl
}
+ apiUrl, err := url.Parse(apiStr)
+ if err != nil {
+ return Session{}, WellKnownResponseHasInvalidApiUrlError{ApiUrl: apiStr, Err: err}
+ }
return Session{
Username: username,
AccountId: accountId,
- JmapUrl: apiUrl,
+ JmapUrl: *apiUrl,
}, nil
}
@@ -172,31 +186,85 @@ func (j *Client) GetAllMailboxes(session *Session, ctx context.Context, logger *
return j.GetMailbox(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.AccountId, Filter: filter}, "0"))
+ if err != nil {
+ return MailboxQueryResponse{}, err
+ }
+ return command(j.api, logger, ctx, session, cmd, func(body *Response) (MailboxQueryResponse, error) {
+ var response MailboxQueryResponse
+ err = retrieveResponseMatchParameters(body, MailboxQuery, "0", &response)
+ return response, err
+ })
+}
+
+type Mailboxes struct {
+ Mailboxes []Mailbox `json:"mailboxes,omitempty"`
+ 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)
+
+ cmd, err := request(
+ invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: session.AccountId, Filter: filter}, "0"),
+ invocation(MailboxGet, MailboxGetRefCommand{
+ AccountId: session.AccountId,
+ IdRef: &Ref{Name: MailboxQuery, Path: "/ids/*", ResultOf: "0"},
+ }, "1"),
+ )
+ if err != nil {
+ return Mailboxes{}, err
+ }
+
+ return command(j.api, logger, ctx, session, cmd, func(body *Response) (Mailboxes, error) {
+ var response MailboxGetResponse
+ err = retrieveResponseMatchParameters(body, MailboxGet, "1", &response)
+ if err != nil {
+ return Mailboxes{}, err
+ }
+ return Mailboxes{Mailboxes: response.List, State: body.SessionState}, nil
+ })
+}
+
type Emails struct {
- Emails []Email
- State string
+ Emails []Email `json:"emails,omitempty"`
+ 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 {
return z.Bool(logFetchBodies, fetchBodies).Int(logOffset, offset).Int(logLimit, limit)
})
+
+ query := EmailQueryCommand{
+ AccountId: session.AccountId,
+ Filter: &MessageFilter{InMailbox: mailboxId},
+ Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}},
+ CollapseThreads: true,
+ CalculateTotal: false,
+ }
+ if offset >= 0 {
+ query.Position = offset
+ }
+ if limit >= 0 {
+ query.Limit = limit
+ }
+
+ get := EmailGetRefCommand{
+ AccountId: session.AccountId,
+ FetchAllBodyValues: fetchBodies,
+ IdRef: &Ref{Name: EmailQuery, Path: "/ids/*", ResultOf: "0"},
+ }
+ if maxBodyValueBytes >= 0 {
+ get.MaxBodyValueBytes = maxBodyValueBytes
+ }
+
cmd, err := request(
- invocation(EmailQuery, EmailQueryCommand{
- AccountId: session.AccountId,
- Filter: &Filter{InMailbox: mailboxId},
- Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}},
- CollapseThreads: true,
- Position: offset,
- Limit: limit,
- CalculateTotal: false,
- }, "0"),
- invocation(EmailGet, EmailGetCommand{
- AccountId: session.AccountId,
- FetchAllBodyValues: fetchBodies,
- MaxBodyValueBytes: maxBodyValueBytes,
- IdRef: &Ref{Name: EmailQuery, Path: "/ids/*", ResultOf: "0"},
- }, "1"),
+ invocation(EmailQuery, query, "0"),
+ invocation(EmailGet, get, "1"),
)
if err != nil {
return Emails{}, err
diff --git a/pkg/jmap/jmap_http.go b/pkg/jmap/jmap_http.go
index f7e52e0d7..d1fbf6c55 100644
--- a/pkg/jmap/jmap_http.go
+++ b/pkg/jmap/jmap_http.go
@@ -7,25 +7,18 @@ import (
"fmt"
"io"
"net/http"
- "path"
+ "net/url"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/version"
)
-type HttpJmapUsernameProvider interface {
- // Provide the username for JMAP operations.
- GetUsername(req *http.Request, ctx context.Context, logger *log.Logger) (string, error)
-}
-
type HttpJmapApiClient struct {
- baseurl string
- jmapurl string
- client *http.Client
- usernameProvider HttpJmapUsernameProvider
- masterUser string
- masterPassword string
- userAgent string
+ baseurl url.URL
+ client *http.Client
+ masterUser string
+ masterPassword string
+ userAgent string
}
var (
@@ -39,15 +32,13 @@ func bearer(req *http.Request, token string) {
}
*/
-func NewHttpJmapApiClient(baseurl string, jmapurl string, client *http.Client, usernameProvider HttpJmapUsernameProvider, masterUser string, masterPassword string) *HttpJmapApiClient {
+func NewHttpJmapApiClient(baseurl url.URL, client *http.Client, masterUser string, masterPassword string) *HttpJmapApiClient {
return &HttpJmapApiClient{
- baseurl: baseurl,
- jmapurl: jmapurl,
- client: client,
- usernameProvider: usernameProvider,
- masterUser: masterUser,
- masterPassword: masterPassword,
- userAgent: "OpenCloud/" + version.GetString(),
+ baseurl: baseurl,
+ client: client,
+ masterUser: masterUser,
+ masterPassword: masterPassword,
+ userAgent: "OpenCloud/" + version.GetString(),
}
}
@@ -67,18 +58,7 @@ func (e AuthenticationError) Unwrap() error {
return e.Err
}
-func (h *HttpJmapApiClient) auth(logger *log.Logger, ctx context.Context, req *http.Request) error {
- username, err := h.usernameProvider.GetUsername(req, ctx, logger)
- if err != nil {
- logger.Error().Err(err).Msg("failed to find username")
- return AuthenticationError{Err: err}
- }
- masterUsername := username + "%" + h.masterUser
- req.SetBasicAuth(masterUsername, h.masterPassword)
- return nil
-}
-
-func (h *HttpJmapApiClient) authWithUsername(_ *log.Logger, username string, req *http.Request) error {
+func (h *HttpJmapApiClient) auth(username string, logger *log.Logger, req *http.Request) error {
masterUsername := username + "%" + h.masterUser
req.SetBasicAuth(masterUsername, h.masterPassword)
return nil
@@ -100,14 +80,14 @@ func (e HttpError) Unwrap() error {
}
func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (WellKnownResponse, error) {
- wellKnownUrl := path.Join(h.baseurl, ".well-known", "jmap")
+ wellKnownUrl := h.baseurl.JoinPath(".well-known", "jmap").String()
req, err := http.NewRequest(http.MethodGet, wellKnownUrl, nil)
if err != nil {
logger.Error().Err(err).Msgf("failed to create GET request for %v", wellKnownUrl)
return WellKnownResponse{}, HttpError{Op: "creating request", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: err}
}
- h.authWithUsername(logger, username, req)
+ h.auth(username, logger, req)
req.Header.Add("Cache-Control", "no-cache, no-store, must-revalidate") // spec recommendation
res, err := h.client.Do(req)
@@ -145,10 +125,7 @@ func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (W
}
func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, error) {
- jmapUrl := h.jmapurl
- if jmapUrl == "" {
- jmapUrl = session.JmapUrl
- }
+ jmapUrl := session.JmapUrl.String()
bodyBytes, marshalErr := json.Marshal(request)
if marshalErr != nil {
@@ -163,7 +140,7 @@ func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("User-Agent", h.userAgent)
- h.auth(logger, ctx, req)
+ h.auth(session.Username, logger, req)
res, err := h.client.Do(req)
if err != nil {
diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go
index d0c974b3c..58c9df51b 100644
--- a/pkg/jmap/jmap_model.go
+++ b/pkg/jmap/jmap_model.go
@@ -43,17 +43,17 @@ type WellKnownResponse struct {
}
type Mailbox struct {
- Id string
- Name string
- ParentId string
- Role string
- SortOrder int
- IsSubscribed bool
- TotalEmails int
- UnreadEmails int
- TotalThreads int
- UnreadThreads int
- MyRights map[string]bool
+ Id string `json:"id,omitempty"`
+ 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"`
+ MyRights map[string]bool `json:"myRights,omitempty"`
}
type MailboxGetCommand struct {
@@ -61,7 +61,40 @@ type MailboxGetCommand struct {
Ids []string `json:"ids,omitempty"`
}
-type Filter struct {
+type MailboxGetRefCommand struct {
+ AccountId string `json:"accountId"`
+ IdRef *Ref `json:"#ids,omitempty"`
+}
+
+type MailboxFilterCondition struct {
+ ParentId string `json:"parentId,omitempty"`
+ Name string `json:"name,omitempty"`
+ Role string `json:"role,omitempty"`
+ HasAnyRole *bool `json:"hasAnyRole,omitempty"`
+ IsSubscribed *bool `json:"isSubscribed,omitempty"`
+}
+
+type MailboxFilterOperator struct {
+ Operator string `json:"operator"`
+ Conditions []MailboxFilterCondition `json:"conditions"`
+}
+
+type MailboxComparator struct {
+ Property string `json:"property"`
+ IsAscending bool `json:"isAscending,omitempty"`
+ Limit int `json:"limit,omitzero"`
+ CalculateTotal bool `json:"calculateTotal,omitempty"`
+}
+
+type SimpleMailboxQueryCommand struct {
+ AccountId string `json:"accountId"`
+ Filter MailboxFilterCondition `json:"filter,omitempty"`
+ Sort []MailboxComparator `json:"sort,omitempty"`
+ SortAsTree bool `json:"sortAsTree,omitempty"`
+ FilterAsTree bool `json:"filterAsTree,omitempty"`
+}
+
+type MessageFilter struct {
InMailbox string `json:"inMailbox,omitempty"`
InMailboxOtherThan []string `json:"inMailboxOtherThan,omitempty"`
Before time.Time `json:"before,omitzero"` // omitzero requires Go 1.24
@@ -85,13 +118,13 @@ type Sort struct {
}
type EmailQueryCommand struct {
- AccountId string `json:"accountId"`
- Filter *Filter `json:"filter,omitempty"`
- Sort []Sort `json:"sort,omitempty"`
- CollapseThreads bool `json:"collapseThreads,omitempty"`
- Position int `json:"position,omitempty"`
- Limit int `json:"limit,omitempty"`
- CalculateTotal bool `json:"calculateTotal,omitempty"`
+ AccountId string `json:"accountId"`
+ Filter *MessageFilter `json:"filter,omitempty"`
+ Sort []Sort `json:"sort,omitempty"`
+ CollapseThreads bool `json:"collapseThreads,omitempty"`
+ Position int `json:"position,omitempty"`
+ Limit int `json:"limit,omitempty"`
+ CalculateTotal bool `json:"calculateTotal,omitempty"`
}
type Ref struct {
@@ -100,7 +133,7 @@ type Ref struct {
ResultOf string `json:"resultOf,omitempty"`
}
-type EmailGetCommand struct {
+type EmailGetRefCommand struct {
AccountId string `json:"accountId"`
FetchAllBodyValues bool `json:"fetchAllBodyValues,omitempty"`
MaxBodyValueBytes int `json:"maxBodyValueBytes,omitempty"`
@@ -108,49 +141,49 @@ type EmailGetCommand struct {
}
type EmailAddress struct {
- Name string
- Email string
+ Name string `json:"name,omitempty"`
+ Email string `json:"email,omitempty"`
}
type EmailBodyRef struct {
- PartId string
- BlobId string
- Size int
- Name string
- Type string
- Charset string
- Disposition string
- Cid string
- Language string
- Location string
+ PartId string `json:"partId,omitempty"`
+ BlobId string `json:"blobId,omitempty"`
+ Size int `json:"size,omitempty"`
+ Name string `json:"name,omitempty"`
+ Type string `json:"type,omitempty"`
+ Charset string `json:"charset,omitempty"`
+ Disposition string `json:"disposition,omitempty"`
+ Cid string `json:"cid,omitempty"`
+ Language string `json:"language,omitempty"`
+ Location string `json:"location,omitempty"`
}
type EmailBody struct {
- IsEncodingProblem bool
- IsTruncated bool
- Value string
+ IsEncodingProblem bool `json:"isEncodingProblem,omitempty"`
+ IsTruncated bool `json:"isTruncated,omitempty"`
+ Value string `json:"value,omitempty"`
}
type Email struct {
- Id string
- MessageId []string
- BlobId string
- ThreadId string
- Size int
- From []EmailAddress
- To []EmailAddress
- Cc []EmailAddress
- Bcc []EmailAddress
- ReplyTo []EmailAddress
- Subject string
- HasAttachments bool
- ReceivedAt time.Time
- SentAt time.Time
- Preview string
- BodyValues map[string]EmailBody
- TextBody []EmailBodyRef
- HtmlBody []EmailBodyRef
- Keywords map[string]bool
- MailboxIds map[string]bool
+ Id string `json:"id,omitempty"`
+ MessageId []string `json:"messageId,omitempty"`
+ BlobId string `json:"blobId,omitempty"`
+ ThreadId string `json:"threadId,omitempty"`
+ Size int `json:"size,omitempty"`
+ From []EmailAddress `json:"from,omitempty"`
+ To []EmailAddress `json:"to,omitempty"`
+ Cc []EmailAddress `json:"cc,omitempty"`
+ Bcc []EmailAddress `json:"bcc,omitempty"`
+ ReplyTo []EmailAddress `json:"replyTo,omitempty"`
+ Subject string `json:"subject,omitempty"`
+ HasAttachments bool `json:"hasAttachments,omitempty"`
+ ReceivedAt time.Time `json:"receivedAt,omitempty"`
+ SentAt time.Time `json:"sentAt,omitempty"`
+ Preview string `json:"preview,omitempty"`
+ BodyValues map[string]EmailBody `json:"bodyValues,omitempty"`
+ TextBody []EmailBodyRef `json:"textBody,omitempty"`
+ HtmlBody []EmailBodyRef `json:"htmlBody,omitempty"`
+ Keywords map[string]bool `json:"keywords,omitempty"`
+ MailboxIds map[string]bool `json:"mailboxIds,omitempty"`
}
type Command string
diff --git a/pkg/jmap/jmap_reva.go b/pkg/jmap/jmap_reva.go
deleted file mode 100644
index a2254c193..000000000
--- a/pkg/jmap/jmap_reva.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package jmap
-
-import (
- "context"
- "fmt"
- "net/http"
-
- "github.com/opencloud-eu/opencloud/pkg/log"
- revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
-)
-
-// HttpJmapUsernameProvider implementation that uses Reva's enrichment of the Context
-// to retrieve the current username.
-type revaContextHttpJmapUsernameProvider struct {
-}
-
-var _ HttpJmapUsernameProvider = revaContextHttpJmapUsernameProvider{}
-
-func NewRevaContextHttpJmapUsernameProvider() HttpJmapUsernameProvider {
- return revaContextHttpJmapUsernameProvider{}
-}
-
-var errUserNotInContext = fmt.Errorf("user not in context")
-
-func (r revaContextHttpJmapUsernameProvider) GetUsername(req *http.Request, ctx context.Context, logger *log.Logger) (string, error) {
- u, ok := revactx.ContextGetUser(ctx)
- if !ok {
- logger.Error().Msg("could not get user: user not in reva context")
- return "", errUserNotInContext
- }
- return u.GetUsername(), nil
-}
diff --git a/pkg/jmap/jmap_test.go b/pkg/jmap/jmap_test.go
index d9e2cb438..dd044dd85 100644
--- a/pkg/jmap/jmap_test.go
+++ b/pkg/jmap/jmap_test.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"math/rand"
+ "net/url"
"os"
"path/filepath"
"testing"
@@ -95,7 +96,10 @@ func TestRequests(t *testing.T) {
ctx := context.Background()
client := NewClient(wkClient, apiClient)
- session := Session{AccountId: "123", JmapUrl: "test://"}
+ jmapUrl, err := url.Parse("http://localhost/jmap")
+ require.NoError(err)
+
+ session := Session{AccountId: "123", Username: "user123", JmapUrl: *jmapUrl}
folders, err := client.GetAllMailboxes(&session, ctx, &logger)
require.NoError(err)
diff --git a/services/graph/pkg/config/config.go b/services/graph/pkg/config/config.go
index ca48036e8..2134479c1 100644
--- a/services/graph/pkg/config/config.go
+++ b/services/graph/pkg/config/config.go
@@ -36,8 +36,6 @@ type Config struct {
Keycloak Keycloak `yaml:"keycloak"`
ServiceAccount ServiceAccount `yaml:"service_account"`
- Mail Mail `yaml:"mail"`
-
Context context.Context `yaml:"-"`
Metadata Metadata `yaml:"metadata_config"`
@@ -180,17 +178,3 @@ type Store struct {
AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;GRAPH_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
}
-type MasterAuth struct {
- Username string `yaml:"username" env:"OC_JMAP_MASTER_USERNAME;GROUPWARE_JMAP_MASTER_USERNAME"`
- Password string `yaml:"password" env:"OC_JMAP_MASTER_PASSWORD;GROUPWARE_JMAP_MASTER_PASSWORD"`
-}
-
-type Mail struct {
- Master MasterAuth `yaml:"master"`
- BaseUrl string `yaml:"base_url" env:"GROUPWARE_BASE_URL"`
- JmapUrl string `yaml:"jmap_url" env:"GROUPWARE_JMAP_URL"`
- Timeout time.Duration `yaml:"timeout" env:"GROUPWARE_JMAP_TIMEOUT"`
- SessionCacheTTL time.Duration `yaml:"session_cache_ttl" env:"GROUPWARE_SESSION_CACHE_TTL"`
- DefaultEmailLimit int `yaml:"default_email_limit" env:"GROUPWARE_JMAP_DEFAULT_EMAIL_LIMIT"`
- MaxBodyValueBytes int `yaml:"max_body_value_bytes" env:"GROUPWARE_JMAP_MAx_BODY_VALUE_BYTES"`
-}
diff --git a/services/graph/pkg/config/defaults/defaultconfig.go b/services/graph/pkg/config/defaults/defaultconfig.go
index 55d066c27..9b5352f6e 100644
--- a/services/graph/pkg/config/defaults/defaultconfig.go
+++ b/services/graph/pkg/config/defaults/defaultconfig.go
@@ -135,18 +135,6 @@ func DefaultConfig() *config.Config {
Nodes: []string{"127.0.0.1:9233"},
Database: "graph",
},
- Mail: config.Mail{
- Master: config.MasterAuth{
- Username: "master",
- Password: "admin",
- },
- BaseUrl: "https://stalwart.opencloud.test",
- JmapUrl: "https://stalwart.opencloud.test/jmap",
- Timeout: time.Duration(3 * time.Second),
- SessionCacheTTL: time.Duration(1 * time.Hour),
- DefaultEmailLimit: 100,
- MaxBodyValueBytes: 0,
- },
}
}
diff --git a/services/graph/pkg/service/v0/groupware/groupware.go b/services/graph/pkg/service/v0/groupware/groupware.go
deleted file mode 100644
index 2e760e37c..000000000
--- a/services/graph/pkg/service/v0/groupware/groupware.go
+++ /dev/null
@@ -1,237 +0,0 @@
-package groupware
-
-import (
- "context"
- "crypto/tls"
- "fmt"
- "io"
- "net/http"
- "sync/atomic"
- "time"
-
- "github.com/opencloud-eu/opencloud/pkg/jmap"
- "github.com/opencloud-eu/opencloud/pkg/log"
- "github.com/opencloud-eu/opencloud/services/graph/pkg/config"
- "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
-
- "github.com/go-chi/render"
-
- "github.com/jellydator/ttlcache/v3"
-)
-
-const (
- logFolderId = "folder-id"
- logQuery = "query"
-)
-
-type Groupware struct {
- logger *log.Logger
- jmapClient jmap.Client
- sessionCache *ttlcache.Cache[string, jmap.Session]
- usernameProvider jmap.HttpJmapUsernameProvider // we also need it for ourselves for now
- defaultEmailLimit int
- maxBodyValueBytes int
- io.Closer
-}
-
-var _ io.Closer = Groupware{}
-
-type ItemBody struct {
- Content string `json:"content"`
- ContentType string `json:"contentType"` // text|html
-}
-
-type EmailAddress struct {
- Address string `json:"address"`
- Name string `json:"name"`
-}
-
-type Messages struct {
- Context string `json:"@odata.context,omitempty"`
- Value []Message `json:"value"`
-}
-
-type Message struct {
- Etag string `json:"@odata.etag,omitempty"`
- Id string `json:"id,omitempty"`
- CreatedDateTime time.Time `json:"createdDateTime,omitzero"`
- ReceivedDateTime time.Time `json:"receivedDateTime,omitzero"`
- SentDateTime time.Time `json:"sentDateTime,omitzero"`
- HasAttachments bool `json:"hasAttachments,omitempty"`
- InternetMessageId string `json:"internetMessageId,omitempty"`
- Subject string `json:"subject,omitempty"`
- BodyPreview string `json:"bodyPreview,omitempty"`
- Body *ItemBody `json:"body,omitempty"`
- From *EmailAddress `json:"from,omitempty"`
- ToRecipients []EmailAddress `json:"toRecipients,omitempty"`
- CcRecipients []EmailAddress `json:"ccRecipients,omitempty"`
- BccRecipients []EmailAddress `json:"bccRecipients,omitempty"`
- ReplyTo []EmailAddress `json:"replyTo,omitempty"`
- IsRead bool `json:"isRead,omitempty"`
- IsDraft bool `json:"isDraft,omitempty"`
- Importance string `json:"importance,omitempty"`
- ParentFolderId string `json:"parentFolderId,omitempty"`
- Categories []string `json:"categories,omitempty"`
- ConversationId string `json:"conversationId,omitempty"`
- WebLink string `json:"webLink,omitempty"`
- // ConversationIndex string `json:"conversationIndex"`
-}
-
-func NewGroupware(logger *log.Logger, config *config.Config) *Groupware {
- baseUrl := config.Mail.BaseUrl
- jmapUrl := config.Mail.JmapUrl
- masterUsername := config.Mail.Master.Username
- masterPassword := config.Mail.Master.Password
- defaultEmailLimit := config.Mail.DefaultEmailLimit
- maxBodyValueBytes := config.Mail.MaxBodyValueBytes
-
- tr := http.DefaultTransport.(*http.Transport).Clone()
- tr.ResponseHeaderTimeout = time.Duration(config.Mail.Timeout)
- tlsConfig := &tls.Config{InsecureSkipVerify: true}
- tr.TLSClientConfig = tlsConfig
- c := *http.DefaultClient
- c.Transport = tr
-
- jmapUsernameProvider := jmap.NewRevaContextHttpJmapUsernameProvider()
-
- api := jmap.NewHttpJmapApiClient(
- baseUrl,
- jmapUrl,
- &c,
- jmapUsernameProvider,
- masterUsername,
- masterPassword,
- )
-
- jmapClient := jmap.NewClient(api, api)
-
- sessionCache := ttlcache.New(
- ttlcache.WithTTL[string, jmap.Session](
- config.Mail.SessionCacheTTL,
- ),
- ttlcache.WithDisableTouchOnHit[string, jmap.Session](),
- )
- go sessionCache.Start()
-
- return &Groupware{
- logger: logger,
- jmapClient: jmapClient,
- sessionCache: sessionCache,
- usernameProvider: jmapUsernameProvider,
- defaultEmailLimit: defaultEmailLimit,
- maxBodyValueBytes: maxBodyValueBytes,
- }
-}
-
-func (g Groupware) Close() error {
- g.sessionCache.Stop()
- return nil
-}
-
-func (g Groupware) session(req *http.Request, ctx context.Context, logger *log.Logger) (jmap.Session, bool, error) {
- username, err := g.usernameProvider.GetUsername(req, ctx, logger)
- if err != nil {
- logger.Error().Err(err).Msg("failed to retrieve username")
- return jmap.Session{}, false, err
- }
-
- fetchErrRef := atomic.Value{}
- item, _ := g.sessionCache.GetOrSetFunc(username, func() jmap.Session {
- jmapContext, err := g.jmapClient.FetchSession(username, logger)
- if err != nil {
- fetchErrRef.Store(err)
- logger.Error().Err(err).Str("username", username).Msg("failed to retrieve well-known")
- return jmap.Session{}
- }
- return jmapContext
- })
- p := fetchErrRef.Load()
- if p != nil {
- err = p.(error)
- return jmap.Session{}, false, err
- }
- if item != nil {
- return item.Value(), true, nil
- } else {
- return jmap.Session{}, false, nil
- }
-}
-
-func (g Groupware) withSession(w http.ResponseWriter, r *http.Request, handler func(r *http.Request, ctx context.Context, logger log.Logger, session *jmap.Session) (any, error)) {
- ctx := r.Context()
- logger := g.logger.SubloggerWithRequestID(ctx)
- session, ok, err := g.session(r, ctx, &logger)
- if err != nil {
- logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg("failed to determine JMAP session")
- errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
- return
- }
- if !ok {
- // no session = authentication failed
- logger.Warn().Err(err).Interface(logQuery, r.URL.Query()).Msg("could not authenticate")
- errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "failed to authenticate")
- return
- }
- logger = session.DecorateLogger(logger)
-
- response, err := handler(r, ctx, logger, &session)
- if err != nil {
- logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg(err.Error())
- errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
- return
- }
-
- render.Status(r, http.StatusOK)
- render.JSON(w, r, response)
-}
-
-func (g Groupware) GetIdentity(w http.ResponseWriter, r *http.Request) {
- g.withSession(w, r, func(r *http.Request, ctx context.Context, logger log.Logger, session *jmap.Session) (any, error) {
- return g.jmapClient.GetIdentity(session, ctx, &logger)
- })
-}
-
-func (g Groupware) GetVacation(w http.ResponseWriter, r *http.Request) {
- g.withSession(w, r, func(r *http.Request, ctx context.Context, logger log.Logger, session *jmap.Session) (any, error) {
- return g.jmapClient.GetVacationResponse(session, ctx, &logger)
- })
-}
-
-func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
- g.withSession(w, r, func(r *http.Request, ctx context.Context, logger log.Logger, session *jmap.Session) (any, error) {
- offset, ok, _ := parseNumericParam(r, "$skip", 0)
- if ok {
- logger = log.Logger{Logger: logger.With().Int("$skip", offset).Logger()}
- }
- limit, ok, _ := parseNumericParam(r, "$top", g.defaultEmailLimit)
- if ok {
- logger = log.Logger{Logger: logger.With().Int("$top", limit).Logger()}
- }
-
- mailboxGetResponse, err := g.jmapClient.GetAllMailboxes(session, ctx, &logger)
- if err != nil {
- return nil, err
- }
-
- inboxId := pickInbox(mailboxGetResponse.List)
- if inboxId == "" {
- return nil, fmt.Errorf("failed to find an inbox folder")
- }
- logger = log.Logger{Logger: logger.With().Str(logFolderId, inboxId).Logger()}
-
- emails, err := g.jmapClient.GetEmails(session, ctx, &logger, inboxId, offset, limit, true, g.maxBodyValueBytes)
- if err != nil {
- return nil, err
- }
-
- messages := make([]Message, 0, len(emails.Emails))
- for _, email := range emails.Emails {
- message := message(email, emails.State)
- messages = append(messages, message)
- }
-
- odataContext := *r.URL
- odataContext.Path = fmt.Sprintf("/graph/v1.0/$metadata#users('%s')/mailFolders('%s')/messages()", session.Username, inboxId)
- return Messages{Context: odataContext.String(), Value: messages}, nil
- })
-}
diff --git a/services/graph/pkg/service/v0/groupware/groupware_test.go b/services/graph/pkg/service/v0/groupware/groupware_test.go
deleted file mode 100644
index e6ddcc0b5..000000000
--- a/services/graph/pkg/service/v0/groupware/groupware_test.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package groupware
-
-import (
- "time"
-
- g "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-
- "github.com/opencloud-eu/opencloud/pkg/jmap"
-)
-
-var _ = g.Describe("Groupware", func() {
-
- g.Describe("message", func() {
- g.It("copies a JMAP Email to a Graph Message correctly", func() {
- now := time.Now()
- email := jmap.Email{
- Id: "id",
- MessageId: []string{"123.456@example.com"},
- BlobId: "918e929e-a296-4078-915a-2c2abc580a8d",
- ThreadId: "t",
- Size: 12345,
- From: []jmap.EmailAddress{
- {Name: "Bobbie Draper", Email: "bobbie@mcrn.mars"},
- },
- To: []jmap.EmailAddress{
- {Name: "Camina Drummer", Email: "camina@opa.org"},
- },
- Subject: "test subject",
- HasAttachments: true,
- ReceivedAt: now,
- Preview: "the preview",
- TextBody: []jmap.EmailBodyRef{
- {PartId: "0", Type: "text/plain"},
- },
- BodyValues: map[string]jmap.EmailBody{
- "0": {
- IsEncodingProblem: false,
- IsTruncated: false,
- Value: "the body",
- },
- },
- }
-
- msg := message(email, "aaa")
- Expect(msg.Body.ContentType).To(Equal("text/plain"))
- Expect(msg.Body.Content).To(Equal("the body"))
- Expect(msg.Subject).To(Equal("test subject"))
- })
- })
-})
diff --git a/services/graph/pkg/service/v0/groupware/groupware_tools.go b/services/graph/pkg/service/v0/groupware/groupware_tools.go
deleted file mode 100644
index 4fa069c7a..000000000
--- a/services/graph/pkg/service/v0/groupware/groupware_tools.go
+++ /dev/null
@@ -1,176 +0,0 @@
-package groupware
-
-import (
- "net/http"
- "net/url"
- "strconv"
- "strings"
-
- "github.com/opencloud-eu/opencloud/pkg/jmap"
-)
-
-func pickInbox(folders []jmap.Mailbox) string {
- for _, folder := range folders {
- if folder.Role == "inbox" {
- return folder.Id
- }
- }
- return ""
-}
-
-func mapContentType(jmap string) string {
- switch jmap {
- case "text/html":
- return "html"
- case "text/plain":
- return "text"
- default:
- return jmap
- }
-}
-
-func foldBody(email jmap.Email) *ItemBody {
- if email.BodyValues != nil {
- if len(email.HtmlBody) > 0 {
- pick := email.HtmlBody[0]
- content, ok := email.BodyValues[pick.PartId]
- if ok {
- return &ItemBody{Content: content.Value, ContentType: mapContentType(pick.Type)}
- }
- }
- if len(email.TextBody) > 0 {
- pick := email.TextBody[0]
- content, ok := email.BodyValues[pick.PartId]
- if ok {
- return &ItemBody{Content: content.Value, ContentType: mapContentType(pick.Type)}
- }
- }
- }
- return nil
-}
-
-func firstOf[T any](ary []T) T {
- if len(ary) > 0 {
- return ary[0]
- }
- var nothing T
- return nothing
-}
-
-func emailAddress(j jmap.EmailAddress) EmailAddress {
- return EmailAddress{Address: j.Email, Name: j.Name}
-}
-
-func emailAddresses(j []jmap.EmailAddress) []EmailAddress {
- result := make([]EmailAddress, len(j))
- for i := 0; i < len(j); i++ {
- result[i] = emailAddress(j[i])
- }
- return result
-}
-
-func hasKeyword(j jmap.Email, kw string) bool {
- value, ok := j.Keywords[kw]
- if ok {
- return value
- }
- return false
-}
-
-func categories(j jmap.Email) []string {
- categories := []string{}
- for k, v := range j.Keywords {
- if v && !strings.HasPrefix(k, jmap.JmapKeywordPrefix) {
- categories = append(categories, k)
- }
- }
- return categories
-}
-
-/*
-func toEdmBinary(value int) string {
- return fmt.Sprintf("%X", value)
-}
-*/
-
-// https://learn.microsoft.com/en-us/graph/api/resources/message?view=graph-rest-1.0
-func message(email jmap.Email, state string) Message {
- body := foldBody(email)
- importance := "" // omit "normal" as it is expected to be the default
- if hasKeyword(email, jmap.JmapKeywordFlagged) {
- importance = "high"
- }
-
- mailboxId := ""
- for k, v := range email.MailboxIds {
- if v {
- // TODO how to map JMAP short identifiers (e.g. 'a') to something uniquely addressable for the clients?
- // e.g. do we need to include tenant/sharding/cluster information?
- mailboxId = k
- break
- }
- }
-
- // TODO how to map JMAP short identifiers (e.g. 'a') to something uniquely addressable for the clients?
- // e.g. do we need to include tenant/sharding/cluster information?
- id := email.Id
- // for this one too:
- messageId := firstOf(email.MessageId)
- // as well as this one:
- threadId := email.ThreadId
-
- categories := categories(email)
-
- var from *EmailAddress = nil
- if len(email.From) > 0 {
- e := emailAddress(email.From[0])
- from = &e
- }
-
- // TODO how to map JMAP state to an OData Etag?
- etag := state
-
- weblink, err := url.JoinPath("/groupware/mail", id)
- if err != nil {
- weblink = ""
- }
-
- return Message{
- Etag: etag,
- Id: id,
- Subject: email.Subject,
- CreatedDateTime: email.ReceivedAt,
- ReceivedDateTime: email.ReceivedAt,
- SentDateTime: email.SentAt,
- HasAttachments: email.HasAttachments,
- InternetMessageId: messageId,
- BodyPreview: email.Preview,
- Body: body,
- From: from,
- ToRecipients: emailAddresses(email.To),
- CcRecipients: emailAddresses(email.Cc),
- BccRecipients: emailAddresses(email.Bcc),
- ReplyTo: emailAddresses(email.ReplyTo),
- IsRead: hasKeyword(email, jmap.JmapKeywordSeen),
- IsDraft: hasKeyword(email, jmap.JmapKeywordDraft),
- Importance: importance,
- ParentFolderId: mailboxId,
- Categories: categories,
- ConversationId: threadId,
- WebLink: weblink,
- // ConversationIndex: toEdmBinary(email.ThreadIndex),
- } // TODO more email fields
-}
-
-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
-}
diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go
index 6390b1bf6..f130de6dc 100644
--- a/services/graph/pkg/service/v0/service.go
+++ b/services/graph/pkg/service/v0/service.go
@@ -34,7 +34,6 @@ import (
settingssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/settings/v0"
"github.com/opencloud-eu/opencloud/services/graph/pkg/identity"
graphm "github.com/opencloud-eu/opencloud/services/graph/pkg/middleware"
- "github.com/opencloud-eu/opencloud/services/graph/pkg/service/v0/groupware"
"github.com/opencloud-eu/opencloud/services/graph/pkg/unifiedrole"
)
@@ -203,8 +202,6 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
natskv: options.NatsKeyValue,
}
- gw := groupware.NewGroupware(&options.Logger, options.Config)
-
if err := setIdentityBackends(options, &svc); err != nil {
return svc, err
}
@@ -321,9 +318,6 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
r.Patch("/", usersUserProfilePhotoApi.UpsertProfilePhoto(GetUserIDFromCTX))
r.Delete("/", usersUserProfilePhotoApi.DeleteProfilePhoto(GetUserIDFromCTX))
})
- r.Get("/messages", gw.GetMessages)
- r.Get("/identity", gw.GetIdentity)
- r.Get("/vacation", gw.GetVacation)
})
r.Route("/users", func(r chi.Router) {
r.Get("/", svc.GetUsers)
diff --git a/services/groupware/pkg/config/config.go b/services/groupware/pkg/config/config.go
index bfcec4a64..149bbd96c 100644
--- a/services/groupware/pkg/config/config.go
+++ b/services/groupware/pkg/config/config.go
@@ -21,17 +21,23 @@ type Config struct {
Mail Mail `yaml:"mail"`
+ TokenManager *TokenManager `yaml:"token_manager"`
+
Context context.Context `yaml:"-"`
}
-type MasterAuth struct {
- Username string `yaml:"username" env:"OC_JMAP_MASTER_USERNAME;GROUPWARE_JMAP_MASTER_USERNAME"`
- Password string `yaml:"password" env:"OC_JMAP_MASTER_PASSWORD;GROUPWARE_JMAP_MASTER_PASSWORD"`
+type MailMasterAuth struct {
+ Username string `yaml:"username" env:"GROUPWARE_JMAP_MASTER_USERNAME"`
+ Password string `yaml:"password" env:"GROUPWARE_JMAP_MASTER_PASSWORD"`
}
type Mail struct {
- Master MasterAuth `yaml:"master"`
- BaseUrl string `yaml:"base_url" env:"OC_JMAP_BASE_URL;GROUPWARE_BASE_URL"`
- JmapUrl string `yaml:"jmap_url" env:"OC_JMAP_JMAP_URL;GROUPWARE_JMAP_URL"`
- Timeout time.Duration `yaml:"timeout" env:"OC_JMAP_TIMEOUT"`
+ Master MailMasterAuth `yaml:"master"`
+ BaseUrl string `yaml:"base_url" env:"GROUPWARE_JMAP_BASE_URL"`
+ Timeout time.Duration `yaml:"timeout" env:"GROUPWARE_JMAP_TIMEOUT"`
+ DefaultEmailLimit int `yaml:"default_email_limit" env:"GROUPWARE_DEFAULT_EMAIL_LIMIT"`
+ MaxBodyValueBytes int `yaml:"max_body_value_bytes" env:"GROUPWARE_MAX_BODY_VALUE_BYTES"`
+ ResponseHeaderTimeout time.Duration `yaml:"response_header_timeout" env:"GROUPWARE_RESPONSE_HEADER_TIMEOUT"`
+ SessionCacheTtl time.Duration `yaml:"session_cache_ttl" env:"GROUPWARE_SESSION_CACHE_TTL"`
+ SessionFailureCacheTtl time.Duration `yaml:"session_failure_cache_ttl" env:"GROUPWARE_SESSION_FAILURE_CACHE_TTL"`
}
diff --git a/services/groupware/pkg/config/defaults/defaultconfig.go b/services/groupware/pkg/config/defaults/defaultconfig.go
index d227ba36b..1d63e4d22 100644
--- a/services/groupware/pkg/config/defaults/defaultconfig.go
+++ b/services/groupware/pkg/config/defaults/defaultconfig.go
@@ -2,6 +2,7 @@ package defaults
import (
"strings"
+ "time"
"github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
)
@@ -18,18 +19,23 @@ func FullDefaultConfig() *config.Config {
func DefaultConfig() *config.Config {
return &config.Config{
Debug: config.Debug{
- Addr: "127.0.0.1:9202",
+ Addr: "127.0.0.1:9292",
Token: "",
Pprof: false,
Zpages: false,
},
Mail: config.Mail{
- Master: config.MasterAuth{
- Username: "masteradmin",
+ Master: config.MailMasterAuth{
+ Username: "master",
Password: "admin",
},
- BaseUrl: "https://stalwart.opencloud.test",
- JmapUrl: "https://stalwart.opencloud.test/jmap",
+ BaseUrl: "https://stalwart.opencloud.test",
+ Timeout: 30 * time.Second,
+ DefaultEmailLimit: -1,
+ MaxBodyValueBytes: -1,
+ ResponseHeaderTimeout: 10 * time.Second,
+ SessionCacheTtl: 30 * time.Second,
+ SessionFailureCacheTtl: 15 * time.Second,
},
HTTP: config.HTTP{
Addr: "127.0.0.1:9276",
@@ -72,13 +78,16 @@ func EnsureDefaults(cfg *config.Config) {
} else if cfg.Tracing == nil {
cfg.Tracing = &config.Tracing{}
}
-
+ if cfg.TokenManager == nil && cfg.Commons != nil && cfg.Commons.TokenManager != nil {
+ cfg.TokenManager = &config.TokenManager{
+ JWTSecret: cfg.Commons.TokenManager.JWTSecret,
+ }
+ } else if cfg.TokenManager == nil {
+ cfg.TokenManager = &config.TokenManager{}
+ }
if cfg.Commons != nil {
cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS
}
-
- // TODO p.bleser add Mail here
-
}
// Sanitize sanitized the configuration
diff --git a/services/groupware/pkg/config/parser/parse.go b/services/groupware/pkg/config/parser/parse.go
index e02236f56..61f1e0dd4 100644
--- a/services/groupware/pkg/config/parser/parse.go
+++ b/services/groupware/pkg/config/parser/parse.go
@@ -4,6 +4,7 @@ import (
"errors"
occfg "github.com/opencloud-eu/opencloud/pkg/config"
+ "github.com/opencloud-eu/opencloud/pkg/shared"
"github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
"github.com/opencloud-eu/opencloud/services/groupware/pkg/config/defaults"
@@ -34,6 +35,10 @@ func ParseConfig(cfg *config.Config) error {
}
// Validate can validate the configuration
-func Validate(_ *config.Config) error {
+func Validate(cfg *config.Config) error {
+ if cfg.TokenManager.JWTSecret == "" {
+ return shared.MissingJWTTokenError(cfg.Service.Name)
+ }
+
return nil
}
diff --git a/services/groupware/pkg/config/reva.go b/services/groupware/pkg/config/reva.go
new file mode 100644
index 000000000..a357bdf54
--- /dev/null
+++ b/services/groupware/pkg/config/reva.go
@@ -0,0 +1,6 @@
+package config
+
+// TokenManager is the config for using the reva token manager
+type TokenManager struct {
+ JWTSecret string `yaml:"jwt_secret" env:"OC_JWT_SECRET;GROUPWARE_JWT_SECRET" desc:"The secret to mint and validate jwt tokens."`
+}
diff --git a/services/groupware/pkg/groupware/groupware.go b/services/groupware/pkg/groupware/groupware.go
new file mode 100644
index 000000000..cdf5b5348
--- /dev/null
+++ b/services/groupware/pkg/groupware/groupware.go
@@ -0,0 +1,162 @@
+package groupware
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/render"
+
+ "github.com/opencloud-eu/opencloud/pkg/jmap"
+ "github.com/opencloud-eu/opencloud/pkg/log"
+)
+
+func (g Groupware) Route(r chi.Router) {
+ r.Get("/", g.Index)
+ r.Get("/ping", g.Ping)
+ r.Get("/mailboxes", g.GetMailboxes) // ?name=&role=&subcribed=
+ r.Get("/mailbox/{id}", g.GetMailboxById)
+ r.Get("/{mailbox}/messages", g.GetMessages)
+ r.Get("/identity", g.GetIdentity)
+ r.Get("/vacation", g.GetVacation)
+}
+
+type IndexResponse struct {
+ AccountId string
+}
+
+func (IndexResponse) Render(w http.ResponseWriter, r *http.Request) error {
+ return nil
+}
+
+func (g Groupware) Ping(w http.ResponseWriter, r *http.Request) {
+ g.logger.Info().Msg("groupware pinged")
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (g Groupware) Index(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ logger := g.logger.SubloggerWithRequestID(ctx)
+
+ session, ok, err := g.session(r, ctx, &logger)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ if ok {
+ //logger = session.DecorateLogger(logger)
+ _ = render.Render(w, r, IndexResponse{AccountId: session.AccountId})
+ } else {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+}
+
+func (g Groupware) GetIdentity(w http.ResponseWriter, r *http.Request) {
+ g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) {
+ res, err := g.jmap.GetIdentity(session, ctx, logger)
+ return res, res.State, err
+ })
+}
+
+func (g Groupware) GetVacation(w http.ResponseWriter, r *http.Request) {
+ g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) {
+ res, err := g.jmap.GetVacationResponse(session, ctx, logger)
+ return res, res.State, err
+ })
+}
+
+func (g Groupware) GetMailboxById(w http.ResponseWriter, r *http.Request) {
+ mailboxId := chi.URLParam(r, "mailbox")
+ if mailboxId == "" {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+
+ g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) {
+ res, err := g.jmap.GetMailbox(session, ctx, logger, []string{mailboxId})
+ if err != nil {
+ return res, "", err
+ }
+
+ if len(res.List) == 1 {
+ return res.List[0], res.State, err
+ } else {
+ return nil, res.State, err
+ }
+ })
+}
+
+func (g Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ var filter jmap.MailboxFilterCondition
+
+ hasCriteria := false
+ name := q.Get("name")
+ if name != "" {
+ filter.Name = name
+ hasCriteria = true
+ }
+ role := q.Get("role")
+ if role != "" {
+ filter.Role = role
+ hasCriteria = true
+ }
+ subscribed := q.Get("subscribed")
+ if subscribed != "" {
+ b, err := strconv.ParseBool(subscribed)
+ if err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ filter.IsSubscribed = &b
+ hasCriteria = true
+ }
+
+ if hasCriteria {
+ g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) {
+ mailboxes, err := g.jmap.SearchMailboxes(session, ctx, logger, filter)
+ if err != nil {
+ return nil, "", err
+ }
+ return mailboxes.Mailboxes, mailboxes.State, nil
+ })
+ } else {
+ g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) {
+ mailboxes, err := g.jmap.GetAllMailboxes(session, ctx, logger)
+ if err != nil {
+ return nil, "", 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(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) {
+ page, ok, _ := ParseNumericParam(r, "page", -1)
+ 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(session, ctx, logger, mailboxId, offset, limit, true, g.maxBodyValueBytes)
+ if err != nil {
+ return nil, "", err
+ }
+
+ return emails, emails.State, nil
+ })
+}
diff --git a/services/groupware/pkg/groupware/groupware_lowlevel.go b/services/groupware/pkg/groupware/groupware_lowlevel.go
new file mode 100644
index 000000000..f5a4e6aad
--- /dev/null
+++ b/services/groupware/pkg/groupware/groupware_lowlevel.go
@@ -0,0 +1,282 @@
+package groupware
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/render"
+
+ "github.com/jellydator/ttlcache/v3"
+
+ "github.com/opencloud-eu/opencloud/pkg/jmap"
+ "github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
+)
+
+const (
+ logFolderId = "folder-id"
+ logQuery = "query"
+)
+
+type cachedSession interface {
+ Success() bool
+ Get() jmap.Session
+ Error() error
+}
+
+type succeededSession struct {
+ session jmap.Session
+}
+
+func (s succeededSession) Success() bool {
+ return true
+}
+func (s succeededSession) Get() jmap.Session {
+ return s.session
+}
+func (s succeededSession) Error() error {
+ return nil
+}
+
+var _ cachedSession = succeededSession{}
+
+type failedSession struct {
+ err error
+}
+
+func (s failedSession) Success() bool {
+ return false
+}
+func (s failedSession) Get() jmap.Session {
+ panic("this should never be called")
+}
+func (s failedSession) Error() error {
+ return s.err
+}
+
+var _ cachedSession = failedSession{}
+
+type sessionCacheLoader struct {
+ logger *log.Logger
+ jmapClient jmap.Client
+ errorTtl time.Duration
+}
+
+func (l *sessionCacheLoader) Load(c *ttlcache.Cache[string, cachedSession], username string) *ttlcache.Item[string, cachedSession] {
+ 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'", username)
+ return c.Set(username, failedSession{err: err}, l.errorTtl)
+ } else {
+ l.logger.Debug().Str("username", username).Msgf("successfully created session for '%v'", username)
+ return c.Set(username, succeededSession{session: session}, 0) // 0 = use the TTL configured on the Cache
+ }
+}
+
+var _ ttlcache.Loader[string, cachedSession] = &sessionCacheLoader{}
+
+type Groupware struct {
+ mux *chi.Mux
+ logger *log.Logger
+ defaultEmailLimit int
+ maxBodyValueBytes int
+ sessionCache *ttlcache.Cache[string, cachedSession]
+ jmap jmap.Client
+ usernameProvider UsernameProvider
+}
+
+type GroupwareInitializationError struct {
+ Message string
+ Err error
+}
+
+func (e GroupwareInitializationError) Error() string {
+ if e.Message != "" {
+ return fmt.Sprintf("failed to create Groupware: %s: %v", e.Message, e.Err.Error())
+ } else {
+ return fmt.Sprintf("failed to create Groupware: %v", e.Err.Error())
+ }
+}
+func (e GroupwareInitializationError) Unwrap() error {
+ return e.Err
+}
+
+func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux) (*Groupware, error) {
+ baseUrl, err := url.Parse(config.Mail.BaseUrl)
+ if err != nil {
+ logger.Error().Err(err).Msgf("failed to parse configured Mail.Baseurl '%v'", config.Mail.BaseUrl)
+ return nil, GroupwareInitializationError{Message: fmt.Sprintf("failed to parse configured Mail.BaseUrl '%s'", config.Mail.BaseUrl), Err: err}
+ }
+
+ masterUsername := config.Mail.Master.Username
+ if masterUsername == "" {
+ logger.Error().Msg("failed to parse empty Mail.Master.Username")
+ return nil, GroupwareInitializationError{Message: "Mail.Master.Username is empty"}
+ }
+ masterPassword := config.Mail.Master.Password
+ if masterPassword == "" {
+ logger.Error().Msg("failed to parse empty Mail.Master.Password")
+ return nil, GroupwareInitializationError{Message: "Mail.Master.Password is empty"}
+ }
+
+ defaultEmailLimit := config.Mail.DefaultEmailLimit
+ if defaultEmailLimit < 0 {
+ defaultEmailLimit = 0
+ }
+ maxBodyValueBytes := config.Mail.MaxBodyValueBytes
+ if maxBodyValueBytes < 0 {
+ maxBodyValueBytes = 0
+ }
+
+ responseHeaderTimeout := config.Mail.ResponseHeaderTimeout
+ if responseHeaderTimeout < 0 {
+ responseHeaderTimeout = 0
+ }
+ sessionCacheTtl := config.Mail.SessionCacheTtl
+ if sessionCacheTtl < 0 {
+ sessionCacheTtl = 0
+ }
+ sessionFailureCacheTtl := config.Mail.SessionFailureCacheTtl
+ if sessionFailureCacheTtl < 0 {
+ sessionFailureCacheTtl = 0
+ }
+
+ tr := http.DefaultTransport.(*http.Transport).Clone()
+ tr.ResponseHeaderTimeout = responseHeaderTimeout
+ tlsConfig := &tls.Config{InsecureSkipVerify: true}
+ tr.TLSClientConfig = tlsConfig
+ c := *http.DefaultClient
+ c.Transport = tr
+
+ usernameProvider := NewRevaContextUsernameProvider()
+
+ api := jmap.NewHttpJmapApiClient(
+ *baseUrl,
+ &c,
+ masterUsername,
+ masterPassword,
+ )
+
+ jmapClient := jmap.NewClient(api, api)
+
+ var sessionCache *ttlcache.Cache[string, cachedSession]
+ {
+ sessionLoader := &sessionCacheLoader{
+ logger: logger,
+ jmapClient: jmapClient,
+ errorTtl: sessionFailureCacheTtl,
+ }
+
+ sessionCache = ttlcache.New(
+ ttlcache.WithTTL[string, cachedSession](
+ sessionCacheTtl,
+ ),
+ ttlcache.WithDisableTouchOnHit[string, cachedSession](),
+ ttlcache.WithLoader(sessionLoader),
+ )
+ go sessionCache.Start()
+ }
+
+ return &Groupware{
+ mux: mux,
+ logger: logger,
+ sessionCache: sessionCache,
+ usernameProvider: usernameProvider,
+ jmap: jmapClient,
+ defaultEmailLimit: defaultEmailLimit,
+ maxBodyValueBytes: maxBodyValueBytes,
+ }, nil
+}
+
+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
+ }
+
+ item := g.sessionCache.Get(username)
+ if item != nil {
+ value := item.Value()
+ if value != nil {
+ if value.Success() {
+ return value.Get(), true, nil
+ } else {
+ return jmap.Session{}, false, value.Error()
+ }
+ }
+ }
+ return jmap.Session{}, false, nil
+}
+
+func (g Groupware) respond(w http.ResponseWriter, r *http.Request,
+ handler func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error)) {
+ ctx := r.Context()
+ logger := g.logger.SubloggerWithRequestID(ctx)
+ session, ok, err := g.session(r, ctx, &logger)
+ if err != nil {
+ logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg("failed to determine JMAP session")
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ // no session = authentication failed
+ logger.Warn().Err(err).Interface(logQuery, r.URL.Query()).Msg("could not authenticate")
+ w.WriteHeader(http.StatusForbidden)
+ return
+ }
+ logger = session.DecorateLogger(logger)
+
+ response, state, err := handler(r, ctx, &logger, &session)
+ if err != nil {
+ logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg(err.Error())
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ if state != "" {
+ w.Header().Add("ETag", state)
+ }
+ if response == nil {
+ w.WriteHeader(http.StatusNotFound)
+ } else {
+ render.Status(r, http.StatusOK)
+ render.JSON(w, r, response)
+ }
+}
+
+func (g Groupware) withSession(w http.ResponseWriter, r *http.Request,
+ handler func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error)) (any, string, error) {
+ ctx := r.Context()
+ logger := g.logger.SubloggerWithRequestID(ctx)
+ session, ok, err := g.session(r, ctx, &logger)
+ if err != nil {
+ logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg("failed to determine JMAP session")
+ return nil, "", err
+ }
+ if !ok {
+ // no session = authentication failed
+ logger.Warn().Err(err).Interface(logQuery, r.URL.Query()).Msg("could not authenticate")
+ return nil, "", err
+ }
+ logger = session.DecorateLogger(logger)
+
+ response, state, err := handler(r, ctx, &logger, &session)
+ if err != nil {
+ logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg(err.Error())
+ }
+ return response, state, err
+}
diff --git a/services/groupware/pkg/groupware/groupware_reva.go b/services/groupware/pkg/groupware/groupware_reva.go
new file mode 100644
index 000000000..4572a603b
--- /dev/null
+++ b/services/groupware/pkg/groupware/groupware_reva.go
@@ -0,0 +1,36 @@
+package groupware
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+ revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
+)
+
+// UsernameProvider implementation that uses Reva's enrichment of the Context
+// to retrieve the current username.
+type revaContextUsernameProvider struct {
+}
+
+type UsernameProvider interface {
+ // Provide the username for JMAP operations.
+ GetUsername(req *http.Request, ctx context.Context, logger *log.Logger) (string, bool, error)
+}
+
+var _ UsernameProvider = revaContextUsernameProvider{}
+
+func NewRevaContextUsernameProvider() UsernameProvider {
+ return revaContextUsernameProvider{}
+}
+
+// var errUserNotInContext = fmt.Errorf("user not in context")
+
+func (r revaContextUsernameProvider) GetUsername(req *http.Request, ctx context.Context, logger *log.Logger) (string, bool, error) {
+ u, ok := revactx.ContextGetUser(ctx)
+ if !ok {
+ logger.Error().Ctx(ctx).Msgf("could not get user: user not in reva context: %v", ctx)
+ return "", false, nil
+ }
+ return u.GetUsername(), true, nil
+}
diff --git a/services/groupware/pkg/groupware/groupware_tools.go b/services/groupware/pkg/groupware/groupware_tools.go
new file mode 100644
index 000000000..6400742d9
--- /dev/null
+++ b/services/groupware/pkg/groupware/groupware_tools.go
@@ -0,0 +1,30 @@
+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 ""
+}
diff --git a/services/groupware/pkg/middleware/auth.go b/services/groupware/pkg/middleware/auth.go
new file mode 100644
index 000000000..6c8b76fb8
--- /dev/null
+++ b/services/groupware/pkg/middleware/auth.go
@@ -0,0 +1,78 @@
+package middleware
+
+import (
+ "net/http"
+
+ gmmetadata "go-micro.dev/v4/metadata"
+ "google.golang.org/grpc/metadata"
+
+ "github.com/opencloud-eu/opencloud/pkg/account"
+ "github.com/opencloud-eu/opencloud/pkg/log"
+ opkgm "github.com/opencloud-eu/opencloud/pkg/middleware"
+ "github.com/opencloud-eu/reva/v2/pkg/auth/scope"
+ revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
+ "github.com/opencloud-eu/reva/v2/pkg/token/manager/jwt"
+)
+
+// authOptions initializes the available default options.
+func authOptions(opts ...account.Option) account.Options {
+ opt := account.Options{}
+
+ for _, o := range opts {
+ o(&opt)
+ }
+
+ return opt
+}
+
+func Auth(opts ...account.Option) func(http.Handler) http.Handler {
+ opt := authOptions(opts...)
+ tokenManager, err := jwt.New(map[string]any{
+ "secret": opt.JWTSecret,
+ "expires": int64(24 * 60 * 60),
+ })
+ if err != nil {
+ opt.Logger.Fatal().Err(err).Msgf("Could not initialize token-manager")
+ }
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ t := r.Header.Get(revactx.TokenHeader)
+ if t == "" {
+ opt.Logger.Error().Str(log.RequestIDString, r.Header.Get("X-Request-ID")).Msgf("missing access token in header %v", revactx.TokenHeader)
+ w.WriteHeader(http.StatusUnauthorized) // missing access token
+ return
+ }
+
+ u, tokenScope, err := tokenManager.DismantleToken(r.Context(), t)
+ if err != nil {
+ opt.Logger.Error().Str(log.RequestIDString, r.Header.Get("X-Request-ID")).Err(err).Msgf("invalid access token in header %v", revactx.TokenHeader)
+ w.WriteHeader(http.StatusUnauthorized) // invalid token
+ return
+ }
+ if ok, err := scope.VerifyScope(ctx, tokenScope, r); err != nil || !ok {
+ opt.Logger.Error().Str(log.RequestIDString, r.Header.Get("X-Request-ID")).Err(err).Msg("verifying scope failed")
+ w.WriteHeader(http.StatusUnauthorized) // invalid scope
+ return
+ }
+
+ ctx = revactx.ContextSetToken(ctx, t)
+ ctx = revactx.ContextSetUser(ctx, u)
+ ctx = gmmetadata.Set(ctx, opkgm.AccountID, u.GetId().GetOpaqueId())
+ if m := u.GetOpaque().GetMap(); m != nil {
+ if roles, ok := m["roles"]; ok {
+ ctx = gmmetadata.Set(ctx, opkgm.RoleIDs, string(roles.GetValue()))
+ }
+ }
+ ctx = metadata.AppendToOutgoingContext(ctx, revactx.TokenHeader, t)
+
+ initiatorID := r.Header.Get(revactx.InitiatorHeader)
+ if initiatorID != "" {
+ ctx = revactx.ContextSetInitiator(ctx, initiatorID)
+ ctx = metadata.AppendToOutgoingContext(ctx, revactx.InitiatorHeader, initiatorID)
+ }
+
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
diff --git a/services/groupware/pkg/server/http/server.go b/services/groupware/pkg/server/http/server.go
index 67c907391..5c5320ee7 100644
--- a/services/groupware/pkg/server/http/server.go
+++ b/services/groupware/pkg/server/http/server.go
@@ -4,10 +4,12 @@ import (
"fmt"
"github.com/go-chi/chi/v5/middleware"
+ "github.com/opencloud-eu/opencloud/pkg/account"
"github.com/opencloud-eu/opencloud/pkg/cors"
opencloudmiddleware "github.com/opencloud-eu/opencloud/pkg/middleware"
"github.com/opencloud-eu/opencloud/pkg/service/http"
"github.com/opencloud-eu/opencloud/pkg/version"
+ groupwaremiddleware "github.com/opencloud-eu/opencloud/services/groupware/pkg/middleware"
svc "github.com/opencloud-eu/opencloud/services/groupware/pkg/service/http/v0"
"go-micro.dev/v4"
)
@@ -33,7 +35,7 @@ func Server(opts ...Option) (http.Service, error) {
return http.Service{}, fmt.Errorf("could not initialize http service: %w", err)
}
- handle := svc.NewService(
+ handle, err := svc.NewService(
svc.Logger(options.Logger),
svc.Config(options.Config),
svc.Middleware(
@@ -51,8 +53,15 @@ func Server(opts ...Option) (http.Service, error) {
version.GetString(),
),
opencloudmiddleware.Logger(options.Logger),
+ groupwaremiddleware.Auth(
+ account.Logger(options.Logger),
+ account.JWTSecret(options.Config.TokenManager.JWTSecret),
+ ),
),
)
+ if err != nil {
+ return http.Service{}, err
+ }
{
handle = svc.NewInstrument(handle, options.Metrics)
diff --git a/services/groupware/pkg/service/http/v0/service.go b/services/groupware/pkg/service/http/v0/service.go
index 2ce683503..d0f4f2534 100644
--- a/services/groupware/pkg/service/http/v0/service.go
+++ b/services/groupware/pkg/service/http/v0/service.go
@@ -1,18 +1,13 @@
package svc
import (
- "crypto/tls"
"net/http"
- "time"
"github.com/go-chi/chi/v5"
- "github.com/go-chi/render"
"github.com/riandyrn/otelchi"
- "github.com/opencloud-eu/opencloud/pkg/jmap"
- "github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/tracing"
- "github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/groupware"
)
// Service defines the service handlers.
@@ -21,7 +16,7 @@ type Service interface {
}
// NewService returns a service implementation for Service.
-func NewService(opts ...Option) Service {
+func NewService(opts ...Option) (Service, error) {
options := newOptions(opts...)
m := chi.NewMux()
@@ -36,89 +31,17 @@ func NewService(opts ...Option) Service {
),
)
- svc := NewGroupware(options.Config, &options.Logger, m)
+ gw, err := groupware.NewGroupware(options.Config, &options.Logger, m)
+ if err != nil {
+ return nil, err
+ }
- m.Route(options.Config.HTTP.Root, func(r chi.Router) {
- r.Get("/", svc.WellDefined)
- r.Get("/ping", svc.Ping)
- })
+ m.Route(options.Config.HTTP.Root, gw.Route)
_ = chi.Walk(m, func(method string, route string, _ http.Handler, middlewares ...func(http.Handler) http.Handler) error {
options.Logger.Debug().Str("method", method).Str("route", route).Int("middlewares", len(middlewares)).Msg("serving endpoint")
return nil
})
- return svc
-}
-
-type Groupware struct {
- jmapClient jmap.Client
- usernameProvider jmap.HttpJmapUsernameProvider
- config *config.Config
- logger *log.Logger
- mux *chi.Mux
-}
-
-func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux) *Groupware {
- usernameProvider := jmap.NewRevaContextHttpJmapUsernameProvider()
- httpApiClient := httpApiClient(config, usernameProvider)
- jmapClient := jmap.NewClient(httpApiClient, httpApiClient)
- return &Groupware{
- jmapClient: jmapClient,
- usernameProvider: usernameProvider,
- config: config,
- mux: mux,
- logger: logger,
- }
-}
-
-func (g Groupware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- g.mux.ServeHTTP(w, r)
-}
-
-type IndexResponse struct {
- AccountId string
-}
-
-func (IndexResponse) Render(w http.ResponseWriter, r *http.Request) error {
- return nil
-}
-
-func (g Groupware) Ping(w http.ResponseWriter, r *http.Request) {
- g.logger.Info().Msg("groupware pinged")
- w.WriteHeader(http.StatusNoContent)
-}
-
-func httpApiClient(config *config.Config, usernameProvider jmap.HttpJmapUsernameProvider) *jmap.HttpJmapApiClient {
- tr := http.DefaultTransport.(*http.Transport).Clone()
- tr.ResponseHeaderTimeout = time.Duration(10) * time.Second
- tlsConfig := &tls.Config{InsecureSkipVerify: true}
- tr.TLSClientConfig = tlsConfig
- c := *http.DefaultClient
- c.Transport = tr
-
- api := jmap.NewHttpJmapApiClient(
- config.Mail.BaseUrl,
- config.Mail.JmapUrl,
- &c,
- usernameProvider,
- config.Mail.Master.Username,
- config.Mail.Master.Password,
- )
- return api
-}
-func (g Groupware) WellDefined(w http.ResponseWriter, r *http.Request) {
- logger := g.logger.SubloggerWithRequestID(r.Context())
- username, err := g.usernameProvider.GetUsername(r, r.Context(), &logger)
- if err != nil {
- w.WriteHeader(http.StatusInternalServerError)
- return
- }
-
- jmapContext, err := g.jmapClient.FetchSession(username, &logger)
- if err != nil {
- return
- }
-
- _ = render.Render(w, r, IndexResponse{AccountId: jmapContext.AccountId})
+ return gw, nil
}
diff --git a/services/proxy/pkg/config/defaults/defaultconfig.go b/services/proxy/pkg/config/defaults/defaultconfig.go
index b114949a2..493d374e4 100644
--- a/services/proxy/pkg/config/defaults/defaultconfig.go
+++ b/services/proxy/pkg/config/defaults/defaultconfig.go
@@ -292,9 +292,8 @@ func DefaultPolicies() []config.Policy {
SkipXAccessToken: true,
},
{
- Endpoint: "/groupware",
- Service: "eu.opencloud.web.groupware",
- Unprotected: true,
+ Endpoint: "/groupware",
+ Service: "eu.opencloud.web.groupware",
},
{
Endpoint: "/auth",