mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-01-22 12:59:23 -05:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
120
pkg/jmap/jmap.go
120
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
6
services/groupware/pkg/config/reva.go
Normal file
6
services/groupware/pkg/config/reva.go
Normal 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."`
|
||||
}
|
||||
162
services/groupware/pkg/groupware/groupware.go
Normal file
162
services/groupware/pkg/groupware/groupware.go
Normal 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
|
||||
})
|
||||
}
|
||||
282
services/groupware/pkg/groupware/groupware_lowlevel.go
Normal file
282
services/groupware/pkg/groupware/groupware_lowlevel.go
Normal 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
|
||||
}
|
||||
36
services/groupware/pkg/groupware/groupware_reva.go
Normal file
36
services/groupware/pkg/groupware/groupware_reva.go
Normal 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
|
||||
}
|
||||
30
services/groupware/pkg/groupware/groupware_tools.go
Normal file
30
services/groupware/pkg/groupware/groupware_tools.go
Normal 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 ""
|
||||
}
|
||||
78
services/groupware/pkg/middleware/auth.go
Normal file
78
services/groupware/pkg/middleware/auth.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user