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
This commit is contained in:
Pascal Bleser
2025-07-25 15:19:46 +02:00
parent 8bb4ad056d
commit 0247c28d58
25 changed files with 861 additions and 763 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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"`
}

View File

@@ -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,
},
}
}

View File

@@ -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
})
}

View File

@@ -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"))
})
})
})

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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"`
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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."`
}

View File

@@ -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
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 ""
}

View File

@@ -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))
})
}
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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",