From ed11980784f485cad4c4ffaa18f7b261c9cd985f Mon Sep 17 00:00:00 2001 From: Pascal Bleser Date: Wed, 24 Sep 2025 09:36:45 +0200 Subject: [PATCH] start websocket implementation, add endpoint for email summaries * feat(groupware): start implementing JMAP websocket support for push notifications (unfinished) * groupware: add GetLatestEmailsSummaryForAllAccounts * add new vendored dependency: github.com/gorilla/websocket * jmap: add QueryEmailSummaries * openapi: start adding examples * openapi: add new tooling for api-examples.yaml injection * apidoc-process.ts: make it more typescript-y * bump @redocly/cli from 2.0.8 to latest 2.2.0 --- go.mod | 1 + go.sum | 2 + pkg/jmap/jmap_api.go | 16 + pkg/jmap/jmap_api_email.go | 64 +- pkg/jmap/jmap_api_mailbox.go | 8 +- pkg/jmap/jmap_api_ws.go | 13 + pkg/jmap/jmap_client.go | 21 +- pkg/jmap/jmap_error.go | 6 + pkg/jmap/jmap_http.go | 153 ++ pkg/jmap/jmap_integration_test.go | 11 +- pkg/jmap/jmap_model.go | 128 +- pkg/jmap/jmap_session.go | 16 +- pkg/jmap/jmap_test.go | 25 +- services/groupware/api-examples.yaml | 69 + services/groupware/apidoc-process.ts | 153 +- services/groupware/package.json | 4 +- services/groupware/pkg/config/config.go | 1 + .../pkg/config/defaults/defaultconfig.go | 1 + ...pi_messages.go => groupware_api_emails.go} | 332 +++++ .../pkg/groupware/groupware_framework.go | 42 +- .../pkg/groupware/groupware_route.go | 5 + services/groupware/pnpm-lock.yaml | 45 +- services/groupware/pnpm-workspace.yaml | 3 + .../github.com/gorilla/websocket/.gitignore | 25 + vendor/github.com/gorilla/websocket/AUTHORS | 9 + vendor/github.com/gorilla/websocket/LICENSE | 22 + vendor/github.com/gorilla/websocket/README.md | 33 + vendor/github.com/gorilla/websocket/client.go | 434 ++++++ .../gorilla/websocket/compression.go | 148 ++ vendor/github.com/gorilla/websocket/conn.go | 1238 +++++++++++++++++ vendor/github.com/gorilla/websocket/doc.go | 227 +++ vendor/github.com/gorilla/websocket/join.go | 42 + vendor/github.com/gorilla/websocket/json.go | 60 + vendor/github.com/gorilla/websocket/mask.go | 55 + .../github.com/gorilla/websocket/mask_safe.go | 16 + .../github.com/gorilla/websocket/prepared.go | 102 ++ vendor/github.com/gorilla/websocket/proxy.go | 77 + vendor/github.com/gorilla/websocket/server.go | 365 +++++ .../gorilla/websocket/tls_handshake.go | 21 + .../gorilla/websocket/tls_handshake_116.go | 21 + vendor/github.com/gorilla/websocket/util.go | 298 ++++ .../gorilla/websocket/x_net_proxy.go | 473 +++++++ vendor/modules.txt | 3 + 43 files changed, 4693 insertions(+), 95 deletions(-) create mode 100644 pkg/jmap/jmap_api_ws.go create mode 100644 services/groupware/api-examples.yaml rename services/groupware/pkg/groupware/{groupware_api_messages.go => groupware_api_emails.go} (67%) create mode 100644 services/groupware/pnpm-workspace.yaml create mode 100644 vendor/github.com/gorilla/websocket/.gitignore create mode 100644 vendor/github.com/gorilla/websocket/AUTHORS create mode 100644 vendor/github.com/gorilla/websocket/LICENSE create mode 100644 vendor/github.com/gorilla/websocket/README.md create mode 100644 vendor/github.com/gorilla/websocket/client.go create mode 100644 vendor/github.com/gorilla/websocket/compression.go create mode 100644 vendor/github.com/gorilla/websocket/conn.go create mode 100644 vendor/github.com/gorilla/websocket/doc.go create mode 100644 vendor/github.com/gorilla/websocket/join.go create mode 100644 vendor/github.com/gorilla/websocket/json.go create mode 100644 vendor/github.com/gorilla/websocket/mask.go create mode 100644 vendor/github.com/gorilla/websocket/mask_safe.go create mode 100644 vendor/github.com/gorilla/websocket/prepared.go create mode 100644 vendor/github.com/gorilla/websocket/proxy.go create mode 100644 vendor/github.com/gorilla/websocket/server.go create mode 100644 vendor/github.com/gorilla/websocket/tls_handshake.go create mode 100644 vendor/github.com/gorilla/websocket/tls_handshake_116.go create mode 100644 vendor/github.com/gorilla/websocket/util.go create mode 100644 vendor/github.com/gorilla/websocket/x_net_proxy.go diff --git a/go.mod b/go.mod index 1cc79ace2b..ef970beb68 100644 --- a/go.mod +++ b/go.mod @@ -257,6 +257,7 @@ require ( github.com/gookit/goutil v0.7.1 // indirect github.com/gorilla/handlers v1.5.1 // indirect github.com/gorilla/schema v1.4.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-plugin v1.7.0 // indirect diff --git a/go.sum b/go.sum index 363e5ab541..7cb412ee05 100644 --- a/go.sum +++ b/go.sum @@ -641,6 +641,8 @@ github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWS github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= diff --git a/pkg/jmap/jmap_api.go b/pkg/jmap/jmap_api.go index a8bfd45f95..24080ac476 100644 --- a/pkg/jmap/jmap_api.go +++ b/pkg/jmap/jmap_api.go @@ -13,13 +13,29 @@ type ApiClient interface { io.Closer } +type WsPushListener interface { + OnNotification(stateChange StateChange) +} + +type WsClient interface { + DisableNotifications() Error + io.Closer +} + +type WsClientFactory interface { + EnableNotifications(pushState string, sessionProvider func() (*Session, error), listener WsPushListener) (WsClient, Error) + io.Closer +} + type SessionClient interface { GetSession(baseurl *url.URL, username string, logger *log.Logger) (SessionResponse, Error) + io.Closer } type BlobClient interface { UploadBinary(ctx context.Context, logger *log.Logger, session *Session, uploadUrl string, endpoint string, contentType string, content io.Reader) (UploadedBlob, Error) DownloadBinary(ctx context.Context, logger *log.Logger, session *Session, downloadUrl string, endpoint string) (*BlobDownload, Error) + io.Closer } const ( diff --git a/pkg/jmap/jmap_api_email.go b/pkg/jmap/jmap_api_email.go index 1ec92265c6..8aeb73e48c 100644 --- a/pkg/jmap/jmap_api_email.go +++ b/pkg/jmap/jmap_api_email.go @@ -7,6 +7,7 @@ import ( "time" "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/pkg/structs" "github.com/rs/zerolog" ) @@ -77,7 +78,7 @@ func (j *Client) GetAllEmailsInMailbox(accountId string, session *Session, ctx c get := EmailGetRefCommand{ AccountId: accountId, FetchAllBodyValues: fetchBodies, - IdRef: &ResultReference{Name: CommandEmailQuery, Path: "/ids/*", ResultOf: "0"}, + IdsRef: &ResultReference{Name: CommandEmailQuery, Path: "/ids/*", ResultOf: "0"}, } if maxBodyValueBytes > 0 { get.MaxBodyValueBytes = maxBodyValueBytes @@ -131,7 +132,7 @@ func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context. getCreated := EmailGetRefCommand{ AccountId: accountId, FetchAllBodyValues: fetchBodies, - IdRef: &ResultReference{Name: CommandEmailChanges, Path: "/created", ResultOf: "0"}, + IdsRef: &ResultReference{Name: CommandEmailChanges, Path: "/created", ResultOf: "0"}, } if maxBodyValueBytes > 0 { getCreated.MaxBodyValueBytes = maxBodyValueBytes @@ -139,7 +140,7 @@ func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context. getUpdated := EmailGetRefCommand{ AccountId: accountId, FetchAllBodyValues: fetchBodies, - IdRef: &ResultReference{Name: CommandEmailChanges, Path: "/updated", ResultOf: "0"}, + IdsRef: &ResultReference{Name: CommandEmailChanges, Path: "/updated", ResultOf: "0"}, } if maxBodyValueBytes > 0 { getUpdated.MaxBodyValueBytes = maxBodyValueBytes @@ -284,7 +285,7 @@ func (j *Client) QueryEmails(accountId string, filter EmailFilterElement, sessio mails := EmailGetRefCommand{ AccountId: accountId, - IdRef: &ResultReference{ + IdsRef: &ResultReference{ ResultOf: "0", Name: CommandEmailQuery, Path: "/ids/*", @@ -369,7 +370,7 @@ func (j *Client) QueryEmailsWithSnippets(accountId string, filter EmailFilterEle mails := EmailGetRefCommand{ AccountId: accountId, - IdRef: &ResultReference{ + IdsRef: &ResultReference{ ResultOf: "0", Name: CommandEmailQuery, Path: "/ids/*", @@ -759,7 +760,7 @@ func (j *Client) EmailsInThread(accountId string, threadId string, session *Sess }, "0"), invocation(CommandEmailGet, EmailGetRefCommand{ AccountId: accountId, - IdRef: &ResultReference{ + IdsRef: &ResultReference{ ResultOf: "0", Name: CommandThreadGet, Path: "/list/*/emailIds", @@ -782,3 +783,54 @@ func (j *Client) EmailsInThread(accountId string, threadId string, session *Sess }) } + +type EmailsSummary struct { + Emails []Email `json:"emails"` + State State `json:"state"` +} + +func (j *Client) QueryEmailSummaries(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, filter EmailFilterElement, limit uint) (map[string]EmailsSummary, SessionState, Error) { + logger = j.logger("QueryEmailSummaries", session, logger) + + uniqueAccountIds := structs.Uniq(accountIds) + + invocations := make([]Invocation, len(uniqueAccountIds)*2) + for i, accountId := range uniqueAccountIds { + invocations[i*2+0] = invocation(CommandEmailQuery, EmailQueryCommand{ + AccountId: accountId, + Filter: filter, + Sort: []EmailComparator{EmailComparator{Property: emailSortByReceivedAt, IsAscending: false}}, + Limit: limit, + //CalculateTotal: false, + }, mcid(accountId, "0")) + invocations[i*2+1] = invocation(CommandEmailGet, EmailGetRefCommand{ + AccountId: accountId, + IdsRef: &ResultReference{ + Name: CommandEmailQuery, + Path: "/ids/*", + ResultOf: mcid(accountId, "0"), + }, + Properties: []string{"id", "threadId", "mailboxIds", "keywords", "size", "receivedAt", "sender", "from", "to", "cc", "bcc", "subject", "sentAt", "hasAttachment", "attachments", "preview"}, + }, mcid(accountId, "1")) + } + cmd, err := j.request(session, logger, invocations...) + if err != nil { + return map[string]EmailsSummary{}, "", err + } + + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (map[string]EmailsSummary, Error) { + resp := map[string]EmailsSummary{} + for _, accountId := range uniqueAccountIds { + var response EmailGetResponse + err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, mcid(accountId, "1"), &response) + if err != nil { + return map[string]EmailsSummary{}, err + } + if len(response.NotFound) > 0 { + // TODO what to do when there are not-found emails here? potentially nothing, they could have been deleted between query and get? + } + resp[accountId] = EmailsSummary{Emails: response.List, State: response.State} + } + return resp, nil + }) +} diff --git a/pkg/jmap/jmap_api_mailbox.go b/pkg/jmap/jmap_api_mailbox.go index 6c43e20b65..558b674280 100644 --- a/pkg/jmap/jmap_api_mailbox.go +++ b/pkg/jmap/jmap_api_mailbox.go @@ -152,7 +152,7 @@ func (j *Client) GetMailboxChanges(accountId string, session *Session, ctx conte getCreated := EmailGetRefCommand{ AccountId: accountId, FetchAllBodyValues: fetchBodies, - IdRef: &ResultReference{Name: CommandMailboxChanges, Path: "/created", ResultOf: "0"}, + IdsRef: &ResultReference{Name: CommandMailboxChanges, Path: "/created", ResultOf: "0"}, } if maxBodyValueBytes > 0 { getCreated.MaxBodyValueBytes = maxBodyValueBytes @@ -160,7 +160,7 @@ func (j *Client) GetMailboxChanges(accountId string, session *Session, ctx conte getUpdated := EmailGetRefCommand{ AccountId: accountId, FetchAllBodyValues: fetchBodies, - IdRef: &ResultReference{Name: CommandMailboxChanges, Path: "/updated", ResultOf: "0"}, + IdsRef: &ResultReference{Name: CommandMailboxChanges, Path: "/updated", ResultOf: "0"}, } if maxBodyValueBytes > 0 { getUpdated.MaxBodyValueBytes = maxBodyValueBytes @@ -241,7 +241,7 @@ func (j *Client) GetMailboxChangesForMultipleAccounts(accountIds []string, sessi getCreated := EmailGetRefCommand{ AccountId: accountId, FetchAllBodyValues: fetchBodies, - IdRef: &ResultReference{Name: CommandMailboxChanges, Path: "/created", ResultOf: mcid(accountId, "0")}, + IdsRef: &ResultReference{Name: CommandMailboxChanges, Path: "/created", ResultOf: mcid(accountId, "0")}, } if maxBodyValueBytes > 0 { getCreated.MaxBodyValueBytes = maxBodyValueBytes @@ -249,7 +249,7 @@ func (j *Client) GetMailboxChangesForMultipleAccounts(accountIds []string, sessi getUpdated := EmailGetRefCommand{ AccountId: accountId, FetchAllBodyValues: fetchBodies, - IdRef: &ResultReference{Name: CommandMailboxChanges, Path: "/updated", ResultOf: mcid(accountId, "0")}, + IdsRef: &ResultReference{Name: CommandMailboxChanges, Path: "/updated", ResultOf: mcid(accountId, "0")}, } if maxBodyValueBytes > 0 { getUpdated.MaxBodyValueBytes = maxBodyValueBytes diff --git a/pkg/jmap/jmap_api_ws.go b/pkg/jmap/jmap_api_ws.go new file mode 100644 index 0000000000..62cef36ce3 --- /dev/null +++ b/pkg/jmap/jmap_api_ws.go @@ -0,0 +1,13 @@ +package jmap + +import ( + "github.com/opencloud-eu/opencloud/pkg/log" +) + +func (j *Client) EnablePush(pushState string, session *Session, _ *log.Logger) Error { + return nil // TODO +} + +func (j *Client) DisablePush(_ *Session, _ *log.Logger) Error { + return nil // TODO +} diff --git a/pkg/jmap/jmap_client.go b/pkg/jmap/jmap_client.go index e8337e2d18..8268a0d0e3 100644 --- a/pkg/jmap/jmap_client.go +++ b/pkg/jmap/jmap_client.go @@ -1,6 +1,7 @@ package jmap import ( + "errors" "io" "net/url" @@ -12,22 +13,28 @@ type Client struct { session SessionClient api ApiClient blob BlobClient + ws WsClientFactory sessionEventListeners *eventListeners[SessionEventListener] + wsPushListeners *eventListeners[WsPushListener] io.Closer + WsPushListener } var _ io.Closer = &Client{} +var _ WsPushListener = &Client{} func (j *Client) Close() error { - return j.api.Close() + return errors.Join(j.api.Close(), j.session.Close(), j.blob.Close(), j.ws.Close()) } -func NewClient(session SessionClient, api ApiClient, blob BlobClient) Client { +func NewClient(session SessionClient, api ApiClient, blob BlobClient, ws WsClientFactory) Client { return Client{ session: session, api: api, blob: blob, + ws: ws, sessionEventListeners: newEventListeners[SessionEventListener](), + wsPushListeners: newEventListeners[WsPushListener](), } } @@ -41,6 +48,16 @@ func (j *Client) onSessionOutdated(session *Session, newSessionState SessionStat }) } +func (j *Client) AddWsPushListener(listener WsPushListener) { + j.wsPushListeners.add(listener) +} + +func (j *Client) OnNotification(stateChange StateChange) { + j.wsPushListeners.signal(func(listener WsPushListener) { + listener.OnNotification(stateChange) + }) +} + // Retrieve JMAP well-known data from the Stalwart server and create a Session from that. func (j *Client) FetchSession(sessionUrl *url.URL, username string, logger *log.Logger) (Session, Error) { wk, err := j.session.GetSession(sessionUrl, username, logger) diff --git a/pkg/jmap/jmap_error.go b/pkg/jmap/jmap_error.go index 710ff397bb..e81b9578af 100644 --- a/pkg/jmap/jmap_error.go +++ b/pkg/jmap/jmap_error.go @@ -30,6 +30,12 @@ const ( JmapErrorAccountNotFound JmapErrorAccountNotSupportedByMethod JmapErrorAccountReadOnly + JmapErrorFailedToEstablishWssConnection + JmapErrorWssConnectionResponseMissingJmapSubprotocol + JmapErrorWssFailedToSendWebSocketPushEnable + JmapErrorWssFailedToSendWebSocketPushDisable + JmapErrorWssFailedToClose + JmapErrorWssFailedToRetrieveSession ) var ( diff --git a/pkg/jmap/jmap_http.go b/pkg/jmap/jmap_http.go index bdc633bd37..62404d5565 100644 --- a/pkg/jmap/jmap_http.go +++ b/pkg/jmap/jmap_http.go @@ -3,14 +3,17 @@ package jmap import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" + "slices" "strconv" + "github.com/gorilla/websocket" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/version" ) @@ -320,3 +323,153 @@ func (h *HttpJmapClient) DownloadBinary(ctx context.Context, logger *log.Logger, CacheControl: res.Header.Get("Cache-Control"), }, nil } + +type WebSocketPushEnable struct { + // This MUST be the string "WebSocketPushEnable". + Type string `json:"@type"` + + // A list of data type names (e.g., "Mailbox" or "Email") that the client is interested in. + // + // A StateChange notification will only be sent if the data for one of these types changes. + // Other types are omitted from the TypeState object. + // + // If null, changes will be pushed for all supported data types. + DataTypes *[]string `json:"dataTypes"` + + // The last "pushState" token that the client received from the server. + + // Upon receipt of a "pushState" token, the server SHOULD immediately send all changes since that state token. + PushState string `json:"pushState,omitempty"` +} + +type WebSocketPushDisable struct { + // This MUST be the string "WebSocketPushDisable". + Type string `json:"@type"` +} + +type HttpWsClientFactory struct { + dialer *websocket.Dialer + masterUser string + masterPassword string +} + +var _ WsClientFactory = &HttpWsClientFactory{} + +func NewHttpWsClientFactory(dialer *websocket.Dialer, masterUser string, masterPassword string, logger *log.Logger) (*HttpWsClientFactory, error) { + /* + d := websocket.Dialer{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // TODO configurable + HandshakeTimeout: 5 * time.Second, + // RFC 8887: Section 4.2: + // Otherwise, the client MUST make an authenticated HTTP request [RFC7235] on the encrypted connection + // and MUST include the value "jmap" in the list of protocols for the "Sec-WebSocket-Protocol" header + // field. + Subprotocols: []string{"jmap"}, + } + */ + + // RFC 8887: Section 4.2: + // Otherwise, the client MUST make an authenticated HTTP request [RFC7235] on the encrypted connection + // and MUST include the value "jmap" in the list of protocols for the "Sec-WebSocket-Protocol" header + // field. + dialer.Subprotocols = []string{"jmap"} + + return &HttpWsClientFactory{ + dialer: dialer, + masterUser: masterUser, + masterPassword: masterPassword, + }, nil +} + +func (w *HttpWsClientFactory) auth(username string, h http.Header) error { + masterUsername := username + "%" + w.masterUser + h.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(masterUsername+":"+w.masterPassword))) + return nil +} + +func (w *HttpWsClientFactory) connect(sessionProvider func() (*Session, error)) (*websocket.Conn, string, Error) { + session, err := sessionProvider() + if err != nil { + return nil, "", SimpleError{code: JmapErrorWssFailedToRetrieveSession, err: err} + } + if session == nil { + return nil, "", SimpleError{code: JmapErrorWssFailedToRetrieveSession, err: nil} + } + username := session.Username + u := session.WebsocketUrl + + h := http.Header{} + w.auth(username, h) + c, resp, err := w.dialer.Dial(u.String(), h) + if err != nil { + return nil, "", SimpleError{code: JmapErrorFailedToEstablishWssConnection, err: err} + } + + // RFC 8887: Section 4.2: + // The reply from the server MUST also contain a corresponding "Sec-WebSocket-Protocol" header + // field with a value of "jmap" in order for a JMAP subprotocol connection to be established. + if !slices.Contains(resp.Header.Values("Sec-WebSocket-Protocol"), "jmap") { + return nil, "", SimpleError{code: JmapErrorWssConnectionResponseMissingJmapSubprotocol} + } + + return c, username, nil +} + +type HttpWsClient struct { + client *HttpWsClientFactory + username string + sessionProvider func() (*Session, error) + c *websocket.Conn + WsClient +} + +func (w *HttpWsClientFactory) EnableNotifications(pushState string, sessionProvider func() (*Session, error), listener WsPushListener) (WsClient, Error) { + c, username, jerr := w.connect(sessionProvider) + if jerr != nil { + return nil, jerr + } + + err := c.WriteJSON(WebSocketPushEnable{ + Type: "WebSocketPushEnable", + DataTypes: nil, // = all datatypes + PushState: pushState, // will be omitted if empty string + }) + if err != nil { + return nil, SimpleError{code: JmapErrorWssFailedToSendWebSocketPushEnable, err: err} + } + + return &HttpWsClient{ + client: w, + username: username, + sessionProvider: sessionProvider, + c: c, + }, nil +} + +func (w *HttpWsClientFactory) Close() error { + return nil +} + +func (c *HttpWsClient) DisableNotifications() Error { + if c.c == nil { + return nil + } + + err := c.c.WriteJSON(WebSocketPushDisable{ + Type: "WebSocketPushDisable", + }) + if err != nil { + return SimpleError{code: JmapErrorWssFailedToSendWebSocketPushDisable, err: err} + } + + err = c.c.Close() + if err != nil { + return SimpleError{code: JmapErrorWssFailedToClose, err: err} + } + + return nil +} + +func (c *HttpWsClient) Close() error { + return c.DisableNotifications() +} diff --git a/pkg/jmap/jmap_integration_test.go b/pkg/jmap/jmap_integration_test.go index 5bb9c0dfb5..b46ff851f5 100644 --- a/pkg/jmap/jmap_integration_test.go +++ b/pkg/jmap/jmap_integration_test.go @@ -18,6 +18,7 @@ import ( "text/template" "time" + "github.com/gorilla/websocket" "github.com/jhillyerd/enmime/v2" "github.com/stretchr/testify/require" "golang.org/x/text/cases" @@ -322,6 +323,11 @@ func TestWithStalwart(t *testing.T) { jh := *http.DefaultClient jh.Transport = tr + wsd := &websocket.Dialer{ + TLSClientConfig: tlsConfig, + HandshakeTimeout: time.Duration(10) * time.Second, + } + jmapPort, err := container.MappedPort(ctx, httpPort) require.NoError(err) jmapBaseUrl := url.URL{ @@ -338,7 +344,10 @@ func TestWithStalwart(t *testing.T) { nullHttpJmapApiClientEventListener{}, ) - j = NewClient(api, api, api) + wscf, err := NewHttpWsClientFactory(wsd, masterUsername, masterPassword, logger) + require.NoError(err) + + j = NewClient(api, api, api, wscf) s, err := j.FetchSession(sessionUrl, username, logger) require.NoError(err) // we have to overwrite the hostname in JMAP URL because the container diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index bbbf224a6f..8f9de603fc 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -1182,7 +1182,7 @@ type EmailGetRefCommand struct { // // If null, then all records of the data type are returned, if this is supported for that // data type and the number of records does not exceed the maxObjectsInGet limit. - IdRef *ResultReference `json:"#ids,omitempty"` + IdsRef *ResultReference `json:"#ids,omitempty"` // The id of the account to use. AccountId string `json:"accountId"` @@ -1262,6 +1262,8 @@ type EmailAddress struct { // SHOULD be used instead. Otherwise, this property is null. // // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html + // + // example: $emailAddressName Name string `json:"name,omitempty"` // The addr-spec of the mailbox [RFC5322]. @@ -1275,6 +1277,8 @@ type EmailAddress struct { // // [RFC2047]: https://www.rfc-editor.org/rfc/rfc2047.html // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html + // + // example: $emailAddressEmail Email string `json:"email,omitempty"` } @@ -1305,36 +1309,42 @@ type EmailHeader struct { // Email body part. // -// The client may specify a partId OR a blobId, but not both. -// If a partId is given, this partId MUST be present in the bodyValues property. +// The client may specify a `partId` OR a `blobId`, but not both. +// If a `partId` is given, this `partId` MUST be present in the `bodyValues` property. // -// The charset property MUST be omitted if a partId is given (the part’s content is included -// in bodyValues, and the server may choose any appropriate encoding). +// The `charset` property MUST be omitted if a `partId` is given (the part’s content is included +// in `bodyValues`, and the server may choose any appropriate encoding). // -// The size property MUST be omitted if a partId is given. If a blobId is given, it may be +// The `size` property MUST be omitted if a `partId` is given. If a `blobId` is given, it may be // included but is ignored by the server (the size is actually calculated from the blob content // itself). // -// A Content-Transfer-Encoding header field MUST NOT be given. +// A `Content-Transfer-Encoding` header field MUST NOT be given. type EmailBodyPart struct { // Identifies this part uniquely within the Email. // - // This is scoped to the emailId and has no meaning outside of the JMAP Email object representation. - // This is null if, and only if, the part is of type multipart/*. + // This is scoped to the `emailId` and has no meaning outside of the JMAP Email object representation. + // This is null if, and only if, the part is of type `multipart/*`. + // + // example: $attachmentPartId PartId string `json:"partId,omitempty"` // The id representing the raw octets of the contents of the part, after decoding any known - // Content-Transfer-Encoding (as defined in [RFC2045]), or null if, and only if, the part is of type multipart/*. + // `Content-Transfer-Encoding` (as defined in [RFC2045]), or null if, and only if, the part is of type `multipart/*`. // // Note that two parts may be transfer-encoded differently but have the same blob id if their decoded octets are identical // and the server is using a secure hash of the data for the blob id. // If the transfer encoding is unknown, it is treated as though it had no transfer encoding. // // [RFC2045]: https://www.rfc-editor.org/rfc/rfc2045.html + // + // example: $blobId BlobId string `json:"blobId,omitempty"` - // The size, in octets, of the raw data after content transfer decoding (as referenced by the blobId, i.e., + // The size, in octets, of the raw data after content transfer decoding (as referenced by the `blobId`, i.e., // the number of octets in the file the user would download). + // + // example: 31219 Size int `json:"size,omitempty"` // This is a list of all header fields in the part, in the order they appear in the message. @@ -1342,64 +1352,81 @@ type EmailBodyPart struct { // The values are in Raw form. Headers []EmailHeader `json:"headers,omitempty"` - // This is the decoded filename parameter of the Content-Disposition header field per [RFC2231], or - // (for compatibility with existing systems). - // - // If not present, then it’s the decoded name parameter of the Content-Type header field per [RFC2047]. + // This is the decoded filename parameter of the `Content-Disposition` header field per [RFC2231], or + // (for compatibility with existing systems) if not present, then it’s the decoded name parameter of + // the `Content-Type` header field per [RFC2047]. // // [RFC2231]: https://www.rfc-editor.org/rfc/rfc2231.html // [RFC2047]: https://www.rfc-editor.org/rfc/rfc2047.html + // + // name: $attachmentName Name string `json:"name,omitempty"` - // The value of the Content-Type header field of the part, if present; otherwise, the implicit type as per - // the MIME standard (text/plain or message/rfc822 if inside a multipart/digest). + // The value of the `Content-Type` header field of the part, if present; otherwise, the implicit type as per + // the MIME standard (`text/plain` or `message/rfc822` if inside a `multipart/digest`). // - // CFWS is removed and any parameters are stripped. + // [CFWS] is removed and any parameters are stripped. + // + // [CFWS]: https://www.rfc-editor.org/rfc/rfc5322#section-3.2.2 + // + // example: $attachmentType Type string `json:"type,omitempty"` - // The value of the charset parameter of the Content-Type header field, if present, or null if the header - // field is present but not of type text/*. + // The value of the `charset` parameter of the `Content-Type` header field, if present, or null if the header + // field is present but not of type `text/*`. // - // If there is no Content-Type header field, or it exists and is of type text/* but has no charset parameter, - // this is the implicit charset as per the MIME standard: us-ascii. + // If there is no `Content-Type` header field, or it exists and is of type `text/*` but has no `charset` parameter, + // this is the implicit charset as per the MIME standard: `us-ascii`. + // + // example: $attachmentCharset Charset string `json:"charset,omitempty"` - // The value of the Content-Disposition header field of the part, if present; + // The value of the `Content-Disposition` header field of the part, if present; // otherwise, it’s null. // - // CFWS is removed and any parameters are stripped. + // [CFWS] is removed and any parameters are stripped. + // + // [CFWS]: https://www.rfc-editor.org/rfc/rfc5322#section-3.2.2 + // + // example: $attachmentDisposition Disposition string `json:"disposition,omitempty"` - // The value of the Content-Id header field of the part, if present; otherwise it’s null. + // The value of the `Content-Id` header field of the part, if present; otherwise it’s null. // - // CFWS and surrounding angle brackets (<>) are removed. - // This may be used to reference the content from within a text/html body part HTML using the cid: protocol, as defined in [RFC2392]. + // [CFWS] and surrounding angle brackets (`<>`) are removed. + // + // This may be used to reference the content from within a `text/html` body part HTML using the `cid:` protocol, + // as defined in [RFC2392]. // // [RFC2392]: https://www.rfc-editor.org/rfc/rfc2392.html + // [CFWS]: https://www.rfc-editor.org/rfc/rfc5322#section-3.2.2 + // + // example: $attachmentCid Cid string `json:"cid,omitempty"` - // The list of language tags, as defined in [RFC3282], in the Content-Language header field of the part, if present. + // The list of language tags, as defined in [RFC3282], in the `Content-Language` header field of the part, + // if present. // // [RFC3282]: https://www.rfc-editor.org/rfc/rfc3282.html Language string `json:"language,omitempty"` - // The URI, as defined in [RFC2557], in the Content-Location header field of the part, if present. + // The URI, as defined in [RFC2557], in the `Content-Location` header field of the part, if present. // // [RFC2557]: https://www.rfc-editor.org/rfc/rfc2557.html Location string `json:"location,omitempty"` - // If the type is multipart/*, this contains the body parts of each child. + // If the type is `multipart/*`, this contains the body parts of each child. SubParts []EmailBodyPart `json:"subParts,omitempty"` } type EmailBodyValue struct { - // The value of the body part after decoding Content-Transfer-Encoding and the Content-Type charset, + // The value of the body part after decoding `Content-Transfer-Encoding` and the `Content-Type` charset, // if both known to the server, and with any CRLF replaced with a single LF. // // The server MAY use heuristics to determine the charset to use for decoding if the charset is unknown, // no charset is given, or it believes the charset given is incorrect. // - // Decoding is best effort; the server SHOULD insert the unicode replacement character (U+FFFD) and continue + // Decoding is best effort; the server SHOULD insert the unicode replacement character (`U+FFFD`) and continue // when a malformed section is encountered. // // Note that due to the charset decoding and line ending normalisation, the length of this string will @@ -1424,12 +1451,12 @@ type EmailBodyValue struct { type Email struct { // The id of the Email object. // - // Note that this is the JMAP object id, NOT the Message-ID header field value of the message [RFC5322]. + // Note that this is the JMAP object id, NOT the `Message-ID` header field value of the message [RFC5322]. // // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html // // required: true - // example: eaaaaab + // example: $emailId Id string `json:"id,omitempty"` // The id representing the raw octets of the message [RFC5322] for this Email. @@ -1438,12 +1465,12 @@ type Email struct { // // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html // - // example: cbbrzak0jw3gmtovgtwd1nd1p7p0czjlxx0ejgqws9oucgpuyr9fsayaae + // example: $blobId BlobId string `json:"blobId,omitempty"` // The id of the Thread to which this Email belongs. // - // example: b + // example: $threadId ThreadId string `json:"threadId,omitempty"` // The set of Mailbox ids this Email belongs to. @@ -1940,6 +1967,8 @@ type ObjectType string const ( VacationResponseType ObjectType = "VacationResponse" EmailType ObjectType = "Email" + EmailDeliveryType ObjectType = "EmailDelivery" + MailboxType ObjectType = "Mailbox" ) type Command string @@ -2756,6 +2785,33 @@ type SearchSnippetGetResponse struct { NotFound []string `json:"notFound,omitempty"` } +type StateChange struct { + // This MUST be the string "StateChange". + Type string `json:"@type"` + + // A map of an "account id" to an object encoding the state of data types that have + // changed for that account since the last StateChange object was pushed, for each + // of the accounts to which the user has access and for which something has changed. + // + // The value is a map. The keys are the type name "Foo" e.g., "Mailbox" or "Email"), + // and the value is the "state" property that would currently be returned by a call to + // "Foo/get". + // + // The client can compare the new state strings with its current values to see whether + // it has the current data for these types. If not, the changes can then be efficiently + // fetched in a single standard API request (using the /changes type methods). + Changed map[string]map[ObjectType]string `json:"changed"` + + // A (preferably short) string that encodes the entire server state visible to the user + // (not just the objects returned in this call). + // + // The purpose of the "pushState" token is to allow a client to immediately get any changes + // that occurred while it was disconnected. If the server does not support "pushState" tokens, + // the client will have to issue a series of "/changes" requests upon reconnection to update + // its state to match that of the server. + PushState string `json:"pushState"` +} + type ErrorResponse struct { Type string `json:"type"` Description string `json:"description,omitempty"` diff --git a/pkg/jmap/jmap_session.go b/pkg/jmap/jmap_session.go index b2d02bf90d..db20758d59 100644 --- a/pkg/jmap/jmap_session.go +++ b/pkg/jmap/jmap_session.go @@ -48,8 +48,9 @@ type Session struct { // An identifier of the DownloadUrlTemplate to use in metrics and tracing DownloadEndpoint string - WebsocketEndpoint *url.URL + WebsocketUrl *url.URL SupportsWebsocketPush bool + WebsocketEndpoint string SessionResponse } @@ -91,15 +92,17 @@ func newSession(sessionResponse SessionResponse) (Session, Error) { } downloadEndpoint := toEndpoint(downloadUrl) - var websocketEndpoint *url.URL = nil + var websocketUrl *url.URL = nil + websocketEndpoint := "" supportsWebsocketPush := false - websocketUrl := sessionResponse.Capabilities.Websocket.Url - if websocketUrl != "" { - websocketEndpoint, err = url.Parse(websocketUrl) + websocketUrlStr := sessionResponse.Capabilities.Websocket.Url + if websocketUrlStr != "" { + websocketUrl, err = url.Parse(websocketUrlStr) if err != nil { return Session{}, invalidSessionResponseErrorInvalidWebsocketUrl } supportsWebsocketPush = sessionResponse.Capabilities.Websocket.SupportsPush + websocketEndpoint = endpointOf(websocketUrl) } return Session{ @@ -110,8 +113,9 @@ func newSession(sessionResponse SessionResponse) (Session, Error) { UploadEndpoint: uploadEndpoint, DownloadUrlTemplate: downloadUrl, DownloadEndpoint: downloadEndpoint, - WebsocketEndpoint: websocketEndpoint, + WebsocketUrl: websocketUrl, SupportsWebsocketPush: supportsWebsocketPush, + WebsocketEndpoint: websocketEndpoint, SessionResponse: sessionResponse, }, nil } diff --git a/pkg/jmap/jmap_test.go b/pkg/jmap/jmap_test.go index c927bbf97a..88ac0f7857 100644 --- a/pkg/jmap/jmap_test.go +++ b/pkg/jmap/jmap_test.go @@ -100,6 +100,28 @@ func (h *TestJmapBlobClient) DownloadBinary(ctx context.Context, logger *log.Log }, nil } +func (t TestJmapBlobClient) Close() error { + return nil +} + +type TestWsClientFactory struct { + WsClientFactory +} + +var _ WsClientFactory = &TestWsClientFactory{} + +func NewTestWsClientFactory(t *testing.T) WsClientFactory { + return TestWsClientFactory{} +} + +func (t TestWsClientFactory) EnableNotifications(pushState string, sessionProvider func() (*Session, error), listener WsPushListener) (WsClient, Error) { + return nil, nil // TODO +} + +func (t TestWsClientFactory) Close() error { + return nil +} + func serveTestFile(t *testing.T, name string) ([]byte, Error) { cwd, _ := os.Getwd() p := filepath.Join(cwd, "testdata", name) @@ -147,9 +169,10 @@ func TestRequests(t *testing.T) { apiClient := NewTestJmapApiClient(t) wkClient := NewTestJmapWellKnownClient(t) blobClient := NewTestJmapBlobClient(t) + wsClientFactory := NewTestWsClientFactory(t) logger := log.NopLogger() ctx := context.Background() - client := NewClient(wkClient, apiClient, blobClient) + client := NewClient(wkClient, apiClient, blobClient, wsClientFactory) jmapUrl, err := url.Parse("http://localhost/jmap") require.NoError(err) diff --git a/services/groupware/api-examples.yaml b/services/groupware/api-examples.yaml new file mode 100644 index 0000000000..6729b24499 --- /dev/null +++ b/services/groupware/api-examples.yaml @@ -0,0 +1,69 @@ +examples: + refs: + accountId: 'a' + emailId: 'bmaaaaa2' + threadId: 'b' + mailboxIds: {"a": true} + emailKeywords: ["$seen", "$notjunk"] + emailSize: 3794 + emailReceivedAt: '2025-09-23T10:58:03Z' + emailSentAt: '2025-09-23T12:58:03+02:00' + blobId: 'cfz7vkmhcfwl1gfln02hga2fb3xwsqirirousda0rs1soeosla2p1aiaahcqjwaf' + attachmentName: 'Alloy_Yellow_Scale.pdf' + attachmentType: 'application/pdf' + attachmentSize: 192128 + attachmentDisposition: 'attachment' + attachmentPartId: '3' + attachmentCharset: 'utf-8' + attachmentCid: 'c1' + emailAddressName: 'Camina Drummer' + emailAddressEmail: 'drummer@opa.org' + emailSenders: + - name: 'Chrisjen Avasarala' + email: 'secgen@earth.gov' + emailFroms: + - name: 'Chrissie' + email: 'secgen@earth.gov' + emailTos: + - name: 'Camina Drummer' + email: 'drummer@opa.org' + emailCCs: + - name: 'Naomi Nagata' + email: 'nagata@opa.org' + - name: 'James Holden' + email: 'holden@earth.gov' + emailBCCs: + - name: 'Fred Johnson' + email: 'johnson@opa.org' + emailSubject: 'Food for thought' + emailPreview: >- + No one starts a war unless I say then can. + emailAttachments: + - partId: '2' + blobId: 'cfz7vkmhcfwl1gfln02hga2fb3xwsqirirousda0rs1soeosla2p1aiaahcqjwaf' + size: 1374 + type: 'application/pdf' + name: 'the_path.pdf' + disposition: 'attachment' + - partId: '3' + blobId: 'cnz7vkmhcfwl1gfln02hga2fb3xwsqirirousda0rs1soeosla2p1aiaahcq0wqo' + charset: 'utf-8' + size: 728 + type: 'text/plain' + name: 'secrets.txt' + disposition: 'attachment' + - partId: '4' + blobId: 'caqyey2wobo2bzjkkp2qlsn1ctitl02yylscnb77lc79nvubjihliaiadq' + size: 787545 + name: 'molecule-design.png' + type: 'image/png' + disposition: 'inline' + cid: 'c1' + inject: + Email: + size: $emailSize + EmailSummary: + size: $emailSize + EmailBodyPart: + size: $attachmentSize + diff --git a/services/groupware/apidoc-process.ts b/services/groupware/apidoc-process.ts index 809a3875a7..f1a19429a1 100644 --- a/services/groupware/apidoc-process.ts +++ b/services/groupware/apidoc-process.ts @@ -1,45 +1,152 @@ import * as fs from 'fs' import * as yaml from 'js-yaml' +const API_PARAMS_CONFIG_FILE = 'api-params.yaml' +const API_EXAMPLES_CONFIG_FILE = 'api-examples.yaml' + +interface Response { + $ref: string +} + +interface Parameter { + type: string + required: boolean + format: string + example: any + name: string + description: string + in: string +} + +interface VerbData { + tags: string[] + summary: string + description: string | undefined + operationId: string + parameters: Parameter[] + responses: {[status:string]:Response} +} + +interface Item { + $ref: string +} + +interface AdditionalProperties { + $ref: string +} + +interface Property { + description: string + type: string + items: Item + example: any + additionalProperties: AdditionalProperties +} + +interface Definition { + type: string + title: string + required: string[] + properties: {[property:string]:Property} +} + +interface OpenApi { + paths: {[path:string]:{[verb:string]:VerbData}} + definitions: {[type:string]:Definition} +} + interface Param { description: string type: string } -interface Config { +interface ParamsConfig { params: {[param:string]:Param} } +interface ExamplesConfigExamples { + refs: {[id:string]:any} + inject: {[id:string]:{[property:string]:any}} +} + +interface ExamplesConfig { + examples: ExamplesConfigExamples +} + let inputData = '' process.stdin.on('data', (chunk) => { inputData += chunk.toString() }) +const usedExamples = new Set() +const unresolvedExampleReferences = new Set() + process.stdin.on('end', () => { try { - const config = yaml.load(fs.readFileSync('api-params.yaml', 'utf8')) as Config - const params = config.params || {} + const paramsConfig = yaml.load(fs.readFileSync(API_PARAMS_CONFIG_FILE, 'utf8')) as ParamsConfig + const params = paramsConfig.params || {} - const data = yaml.load(inputData) as any + const examplesConfig = yaml.load(fs.readFileSync(API_EXAMPLES_CONFIG_FILE, 'utf8')) as ExamplesConfig + const exampleRefs = examplesConfig.examples.refs + const exampleInjects = examplesConfig.examples.inject + + const data = yaml.load(inputData) as OpenApi for (const path in data.paths) { + const pathData = data.paths[path] + for (const param in params) { if (path.includes(`{${param}}`)) { const paramsData = params[param] as Param - const pathData = data.paths[path] as any for (const verb in pathData) { - const verbData = pathData[verb] as any - if (!Object.getOwnPropertyNames(verbData).includes('parameters')) { - verbData.parameters = [] - } - verbData['parameters'].push({ + const verbData = pathData[verb] + verbData.parameters ??= [] + verbData.parameters.push({ name: param, required: true, - type: paramsData.type !== 'undefined' ? paramsData.type : 'string', + type: paramsData.type !== undefined ? paramsData.type : 'string', in: 'path', description: paramsData.description, - }) + } as Parameter) + } + } + } + + // do some magic with the formatting of endpoint descriptions: + for (const verb in pathData) { + const verbData = pathData[verb] + if (verbData.description !== null && verbData.description !== undefined) { + verbData.description = verbData.description.split("\n").map((line) => { + return line.replace(/^(\s*)!/, '$1*') + }).join("\n") + } + } + } + + for (const def in data.definitions) { + const defData = data.definitions[def] + if (defData.properties !== null && defData.properties !== undefined) { + const injects = exampleInjects[def] || {} + for (const prop in defData.properties as any) { + const propData = defData.properties[prop] + + const inject = injects[prop] + if (inject !== null && inject !== undefined) { + propData.example = inject + } + + if (propData.example !== null && propData.example !== undefined) { + if (typeof propData.example === 'string' && (propData.example as string).startsWith('$')) { + const exampleId = propData.example.substring(1) + const value = exampleRefs[exampleId] + if (value === null || value === undefined) { + unresolvedExampleReferences.add(exampleId) + } else { + usedExamples.add(exampleId) + propData.example = value + } + } } } } @@ -47,6 +154,28 @@ process.stdin.on('end', () => { process.stdout.write(yaml.dump(data)) process.stdout.write("\n") + + if (unresolvedExampleReferences.size > 0) { + console.error(`\x1b[33;1m⚠️ WARNING: unresolved example references not contained in ${API_PARAMS_CONFIG_FILE}:\x1b[0m`) + unresolvedExampleReferences.forEach(item => { + console.error(` - ${item}`) + }) + console.error() + } + + const unusedExampleReferences = new Set(Object.keys(exampleRefs)) + usedExamples.forEach(item => { + unusedExampleReferences.delete(item) + }) + + if (unusedExampleReferences.size > 0) { + console.error(`\x1b[33;1m⚠️ WARNING: unused examples in ${API_EXAMPLES_CONFIG_FILE}:\x1b[0m`) + unusedExampleReferences.forEach(item => { + console.error(` - ${item}`) + }) + console.error() + } + } catch (error) { if (error instanceof Error) { console.error(`Error occured while post-processing OpenAPI: ${error.message}`) diff --git a/services/groupware/package.json b/services/groupware/package.json index 085b7ba123..749d280df4 100644 --- a/services/groupware/package.json +++ b/services/groupware/package.json @@ -1,13 +1,13 @@ { "dependencies": { - "@redocly/cli": "^2.0.8", + "@redocly/cli": "^2.2.0", "@types/js-yaml": "^4.0.9", "cheerio": "^1.1.2", "js-yaml": "^4.1.0", "ts-node": "^10.9.2", "typescript": "^5.9.2" }, - "packageManager": "pnpm@10.15.1+sha512.34e538c329b5553014ca8e8f4535997f96180a1d0f614339357449935350d924e22f8614682191264ec33d1462ac21561aff97f6bb18065351c162c7e8f6de67", + "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a", "type": "module", "devDependencies": { "@types/node": "^24.3.1" diff --git a/services/groupware/pkg/config/config.go b/services/groupware/pkg/config/config.go index bcd75e8a4d..60dcdc2e2b 100644 --- a/services/groupware/pkg/config/config.go +++ b/services/groupware/pkg/config/config.go @@ -44,5 +44,6 @@ type Mail struct { DefaultEmailLimit uint `yaml:"default_email_limit" env:"GROUPWARE_DEFAULT_EMAIL_LIMIT"` MaxBodyValueBytes uint `yaml:"max_body_value_bytes" env:"GROUPWARE_MAX_BODY_VALUE_BYTES"` ResponseHeaderTimeout time.Duration `yaml:"response_header_timeout" env:"GROUPWARE_RESPONSE_HEADER_TIMEOUT"` + PushHandshakeTimeout time.Duration `yaml:"push_handshake_timeout" env:"GROUPWARE_PUSH_HANDSHAKE_TIMEOUT"` SessionCache MailSessionCache `yaml:"session_cache"` } diff --git a/services/groupware/pkg/config/defaults/defaultconfig.go b/services/groupware/pkg/config/defaults/defaultconfig.go index d40f26eac0..a5b8f444ba 100644 --- a/services/groupware/pkg/config/defaults/defaultconfig.go +++ b/services/groupware/pkg/config/defaults/defaultconfig.go @@ -34,6 +34,7 @@ func DefaultConfig() *config.Config { DefaultEmailLimit: uint(0), MaxBodyValueBytes: uint(0), ResponseHeaderTimeout: 10 * time.Second, + PushHandshakeTimeout: 10 * time.Second, SessionCache: config.MailSessionCache{ Ttl: 5 * time.Minute, FailureTtl: 15 * time.Second, diff --git a/services/groupware/pkg/groupware/groupware_api_messages.go b/services/groupware/pkg/groupware/groupware_api_emails.go similarity index 67% rename from services/groupware/pkg/groupware/groupware_api_messages.go rename to services/groupware/pkg/groupware/groupware_api_emails.go index 9ecdb09ff0..f676e717e3 100644 --- a/services/groupware/pkg/groupware/groupware_api_messages.go +++ b/services/groupware/pkg/groupware/groupware_api_emails.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "sort" "strconv" "strings" "time" @@ -14,6 +15,7 @@ import ( "github.com/opencloud-eu/opencloud/pkg/jmap" "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/pkg/structs" "github.com/opencloud-eu/opencloud/services/groupware/pkg/metrics" ) @@ -828,6 +830,321 @@ func (g *Groupware) RelatedToEmail(w http.ResponseWriter, r *http.Request) { }) } +type EmailSummary struct { + // The id of the account this Email summary pertains to. + // required: true + // example: $accountId + AccountId string `json:"accountId,omitempty"` + + // The id of the Email object. + // + // Note that this is the JMAP object id, NOT the Message-ID header field value of the message [RFC5322]. + // + // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html + // + // required: true + // example: $emailId + Id string `json:"id,omitempty"` + + // The id of the Thread to which this Email belongs. + // + // example: $threadId + ThreadId string `json:"threadId,omitempty"` + + // The set of Mailbox ids this Email belongs to. + // + // An Email in the mail store MUST belong to one or more Mailboxes at all times (until it is destroyed). + // The set is represented as an object, with each key being a Mailbox id. + // + // The value for each key in the object MUST be true. + // + // example: $mailboxIds + MailboxIds map[string]bool `json:"mailboxIds,omitempty"` + + // A set of keywords that apply to the Email. + // + // The set is represented as an object, with the keys being the keywords. + // + // The value for each key in the object MUST be true. + // + // Keywords are shared with IMAP. + // + // The six system keywords from IMAP get special treatment. + // + // The following four keywords have their first character changed from \ in IMAP to $ in JMAP and have particular semantic meaning: + // + // - $draft: The Email is a draft the user is composing. + // - $seen: The Email has been read. + // - $flagged: The Email has been flagged for urgent/special attention. + // - $answered: The Email has been replied to. + // + // The IMAP \Recent keyword is not exposed via JMAP. The IMAP \Deleted keyword is also not present: IMAP uses a delete+expunge model, + // which JMAP does not. Any message with the \Deleted keyword MUST NOT be visible via JMAP (and so are not counted in the + // “totalEmails”, “unreadEmails”, “totalThreads”, and “unreadThreads” Mailbox properties). + // + // Users may add arbitrary keywords to an Email. + // For compatibility with IMAP, a keyword is a case-insensitive string of 1–255 characters in the ASCII subset + // %x21–%x7e (excludes control chars and space), and it MUST NOT include any of these characters: + // + // ( ) { ] % * " \ + // + // Because JSON is case sensitive, servers MUST return keywords in lowercase. + // + // The [IMAP and JMAP Keywords] registry as established in [RFC5788] assigns semantic meaning to some other + // keywords in common use. + // + // New keywords may be established here in the future. In particular, note: + // + // - $forwarded: The Email has been forwarded. + // - $phishing: The Email is highly likely to be phishing. + // Clients SHOULD warn users to take care when viewing this Email and disable links and attachments. + // - $junk: The Email is definitely spam. + // Clients SHOULD set this flag when users report spam to help train automated spam-detection systems. + // - $notjunk: The Email is definitely not spam. + // Clients SHOULD set this flag when users indicate an Email is legitimate, to help train automated spam-detection systems. + // + // [IMAP and JMAP Keywords]: https://www.iana.org/assignments/imap-jmap-keywords/ + // [RFC5788]: https://www.rfc-editor.org/rfc/rfc5788.html + // + // example: $emailKeywords + Keywords map[string]bool `json:"keywords,omitempty"` + + // The size, in octets, of the raw data for the message [RFC5322] + // (as referenced by the blobId, i.e., the number of octets in the file the user would download). + // + // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html + Size int `json:"size"` + + // The date the Email was received by the message store. + // + // This is the internal date in IMAP [RFC3501]. + // + // [RFC3501]: https://www.rfc-editor.org/rfc/rfc3501.html + // + // example: $emailReceivedAt + ReceivedAt time.Time `json:"receivedAt,omitzero"` + + // The value is identical to the value of header:Sender:asAddresses. + // example: $emailSenders + Sender []jmap.EmailAddress `json:"sender,omitempty"` + + // The value is identical to the value of header:From:asAddresses. + // example: $emailFroms + From []jmap.EmailAddress `json:"from,omitempty"` + + // The value is identical to the value of header:To:asAddresses. + // example: $emailTos + To []jmap.EmailAddress `json:"to,omitempty"` + + // The value is identical to the value of header:Cc:asAddresses. + // example: $emailCCs + Cc []jmap.EmailAddress `json:"cc,omitempty"` + + // The value is identical to the value of header:Bcc:asAddresses. + // example: $emailBCCs + Bcc []jmap.EmailAddress `json:"bcc,omitempty"` + + // The value is identical to the value of header:Subject:asText. + // example: $emailSubject + Subject string `json:"subject,omitempty"` + + // The value is identical to the value of header:Date:asDate. + // example: $emailSentAt + SentAt time.Time `json:"sentAt,omitzero"` + + // This is true if there are one or more parts in the message that a client UI should offer as downloadable. + // + // A server SHOULD set hasAttachment to true if the attachments list contains at least one item that + // does not have Content-Disposition: inline. + // + // The server MAY ignore parts in this list that are processed automatically in some way or are referenced + // as embedded images in one of the text/html parts of the message. + // + // The server MAY set hasAttachment based on implementation-defined or site-configurable heuristics. + // example: true + HasAttachment bool `json:"hasAttachment,omitempty"` + + // A list, traversing depth-first, of all parts in bodyStructure. + // + // They must satisfy either of the following conditions: + // + // - not of type multipart/* and not included in textBody or htmlBody + // - of type image/*, audio/*, or video/* and not in both textBody and htmlBody + // + // None of these parts include subParts, including message/* types. + // + // Attached messages may be fetched using the Email/parse method and the blobId. + // + // Note that a text/html body part HTML may reference image parts in attachments by using cid: + // links to reference the Content-Id, as defined in [RFC2392], or by referencing the Content-Location. + // + // [RFC2392]: https://www.rfc-editor.org/rfc/rfc2392.html + // + // example: $emailAttachments + Attachments []jmap.EmailBodyPart `json:"attachments,omitempty"` + + // A plaintext fragment of the message body. + // + // This is intended to be shown as a preview line when listing messages in the mail store and may be truncated + // when shown. + // + // The server may choose which part of the message to include in the preview; skipping quoted sections and + // salutations and collapsing white space can result in a more useful preview. + // + // This MUST NOT be more than 256 characters in length. + // + // As this is derived from the message content by the server, and the algorithm for doing so could change over + // time, fetching this for an Email a second time MAY return a different result. + // However, the previous value is not considered incorrect, and the change SHOULD NOT cause the Email object + // to be considered as changed by the server. + // + // example: $emailPreview + Preview string `json:"preview,omitempty"` +} + +func summarizeEmail(accountId string, email jmap.Email) EmailSummary { + return EmailSummary{ + AccountId: accountId, + Id: email.Id, + ThreadId: email.ThreadId, + MailboxIds: email.MailboxIds, + Keywords: email.Keywords, + Size: email.Size, + ReceivedAt: email.ReceivedAt, + Sender: email.Sender, + From: email.From, + To: email.To, + Cc: email.Cc, + Bcc: email.Bcc, + Subject: email.Subject, + SentAt: email.SentAt, + HasAttachment: email.HasAttachment, + Attachments: email.Attachments, + Preview: email.Preview, + } +} + +type emailWithAccountId struct { + accountId string + email jmap.Email +} + +// When the request succeeds. +// swagger:response GetLatestEmailsSummaryForAllAccounts200 +type SwaggerGetLatestEmailsSummaryForAllAccounts200 struct { + // in: body + Body []EmailSummary +} + +// swagger:parameters get_latest_emails_summary_for_all_accounts +type SwaggerGetLatestEmailsSummaryForAllAccountsParams struct { + // in: query + // example: 10 + Limit uint `json:"limit"` + + // in: query + // example: true + Unread bool `json:"unread"` + + // in: query + // example: false + Undesirable bool `json:"undesirable"` +} + +// swagger:route GET /groupware/accounts/all/emails/latest/summary email get_latest_emails_summary_for_all_accounts +// Get a summary of the latest emails across all the mailboxes, across all of a user's accounts. +// +// Retrieves summaries of the latest emails of a user, in all accounts, across all mailboxes. +// +// The number of total summaries to retrieve is specified using the query parameter 'limit'. +// +// The following additional query parameters may be specified to further filter the emails to summarize: +// +// ! `unread`: when `true`, only unread emails will be summarized (default is to summarize all emails, read or unread) +// ! `undesirable`: when `true`, emails that are flagged as spam or phishing will also be summarized (default is to ignore those) +// +// responses: +// +// 200: GetLatestEmailsSummaryForAllAccounts200 +// 400: ErrorResponse400 +// 404: ErrorResponse404 +// 500: ErrorResponse500 +func (g *Groupware) GetLatestEmailsSummaryForAllAccounts(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) Response { + l := req.logger.With() + limit, ok, err := req.parseUIntParam(QueryParamLimit, 10) // TODO from configuration + if err != nil { + return errorResponse(err) + } + if ok { + l = l.Uint(QueryParamLimit, limit) + } + + unread, ok, err := req.parseBoolParam(QueryParamUnread, false) + if err != nil { + return errorResponse(err) + } + if ok { + l = l.Bool(QueryParamUnread, unread) + } + + undesirable, ok, err := req.parseBoolParam(QueryParamUndesirable, false) + if err != nil { + return errorResponse(err) + } + if ok { + l = l.Bool(QueryParamUndesirable, undesirable) + } + + var filter jmap.EmailFilterElement = nil // all emails, read and unread + { + notKeywords := []string{} + if unread { + notKeywords = append(notKeywords, jmap.JmapKeywordSeen) + } + if undesirable { + notKeywords = append(notKeywords, jmap.JmapKeywordJunk, jmap.JmapKeywordPhishing) + } + filter = filterFromNotKeywords(notKeywords) + } + + allAccountIds := structs.Keys(req.session.Accounts) // TODO(pbleser-oc) do we need a limit for a maximum amount of accounts to query at once? + l.Array(logAccountId, log.SafeStringArray(allAccountIds)) + + logger := log.From(l) + + emailsSummariesByAccount, sessionState, jerr := g.jmap.QueryEmailSummaries(allAccountIds, req.session, req.ctx, logger, filter, limit) + if jerr != nil { + return req.errorResponseFromJmap(jerr) + } + + // sort in memory to respect the overall limit + total := uint(0) + for _, emails := range emailsSummariesByAccount { + total += uint(max(len(emails.Emails), 0)) + } + all := make([]emailWithAccountId, total) + i := uint(0) + for accountId, emails := range emailsSummariesByAccount { + for _, email := range emails.Emails { + all[i] = emailWithAccountId{accountId: accountId, email: email} + i++ + } + } + + sort.Slice(all, func(i int, j int) bool { + return all[i].email.ReceivedAt.Before(all[j].email.ReceivedAt) + }) + + summaries := make([]EmailSummary, min(limit, total)) + for i = 0; i < limit && i < total; i++ { + summaries[i] = summarizeEmail(all[i].accountId, all[i].email) + } + + return response(summaries, sessionState) + }) +} + func filterEmails(all []jmap.Email, skip jmap.Email) []jmap.Email { filtered := all[:0] for _, email := range all { @@ -837,3 +1154,18 @@ func filterEmails(all []jmap.Email, skip jmap.Email) []jmap.Email { } return filtered } + +func filterFromNotKeywords(keywords []string) jmap.EmailFilterElement { + switch len(keywords) { + case 0: + return nil + case 1: + return jmap.EmailFilterCondition{NotKeyword: keywords[0]} + default: + conditions := make([]jmap.EmailFilterElement, len(keywords)) + for i, keyword := range keywords { + conditions[i] = jmap.EmailFilterCondition{NotKeyword: keyword} + } + return jmap.EmailFilterOperator{Operator: jmap.And, Conditions: conditions} + } +} diff --git a/services/groupware/pkg/groupware/groupware_framework.go b/services/groupware/pkg/groupware/groupware_framework.go index 0293408599..68ec68929c 100644 --- a/services/groupware/pkg/groupware/groupware_framework.go +++ b/services/groupware/pkg/groupware/groupware_framework.go @@ -12,6 +12,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/render" + "github.com/gorilla/websocket" "github.com/miekg/dns" "github.com/r3labs/sse/v2" "github.com/rs/zerolog" @@ -177,6 +178,7 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome sessionCacheMaxCapacity := uint64(max(config.Mail.SessionCache.MaxCapacity, 0)) sessionCacheTtl := max(config.Mail.SessionCache.Ttl, 0) sessionFailureCacheTtl := max(config.Mail.SessionCache.FailureTtl, 0) + wsHandshakeTimeout := config.Mail.PushHandshakeTimeout eventChannelSize := 100 // TODO make channel queue buffering size configurable workerQueueSize := 100 // TODO configuration setting @@ -195,7 +197,7 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome httpTransport := http.DefaultTransport.(*http.Transport).Clone() httpTransport.ResponseHeaderTimeout = responseHeaderTimeout if insecureTls { - tlsConfig := &tls.Config{InsecureSkipVerify: true} // TODO make configurable + tlsConfig := &tls.Config{InsecureSkipVerify: true} httpTransport.TLSClientConfig = tlsConfig } httpClient := *http.DefaultClient @@ -212,8 +214,21 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome jmapMetricsAdapter, ) + wsDialer := &websocket.Dialer{ + HandshakeTimeout: wsHandshakeTimeout, + } + if insecureTls { + wsDialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + + wsf, err := jmap.NewHttpWsClientFactory(wsDialer, masterUsername, masterPassword, logger) + if err != nil { + logger.Error().Err(err).Msg("failed to create websocket client") + return nil, GroupwareInitializationError{Message: "failed to create websocket client", Err: err} + } + // api implements all three interfaces: - jmapClient := jmap.NewClient(api, api, api) + jmapClient := jmap.NewClient(api, api, api, wsf) sessionCacheBuilder := newSessionCacheBuilder( sessionUrl, @@ -426,7 +441,7 @@ func (g *Groupware) ServeSSE(w http.ResponseWriter, r *http.Request) { } // Provide a JMAP Session for the -func (g *Groupware) session(user user, _ *http.Request, _ context.Context, _ *log.Logger) (jmap.Session, bool, *GroupwareError) { +func (g *Groupware) session(user user, _ *log.Logger) (jmap.Session, bool, *GroupwareError) { s := g.sessionCache.Get(user.GetUsername()) if s != nil { if s.Success() { @@ -482,6 +497,23 @@ func (g *Groupware) serveError(w http.ResponseWriter, r *http.Request, error *Er render.Render(w, r, errorResponses(*error)) } +func (g *Groupware) getSession(user user) (*jmap.Session, *GroupwareError) { + session, ok, gwerr := g.session(user, g.logger) + if gwerr != nil { + g.metrics.SessionFailureCounter.Inc() + g.logger.Error().Str("code", gwerr.Code).Str("error", gwerr.Title).Str("detail", gwerr.Detail).Msg("failed to determine JMAP session") + return nil, gwerr + } + if !ok { + // no session = authentication failed + gwerr = &ErrorInvalidAuthentication + g.metrics.SessionFailureCounter.Inc() + g.logger.Error().Msg("could not authenticate, failed to find Session") + return nil, gwerr + } + return &session, nil +} + func (g *Groupware) withSession(w http.ResponseWriter, r *http.Request, handler func(r Request) Response) (Response, bool) { ctx := r.Context() sl := g.logger.SubloggerWithRequestID(ctx) @@ -501,7 +533,7 @@ func (g *Groupware) withSession(w http.ResponseWriter, r *http.Request, handler logger = log.From(logger.With().Str(logUserId, log.SafeString(user.GetId()))) - session, ok, gwerr := g.session(user, r, ctx, logger) + session, ok, gwerr := g.session(user, logger) if gwerr != nil { g.metrics.SessionFailureCounter.Inc() errorId := errorId(r, ctx) @@ -606,7 +638,7 @@ func (g *Groupware) stream(w http.ResponseWriter, r *http.Request, handler func( logger = log.From(logger.With().Str(logUserId, log.SafeString(user.GetId()))) - session, ok, gwerr := g.session(user, r, ctx, logger) + session, ok, gwerr := g.session(user, logger) if gwerr != nil { errorId := errorId(r, ctx) logger.Error().Str("code", gwerr.Code).Str("error", gwerr.Title).Str("detail", gwerr.Detail).Str(logErrorId, errorId).Msg("failed to determine JMAP session") diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go index d7988bfb96..8f4ac26174 100644 --- a/services/groupware/pkg/groupware/groupware_route.go +++ b/services/groupware/pkg/groupware/groupware_route.go @@ -44,6 +44,8 @@ const ( QueryParamPartId = "partId" QueryParamAttachmentName = "name" QueryParamAttachmentBlobId = "blobId" + QueryParamUnread = "unread" + QueryParamUndesirable = "undesirable" HeaderSince = "if-none-match" ) @@ -57,6 +59,9 @@ func (g *Groupware) Route(r chi.Router) { r.Get("/roles", g.GetMailboxRoles) // ?role= r.Get("/roles/{role}", g.GetMailboxByRoleForAllAccounts) // ?role= }) + r.Route("/emails", func(r chi.Router) { + r.Get("/latest/summary", g.GetLatestEmailsSummaryForAllAccounts) + }) }) r.Route("/accounts/{accountid}", func(r chi.Router) { r.Get("/", g.GetAccount) diff --git a/services/groupware/pnpm-lock.yaml b/services/groupware/pnpm-lock.yaml index 7b1fc93388..389a3eb46a 100644 --- a/services/groupware/pnpm-lock.yaml +++ b/services/groupware/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@redocly/cli': - specifier: ^2.0.8 - version: 2.0.8(@opentelemetry/api@1.9.0)(ajv@8.17.1)(core-js@3.45.1) + specifier: ^2.2.0 + version: 2.2.0(@opentelemetry/api@1.9.0)(ajv@8.17.1)(core-js@3.45.1) '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -45,6 +45,10 @@ packages: resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -219,27 +223,27 @@ packages: '@redocly/ajv@8.11.3': resolution: {integrity: sha512-4P3iZse91TkBiY+Dx5DUgxQ9GXkVJf++cmI0MOyLDxV9b5MUBI4II6ES8zA5JCbO72nKAJxWrw4PUPW+YP3ZDQ==} - '@redocly/cli@2.0.8': - resolution: {integrity: sha512-FWBhB2hvF8rXWViCVgbKT3AmQH9ChRDNCmN3SyLRaL0GQ5SLi10p9rV26/7GktcMAKetM9XC4ETQQJMup3CVQQ==} + '@redocly/cli@2.2.0': + resolution: {integrity: sha512-3kXAcA7JLElZH206XqOiFcYtNlIhJeGFsopRypygO3W36sRvFO1d824jZeboYq9ROUaqij7nBCqBoBRKtldtDQ==} engines: {node: '>=22.12.0 || >=20.19.0 <21.0.0', npm: '>=10'} hasBin: true '@redocly/config@0.22.2': resolution: {integrity: sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==} - '@redocly/config@0.29.0': - resolution: {integrity: sha512-AkP1Berx9GvD15aN6r0IcOo289ElHp52XgeFTxXCumJ4gaUXUmvzqfZTfFFJWDaGgZvUZvmQrs1UdaPjZEXeHA==} + '@redocly/config@0.31.0': + resolution: {integrity: sha512-KPm2v//zj7qdGvClX0YqRNLQ9K7loVJWFEIceNxJIYPXP4hrhNvOLwjmxIkdkai0SdqYqogR2yjM/MjF9/AGdQ==} '@redocly/openapi-core@1.34.5': resolution: {integrity: sha512-0EbE8LRbkogtcCXU7liAyC00n9uNG9hJ+eMyHFdUsy9lB/WGqnEBgwjA9q2cyzAVcdTkQqTBBU1XePNnN3OijA==} engines: {node: '>=18.17.0', npm: '>=9.5.0'} - '@redocly/openapi-core@2.0.8': - resolution: {integrity: sha512-ShnpeEgcQY0YxopFDH/m94PWiHgCuWGa9FIcHchdHMZGA0mgV/Eojhi46A/hi2KP7TaVsgZc6+8u7GxTTeTC5g==} + '@redocly/openapi-core@2.2.0': + resolution: {integrity: sha512-gHedIv/1V5l7x1Nkb/kzRr8rlbGSqmDRs2AabO6BbV3cDofIwkRU7BJRFFNtjTq5LuLIWRTDYghfuIAG94/1Iw==} engines: {node: '>=22.12.0 || >=20.19.0 <21.0.0', npm: '>=10'} - '@redocly/respect-core@2.0.8': - resolution: {integrity: sha512-gKOjUn/UmoHYIDKHV7RanAvFkhQIgQh9oiA3qfYHMI/N4+tWGoJd03Soc+2bn3Zie8DTXjKwQIy0k5n2kZA7hw==} + '@redocly/respect-core@2.2.0': + resolution: {integrity: sha512-agpspN6zZWSjSrpdtAKiT8vVMNSs/T0pKRZfgj3ZSvB/asfenJDdDRhDc0h0w2LTYlbcsHEn+TWj4d5czvwlWg==} engines: {node: '>=22.12.0 || >=20.19.0 <21.0.0', npm: '>=10'} '@sinclair/typebox@0.27.8': @@ -1222,6 +1226,8 @@ snapshots: '@babel/runtime@7.28.3': {} + '@babel/runtime@7.28.4': {} + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -1391,14 +1397,14 @@ snapshots: require-from-string: 2.0.2 uri-js-replace: 1.0.1 - '@redocly/cli@2.0.8(@opentelemetry/api@1.9.0)(ajv@8.17.1)(core-js@3.45.1)': + '@redocly/cli@2.2.0(@opentelemetry/api@1.9.0)(ajv@8.17.1)(core-js@3.45.1)': dependencies: '@opentelemetry/exporter-trace-otlp-http': 0.202.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-node': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.34.0 - '@redocly/openapi-core': 2.0.8(ajv@8.17.1) - '@redocly/respect-core': 2.0.8(ajv@8.17.1) + '@redocly/openapi-core': 2.2.0(ajv@8.17.1) + '@redocly/respect-core': 2.2.0(ajv@8.17.1) abort-controller: 3.0.0 chokidar: 3.6.0 colorette: 1.4.0 @@ -1431,7 +1437,7 @@ snapshots: '@redocly/config@0.22.2': {} - '@redocly/config@0.29.0': + '@redocly/config@0.31.0': dependencies: json-schema-to-ts: 2.7.2 @@ -1449,10 +1455,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@redocly/openapi-core@2.0.8(ajv@8.17.1)': + '@redocly/openapi-core@2.2.0(ajv@8.17.1)': dependencies: '@redocly/ajv': 8.11.3 - '@redocly/config': 0.29.0 + '@redocly/config': 0.31.0 ajv-formats: 2.1.1(ajv@8.17.1) colorette: 1.4.0 js-levenshtein: 1.1.6 @@ -1463,15 +1469,14 @@ snapshots: transitivePeerDependencies: - ajv - '@redocly/respect-core@2.0.8(ajv@8.17.1)': + '@redocly/respect-core@2.2.0(ajv@8.17.1)': dependencies: '@faker-js/faker': 7.6.0 '@noble/hashes': 1.8.0 '@redocly/ajv': 8.11.2 - '@redocly/openapi-core': 2.0.8(ajv@8.17.1) + '@redocly/openapi-core': 2.2.0(ajv@8.17.1) better-ajv-errors: 1.2.0(ajv@8.17.1) colorette: 2.0.20 - jest-diff: 29.7.0 jest-matcher-utils: 29.7.0 json-pointer: 0.6.2 jsonpath-plus: 10.3.0 @@ -1921,7 +1926,7 @@ snapshots: json-schema-to-ts@2.7.2: dependencies: - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.28.4 '@types/json-schema': 7.0.15 ts-algebra: 1.2.2 diff --git a/services/groupware/pnpm-workspace.yaml b/services/groupware/pnpm-workspace.yaml new file mode 100644 index 0000000000..54168a08ef --- /dev/null +++ b/services/groupware/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +onlyBuiltDependencies: + - core-js + - protobufjs diff --git a/vendor/github.com/gorilla/websocket/.gitignore b/vendor/github.com/gorilla/websocket/.gitignore new file mode 100644 index 0000000000..cd3fcd1ef7 --- /dev/null +++ b/vendor/github.com/gorilla/websocket/.gitignore @@ -0,0 +1,25 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe + +.idea/ +*.iml diff --git a/vendor/github.com/gorilla/websocket/AUTHORS b/vendor/github.com/gorilla/websocket/AUTHORS new file mode 100644 index 0000000000..1931f40068 --- /dev/null +++ b/vendor/github.com/gorilla/websocket/AUTHORS @@ -0,0 +1,9 @@ +# This is the official list of Gorilla WebSocket authors for copyright +# purposes. +# +# Please keep the list sorted. + +Gary Burd +Google LLC (https://opensource.google.com/) +Joachim Bauch + diff --git a/vendor/github.com/gorilla/websocket/LICENSE b/vendor/github.com/gorilla/websocket/LICENSE new file mode 100644 index 0000000000..9171c97225 --- /dev/null +++ b/vendor/github.com/gorilla/websocket/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2013 The Gorilla WebSocket Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/gorilla/websocket/README.md b/vendor/github.com/gorilla/websocket/README.md new file mode 100644 index 0000000000..d33ed7fdd8 --- /dev/null +++ b/vendor/github.com/gorilla/websocket/README.md @@ -0,0 +1,33 @@ +# Gorilla WebSocket + +[![GoDoc](https://godoc.org/github.com/gorilla/websocket?status.svg)](https://godoc.org/github.com/gorilla/websocket) +[![CircleCI](https://circleci.com/gh/gorilla/websocket.svg?style=svg)](https://circleci.com/gh/gorilla/websocket) + +Gorilla WebSocket is a [Go](http://golang.org/) implementation of the +[WebSocket](http://www.rfc-editor.org/rfc/rfc6455.txt) protocol. + + +### Documentation + +* [API Reference](https://pkg.go.dev/github.com/gorilla/websocket?tab=doc) +* [Chat example](https://github.com/gorilla/websocket/tree/master/examples/chat) +* [Command example](https://github.com/gorilla/websocket/tree/master/examples/command) +* [Client and server example](https://github.com/gorilla/websocket/tree/master/examples/echo) +* [File watch example](https://github.com/gorilla/websocket/tree/master/examples/filewatch) + +### Status + +The Gorilla WebSocket package provides a complete and tested implementation of +the [WebSocket](http://www.rfc-editor.org/rfc/rfc6455.txt) protocol. The +package API is stable. + +### Installation + + go get github.com/gorilla/websocket + +### Protocol Compliance + +The Gorilla WebSocket package passes the server tests in the [Autobahn Test +Suite](https://github.com/crossbario/autobahn-testsuite) using the application in the [examples/autobahn +subdirectory](https://github.com/gorilla/websocket/tree/master/examples/autobahn). + diff --git a/vendor/github.com/gorilla/websocket/client.go b/vendor/github.com/gorilla/websocket/client.go new file mode 100644 index 0000000000..04fdafee18 --- /dev/null +++ b/vendor/github.com/gorilla/websocket/client.go @@ -0,0 +1,434 @@ +// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "io/ioutil" + "net" + "net/http" + "net/http/httptrace" + "net/url" + "strings" + "time" +) + +// ErrBadHandshake is returned when the server response to opening handshake is +// invalid. +var ErrBadHandshake = errors.New("websocket: bad handshake") + +var errInvalidCompression = errors.New("websocket: invalid compression negotiation") + +// NewClient creates a new client connection using the given net connection. +// The URL u specifies the host and request URI. Use requestHeader to specify +// the origin (Origin), subprotocols (Sec-WebSocket-Protocol) and cookies +// (Cookie). Use the response.Header to get the selected subprotocol +// (Sec-WebSocket-Protocol) and cookies (Set-Cookie). +// +// If the WebSocket handshake fails, ErrBadHandshake is returned along with a +// non-nil *http.Response so that callers can handle redirects, authentication, +// etc. +// +// Deprecated: Use Dialer instead. +func NewClient(netConn net.Conn, u *url.URL, requestHeader http.Header, readBufSize, writeBufSize int) (c *Conn, response *http.Response, err error) { + d := Dialer{ + ReadBufferSize: readBufSize, + WriteBufferSize: writeBufSize, + NetDial: func(net, addr string) (net.Conn, error) { + return netConn, nil + }, + } + return d.Dial(u.String(), requestHeader) +} + +// A Dialer contains options for connecting to WebSocket server. +// +// It is safe to call Dialer's methods concurrently. +type Dialer struct { + // NetDial specifies the dial function for creating TCP connections. If + // NetDial is nil, net.Dial is used. + NetDial func(network, addr string) (net.Conn, error) + + // NetDialContext specifies the dial function for creating TCP connections. If + // NetDialContext is nil, NetDial is used. + NetDialContext func(ctx context.Context, network, addr string) (net.Conn, error) + + // NetDialTLSContext specifies the dial function for creating TLS/TCP connections. If + // NetDialTLSContext is nil, NetDialContext is used. + // If NetDialTLSContext is set, Dial assumes the TLS handshake is done there and + // TLSClientConfig is ignored. + NetDialTLSContext func(ctx context.Context, network, addr string) (net.Conn, error) + + // Proxy specifies a function to return a proxy for a given + // Request. If the function returns a non-nil error, the + // request is aborted with the provided error. + // If Proxy is nil or returns a nil *URL, no proxy is used. + Proxy func(*http.Request) (*url.URL, error) + + // TLSClientConfig specifies the TLS configuration to use with tls.Client. + // If nil, the default configuration is used. + // If either NetDialTLS or NetDialTLSContext are set, Dial assumes the TLS handshake + // is done there and TLSClientConfig is ignored. + TLSClientConfig *tls.Config + + // HandshakeTimeout specifies the duration for the handshake to complete. + HandshakeTimeout time.Duration + + // ReadBufferSize and WriteBufferSize specify I/O buffer sizes in bytes. If a buffer + // size is zero, then a useful default size is used. The I/O buffer sizes + // do not limit the size of the messages that can be sent or received. + ReadBufferSize, WriteBufferSize int + + // WriteBufferPool is a pool of buffers for write operations. If the value + // is not set, then write buffers are allocated to the connection for the + // lifetime of the connection. + // + // A pool is most useful when the application has a modest volume of writes + // across a large number of connections. + // + // Applications should use a single pool for each unique value of + // WriteBufferSize. + WriteBufferPool BufferPool + + // Subprotocols specifies the client's requested subprotocols. + Subprotocols []string + + // EnableCompression specifies if the client should attempt to negotiate + // per message compression (RFC 7692). Setting this value to true does not + // guarantee that compression will be supported. Currently only "no context + // takeover" modes are supported. + EnableCompression bool + + // Jar specifies the cookie jar. + // If Jar is nil, cookies are not sent in requests and ignored + // in responses. + Jar http.CookieJar +} + +// Dial creates a new client connection by calling DialContext with a background context. +func (d *Dialer) Dial(urlStr string, requestHeader http.Header) (*Conn, *http.Response, error) { + return d.DialContext(context.Background(), urlStr, requestHeader) +} + +var errMalformedURL = errors.New("malformed ws or wss URL") + +func hostPortNoPort(u *url.URL) (hostPort, hostNoPort string) { + hostPort = u.Host + hostNoPort = u.Host + if i := strings.LastIndex(u.Host, ":"); i > strings.LastIndex(u.Host, "]") { + hostNoPort = hostNoPort[:i] + } else { + switch u.Scheme { + case "wss": + hostPort += ":443" + case "https": + hostPort += ":443" + default: + hostPort += ":80" + } + } + return hostPort, hostNoPort +} + +// DefaultDialer is a dialer with all fields set to the default values. +var DefaultDialer = &Dialer{ + Proxy: http.ProxyFromEnvironment, + HandshakeTimeout: 45 * time.Second, +} + +// nilDialer is dialer to use when receiver is nil. +var nilDialer = *DefaultDialer + +// DialContext creates a new client connection. Use requestHeader to specify the +// origin (Origin), subprotocols (Sec-WebSocket-Protocol) and cookies (Cookie). +// Use the response.Header to get the selected subprotocol +// (Sec-WebSocket-Protocol) and cookies (Set-Cookie). +// +// The context will be used in the request and in the Dialer. +// +// If the WebSocket handshake fails, ErrBadHandshake is returned along with a +// non-nil *http.Response so that callers can handle redirects, authentication, +// etcetera. The response body may not contain the entire response and does not +// need to be closed by the application. +func (d *Dialer) DialContext(ctx context.Context, urlStr string, requestHeader http.Header) (*Conn, *http.Response, error) { + if d == nil { + d = &nilDialer + } + + challengeKey, err := generateChallengeKey() + if err != nil { + return nil, nil, err + } + + u, err := url.Parse(urlStr) + if err != nil { + return nil, nil, err + } + + switch u.Scheme { + case "ws": + u.Scheme = "http" + case "wss": + u.Scheme = "https" + default: + return nil, nil, errMalformedURL + } + + if u.User != nil { + // User name and password are not allowed in websocket URIs. + return nil, nil, errMalformedURL + } + + req := &http.Request{ + Method: http.MethodGet, + URL: u, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: make(http.Header), + Host: u.Host, + } + req = req.WithContext(ctx) + + // Set the cookies present in the cookie jar of the dialer + if d.Jar != nil { + for _, cookie := range d.Jar.Cookies(u) { + req.AddCookie(cookie) + } + } + + // Set the request headers using the capitalization for names and values in + // RFC examples. Although the capitalization shouldn't matter, there are + // servers that depend on it. The Header.Set method is not used because the + // method canonicalizes the header names. + req.Header["Upgrade"] = []string{"websocket"} + req.Header["Connection"] = []string{"Upgrade"} + req.Header["Sec-WebSocket-Key"] = []string{challengeKey} + req.Header["Sec-WebSocket-Version"] = []string{"13"} + if len(d.Subprotocols) > 0 { + req.Header["Sec-WebSocket-Protocol"] = []string{strings.Join(d.Subprotocols, ", ")} + } + for k, vs := range requestHeader { + switch { + case k == "Host": + if len(vs) > 0 { + req.Host = vs[0] + } + case k == "Upgrade" || + k == "Connection" || + k == "Sec-Websocket-Key" || + k == "Sec-Websocket-Version" || + k == "Sec-Websocket-Extensions" || + (k == "Sec-Websocket-Protocol" && len(d.Subprotocols) > 0): + return nil, nil, errors.New("websocket: duplicate header not allowed: " + k) + case k == "Sec-Websocket-Protocol": + req.Header["Sec-WebSocket-Protocol"] = vs + default: + req.Header[k] = vs + } + } + + if d.EnableCompression { + req.Header["Sec-WebSocket-Extensions"] = []string{"permessage-deflate; server_no_context_takeover; client_no_context_takeover"} + } + + if d.HandshakeTimeout != 0 { + var cancel func() + ctx, cancel = context.WithTimeout(ctx, d.HandshakeTimeout) + defer cancel() + } + + // Get network dial function. + var netDial func(network, add string) (net.Conn, error) + + switch u.Scheme { + case "http": + if d.NetDialContext != nil { + netDial = func(network, addr string) (net.Conn, error) { + return d.NetDialContext(ctx, network, addr) + } + } else if d.NetDial != nil { + netDial = d.NetDial + } + case "https": + if d.NetDialTLSContext != nil { + netDial = func(network, addr string) (net.Conn, error) { + return d.NetDialTLSContext(ctx, network, addr) + } + } else if d.NetDialContext != nil { + netDial = func(network, addr string) (net.Conn, error) { + return d.NetDialContext(ctx, network, addr) + } + } else if d.NetDial != nil { + netDial = d.NetDial + } + default: + return nil, nil, errMalformedURL + } + + if netDial == nil { + netDialer := &net.Dialer{} + netDial = func(network, addr string) (net.Conn, error) { + return netDialer.DialContext(ctx, network, addr) + } + } + + // If needed, wrap the dial function to set the connection deadline. + if deadline, ok := ctx.Deadline(); ok { + forwardDial := netDial + netDial = func(network, addr string) (net.Conn, error) { + c, err := forwardDial(network, addr) + if err != nil { + return nil, err + } + err = c.SetDeadline(deadline) + if err != nil { + c.Close() + return nil, err + } + return c, nil + } + } + + // If needed, wrap the dial function to connect through a proxy. + if d.Proxy != nil { + proxyURL, err := d.Proxy(req) + if err != nil { + return nil, nil, err + } + if proxyURL != nil { + dialer, err := proxy_FromURL(proxyURL, netDialerFunc(netDial)) + if err != nil { + return nil, nil, err + } + netDial = dialer.Dial + } + } + + hostPort, hostNoPort := hostPortNoPort(u) + trace := httptrace.ContextClientTrace(ctx) + if trace != nil && trace.GetConn != nil { + trace.GetConn(hostPort) + } + + netConn, err := netDial("tcp", hostPort) + if err != nil { + return nil, nil, err + } + if trace != nil && trace.GotConn != nil { + trace.GotConn(httptrace.GotConnInfo{ + Conn: netConn, + }) + } + + defer func() { + if netConn != nil { + netConn.Close() + } + }() + + if u.Scheme == "https" && d.NetDialTLSContext == nil { + // If NetDialTLSContext is set, assume that the TLS handshake has already been done + + cfg := cloneTLSConfig(d.TLSClientConfig) + if cfg.ServerName == "" { + cfg.ServerName = hostNoPort + } + tlsConn := tls.Client(netConn, cfg) + netConn = tlsConn + + if trace != nil && trace.TLSHandshakeStart != nil { + trace.TLSHandshakeStart() + } + err := doHandshake(ctx, tlsConn, cfg) + if trace != nil && trace.TLSHandshakeDone != nil { + trace.TLSHandshakeDone(tlsConn.ConnectionState(), err) + } + + if err != nil { + return nil, nil, err + } + } + + conn := newConn(netConn, false, d.ReadBufferSize, d.WriteBufferSize, d.WriteBufferPool, nil, nil) + + if err := req.Write(netConn); err != nil { + return nil, nil, err + } + + if trace != nil && trace.GotFirstResponseByte != nil { + if peek, err := conn.br.Peek(1); err == nil && len(peek) == 1 { + trace.GotFirstResponseByte() + } + } + + resp, err := http.ReadResponse(conn.br, req) + if err != nil { + if d.TLSClientConfig != nil { + for _, proto := range d.TLSClientConfig.NextProtos { + if proto != "http/1.1" { + return nil, nil, fmt.Errorf( + "websocket: protocol %q was given but is not supported;"+ + "sharing tls.Config with net/http Transport can cause this error: %w", + proto, err, + ) + } + } + } + return nil, nil, err + } + + if d.Jar != nil { + if rc := resp.Cookies(); len(rc) > 0 { + d.Jar.SetCookies(u, rc) + } + } + + if resp.StatusCode != 101 || + !tokenListContainsValue(resp.Header, "Upgrade", "websocket") || + !tokenListContainsValue(resp.Header, "Connection", "upgrade") || + resp.Header.Get("Sec-Websocket-Accept") != computeAcceptKey(challengeKey) { + // Before closing the network connection on return from this + // function, slurp up some of the response to aid application + // debugging. + buf := make([]byte, 1024) + n, _ := io.ReadFull(resp.Body, buf) + resp.Body = ioutil.NopCloser(bytes.NewReader(buf[:n])) + return nil, resp, ErrBadHandshake + } + + for _, ext := range parseExtensions(resp.Header) { + if ext[""] != "permessage-deflate" { + continue + } + _, snct := ext["server_no_context_takeover"] + _, cnct := ext["client_no_context_takeover"] + if !snct || !cnct { + return nil, resp, errInvalidCompression + } + conn.newCompressionWriter = compressNoContextTakeover + conn.newDecompressionReader = decompressNoContextTakeover + break + } + + resp.Body = ioutil.NopCloser(bytes.NewReader([]byte{})) + conn.subprotocol = resp.Header.Get("Sec-Websocket-Protocol") + + netConn.SetDeadline(time.Time{}) + netConn = nil // to avoid close in defer. + return conn, resp, nil +} + +func cloneTLSConfig(cfg *tls.Config) *tls.Config { + if cfg == nil { + return &tls.Config{} + } + return cfg.Clone() +} diff --git a/vendor/github.com/gorilla/websocket/compression.go b/vendor/github.com/gorilla/websocket/compression.go new file mode 100644 index 0000000000..813ffb1e84 --- /dev/null +++ b/vendor/github.com/gorilla/websocket/compression.go @@ -0,0 +1,148 @@ +// Copyright 2017 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "compress/flate" + "errors" + "io" + "strings" + "sync" +) + +const ( + minCompressionLevel = -2 // flate.HuffmanOnly not defined in Go < 1.6 + maxCompressionLevel = flate.BestCompression + defaultCompressionLevel = 1 +) + +var ( + flateWriterPools [maxCompressionLevel - minCompressionLevel + 1]sync.Pool + flateReaderPool = sync.Pool{New: func() interface{} { + return flate.NewReader(nil) + }} +) + +func decompressNoContextTakeover(r io.Reader) io.ReadCloser { + const tail = + // Add four bytes as specified in RFC + "\x00\x00\xff\xff" + + // Add final block to squelch unexpected EOF error from flate reader. + "\x01\x00\x00\xff\xff" + + fr, _ := flateReaderPool.Get().(io.ReadCloser) + fr.(flate.Resetter).Reset(io.MultiReader(r, strings.NewReader(tail)), nil) + return &flateReadWrapper{fr} +} + +func isValidCompressionLevel(level int) bool { + return minCompressionLevel <= level && level <= maxCompressionLevel +} + +func compressNoContextTakeover(w io.WriteCloser, level int) io.WriteCloser { + p := &flateWriterPools[level-minCompressionLevel] + tw := &truncWriter{w: w} + fw, _ := p.Get().(*flate.Writer) + if fw == nil { + fw, _ = flate.NewWriter(tw, level) + } else { + fw.Reset(tw) + } + return &flateWriteWrapper{fw: fw, tw: tw, p: p} +} + +// truncWriter is an io.Writer that writes all but the last four bytes of the +// stream to another io.Writer. +type truncWriter struct { + w io.WriteCloser + n int + p [4]byte +} + +func (w *truncWriter) Write(p []byte) (int, error) { + n := 0 + + // fill buffer first for simplicity. + if w.n < len(w.p) { + n = copy(w.p[w.n:], p) + p = p[n:] + w.n += n + if len(p) == 0 { + return n, nil + } + } + + m := len(p) + if m > len(w.p) { + m = len(w.p) + } + + if nn, err := w.w.Write(w.p[:m]); err != nil { + return n + nn, err + } + + copy(w.p[:], w.p[m:]) + copy(w.p[len(w.p)-m:], p[len(p)-m:]) + nn, err := w.w.Write(p[:len(p)-m]) + return n + nn, err +} + +type flateWriteWrapper struct { + fw *flate.Writer + tw *truncWriter + p *sync.Pool +} + +func (w *flateWriteWrapper) Write(p []byte) (int, error) { + if w.fw == nil { + return 0, errWriteClosed + } + return w.fw.Write(p) +} + +func (w *flateWriteWrapper) Close() error { + if w.fw == nil { + return errWriteClosed + } + err1 := w.fw.Flush() + w.p.Put(w.fw) + w.fw = nil + if w.tw.p != [4]byte{0, 0, 0xff, 0xff} { + return errors.New("websocket: internal error, unexpected bytes at end of flate stream") + } + err2 := w.tw.w.Close() + if err1 != nil { + return err1 + } + return err2 +} + +type flateReadWrapper struct { + fr io.ReadCloser +} + +func (r *flateReadWrapper) Read(p []byte) (int, error) { + if r.fr == nil { + return 0, io.ErrClosedPipe + } + n, err := r.fr.Read(p) + if err == io.EOF { + // Preemptively place the reader back in the pool. This helps with + // scenarios where the application does not call NextReader() soon after + // this final read. + r.Close() + } + return n, err +} + +func (r *flateReadWrapper) Close() error { + if r.fr == nil { + return io.ErrClosedPipe + } + err := r.fr.Close() + flateReaderPool.Put(r.fr) + r.fr = nil + return err +} diff --git a/vendor/github.com/gorilla/websocket/conn.go b/vendor/github.com/gorilla/websocket/conn.go new file mode 100644 index 0000000000..5161ef81f6 --- /dev/null +++ b/vendor/github.com/gorilla/websocket/conn.go @@ -0,0 +1,1238 @@ +// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "bufio" + "encoding/binary" + "errors" + "io" + "io/ioutil" + "math/rand" + "net" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" +) + +const ( + // Frame header byte 0 bits from Section 5.2 of RFC 6455 + finalBit = 1 << 7 + rsv1Bit = 1 << 6 + rsv2Bit = 1 << 5 + rsv3Bit = 1 << 4 + + // Frame header byte 1 bits from Section 5.2 of RFC 6455 + maskBit = 1 << 7 + + maxFrameHeaderSize = 2 + 8 + 4 // Fixed header + length + mask + maxControlFramePayloadSize = 125 + + writeWait = time.Second + + defaultReadBufferSize = 4096 + defaultWriteBufferSize = 4096 + + continuationFrame = 0 + noFrame = -1 +) + +// Close codes defined in RFC 6455, section 11.7. +const ( + CloseNormalClosure = 1000 + CloseGoingAway = 1001 + CloseProtocolError = 1002 + CloseUnsupportedData = 1003 + CloseNoStatusReceived = 1005 + CloseAbnormalClosure = 1006 + CloseInvalidFramePayloadData = 1007 + ClosePolicyViolation = 1008 + CloseMessageTooBig = 1009 + CloseMandatoryExtension = 1010 + CloseInternalServerErr = 1011 + CloseServiceRestart = 1012 + CloseTryAgainLater = 1013 + CloseTLSHandshake = 1015 +) + +// The message types are defined in RFC 6455, section 11.8. +const ( + // TextMessage denotes a text data message. The text message payload is + // interpreted as UTF-8 encoded text data. + TextMessage = 1 + + // BinaryMessage denotes a binary data message. + BinaryMessage = 2 + + // CloseMessage denotes a close control message. The optional message + // payload contains a numeric code and text. Use the FormatCloseMessage + // function to format a close message payload. + CloseMessage = 8 + + // PingMessage denotes a ping control message. The optional message payload + // is UTF-8 encoded text. + PingMessage = 9 + + // PongMessage denotes a pong control message. The optional message payload + // is UTF-8 encoded text. + PongMessage = 10 +) + +// ErrCloseSent is returned when the application writes a message to the +// connection after sending a close message. +var ErrCloseSent = errors.New("websocket: close sent") + +// ErrReadLimit is returned when reading a message that is larger than the +// read limit set for the connection. +var ErrReadLimit = errors.New("websocket: read limit exceeded") + +// netError satisfies the net Error interface. +type netError struct { + msg string + temporary bool + timeout bool +} + +func (e *netError) Error() string { return e.msg } +func (e *netError) Temporary() bool { return e.temporary } +func (e *netError) Timeout() bool { return e.timeout } + +// CloseError represents a close message. +type CloseError struct { + // Code is defined in RFC 6455, section 11.7. + Code int + + // Text is the optional text payload. + Text string +} + +func (e *CloseError) Error() string { + s := []byte("websocket: close ") + s = strconv.AppendInt(s, int64(e.Code), 10) + switch e.Code { + case CloseNormalClosure: + s = append(s, " (normal)"...) + case CloseGoingAway: + s = append(s, " (going away)"...) + case CloseProtocolError: + s = append(s, " (protocol error)"...) + case CloseUnsupportedData: + s = append(s, " (unsupported data)"...) + case CloseNoStatusReceived: + s = append(s, " (no status)"...) + case CloseAbnormalClosure: + s = append(s, " (abnormal closure)"...) + case CloseInvalidFramePayloadData: + s = append(s, " (invalid payload data)"...) + case ClosePolicyViolation: + s = append(s, " (policy violation)"...) + case CloseMessageTooBig: + s = append(s, " (message too big)"...) + case CloseMandatoryExtension: + s = append(s, " (mandatory extension missing)"...) + case CloseInternalServerErr: + s = append(s, " (internal server error)"...) + case CloseTLSHandshake: + s = append(s, " (TLS handshake error)"...) + } + if e.Text != "" { + s = append(s, ": "...) + s = append(s, e.Text...) + } + return string(s) +} + +// IsCloseError returns boolean indicating whether the error is a *CloseError +// with one of the specified codes. +func IsCloseError(err error, codes ...int) bool { + if e, ok := err.(*CloseError); ok { + for _, code := range codes { + if e.Code == code { + return true + } + } + } + return false +} + +// IsUnexpectedCloseError returns boolean indicating whether the error is a +// *CloseError with a code not in the list of expected codes. +func IsUnexpectedCloseError(err error, expectedCodes ...int) bool { + if e, ok := err.(*CloseError); ok { + for _, code := range expectedCodes { + if e.Code == code { + return false + } + } + return true + } + return false +} + +var ( + errWriteTimeout = &netError{msg: "websocket: write timeout", timeout: true, temporary: true} + errUnexpectedEOF = &CloseError{Code: CloseAbnormalClosure, Text: io.ErrUnexpectedEOF.Error()} + errBadWriteOpCode = errors.New("websocket: bad write message type") + errWriteClosed = errors.New("websocket: write closed") + errInvalidControlFrame = errors.New("websocket: invalid control frame") +) + +func newMaskKey() [4]byte { + n := rand.Uint32() + return [4]byte{byte(n), byte(n >> 8), byte(n >> 16), byte(n >> 24)} +} + +func hideTempErr(err error) error { + if e, ok := err.(net.Error); ok && e.Temporary() { + err = &netError{msg: e.Error(), timeout: e.Timeout()} + } + return err +} + +func isControl(frameType int) bool { + return frameType == CloseMessage || frameType == PingMessage || frameType == PongMessage +} + +func isData(frameType int) bool { + return frameType == TextMessage || frameType == BinaryMessage +} + +var validReceivedCloseCodes = map[int]bool{ + // see http://www.iana.org/assignments/websocket/websocket.xhtml#close-code-number + + CloseNormalClosure: true, + CloseGoingAway: true, + CloseProtocolError: true, + CloseUnsupportedData: true, + CloseNoStatusReceived: false, + CloseAbnormalClosure: false, + CloseInvalidFramePayloadData: true, + ClosePolicyViolation: true, + CloseMessageTooBig: true, + CloseMandatoryExtension: true, + CloseInternalServerErr: true, + CloseServiceRestart: true, + CloseTryAgainLater: true, + CloseTLSHandshake: false, +} + +func isValidReceivedCloseCode(code int) bool { + return validReceivedCloseCodes[code] || (code >= 3000 && code <= 4999) +} + +// BufferPool represents a pool of buffers. The *sync.Pool type satisfies this +// interface. The type of the value stored in a pool is not specified. +type BufferPool interface { + // Get gets a value from the pool or returns nil if the pool is empty. + Get() interface{} + // Put adds a value to the pool. + Put(interface{}) +} + +// writePoolData is the type added to the write buffer pool. This wrapper is +// used to prevent applications from peeking at and depending on the values +// added to the pool. +type writePoolData struct{ buf []byte } + +// The Conn type represents a WebSocket connection. +type Conn struct { + conn net.Conn + isServer bool + subprotocol string + + // Write fields + mu chan struct{} // used as mutex to protect write to conn + writeBuf []byte // frame is constructed in this buffer. + writePool BufferPool + writeBufSize int + writeDeadline time.Time + writer io.WriteCloser // the current writer returned to the application + isWriting bool // for best-effort concurrent write detection + + writeErrMu sync.Mutex + writeErr error + + enableWriteCompression bool + compressionLevel int + newCompressionWriter func(io.WriteCloser, int) io.WriteCloser + + // Read fields + reader io.ReadCloser // the current reader returned to the application + readErr error + br *bufio.Reader + // bytes remaining in current frame. + // set setReadRemaining to safely update this value and prevent overflow + readRemaining int64 + readFinal bool // true the current message has more frames. + readLength int64 // Message size. + readLimit int64 // Maximum message size. + readMaskPos int + readMaskKey [4]byte + handlePong func(string) error + handlePing func(string) error + handleClose func(int, string) error + readErrCount int + messageReader *messageReader // the current low-level reader + + readDecompress bool // whether last read frame had RSV1 set + newDecompressionReader func(io.Reader) io.ReadCloser +} + +func newConn(conn net.Conn, isServer bool, readBufferSize, writeBufferSize int, writeBufferPool BufferPool, br *bufio.Reader, writeBuf []byte) *Conn { + + if br == nil { + if readBufferSize == 0 { + readBufferSize = defaultReadBufferSize + } else if readBufferSize < maxControlFramePayloadSize { + // must be large enough for control frame + readBufferSize = maxControlFramePayloadSize + } + br = bufio.NewReaderSize(conn, readBufferSize) + } + + if writeBufferSize <= 0 { + writeBufferSize = defaultWriteBufferSize + } + writeBufferSize += maxFrameHeaderSize + + if writeBuf == nil && writeBufferPool == nil { + writeBuf = make([]byte, writeBufferSize) + } + + mu := make(chan struct{}, 1) + mu <- struct{}{} + c := &Conn{ + isServer: isServer, + br: br, + conn: conn, + mu: mu, + readFinal: true, + writeBuf: writeBuf, + writePool: writeBufferPool, + writeBufSize: writeBufferSize, + enableWriteCompression: true, + compressionLevel: defaultCompressionLevel, + } + c.SetCloseHandler(nil) + c.SetPingHandler(nil) + c.SetPongHandler(nil) + return c +} + +// setReadRemaining tracks the number of bytes remaining on the connection. If n +// overflows, an ErrReadLimit is returned. +func (c *Conn) setReadRemaining(n int64) error { + if n < 0 { + return ErrReadLimit + } + + c.readRemaining = n + return nil +} + +// Subprotocol returns the negotiated protocol for the connection. +func (c *Conn) Subprotocol() string { + return c.subprotocol +} + +// Close closes the underlying network connection without sending or waiting +// for a close message. +func (c *Conn) Close() error { + return c.conn.Close() +} + +// LocalAddr returns the local network address. +func (c *Conn) LocalAddr() net.Addr { + return c.conn.LocalAddr() +} + +// RemoteAddr returns the remote network address. +func (c *Conn) RemoteAddr() net.Addr { + return c.conn.RemoteAddr() +} + +// Write methods + +func (c *Conn) writeFatal(err error) error { + err = hideTempErr(err) + c.writeErrMu.Lock() + if c.writeErr == nil { + c.writeErr = err + } + c.writeErrMu.Unlock() + return err +} + +func (c *Conn) read(n int) ([]byte, error) { + p, err := c.br.Peek(n) + if err == io.EOF { + err = errUnexpectedEOF + } + c.br.Discard(len(p)) + return p, err +} + +func (c *Conn) write(frameType int, deadline time.Time, buf0, buf1 []byte) error { + <-c.mu + defer func() { c.mu <- struct{}{} }() + + c.writeErrMu.Lock() + err := c.writeErr + c.writeErrMu.Unlock() + if err != nil { + return err + } + + c.conn.SetWriteDeadline(deadline) + if len(buf1) == 0 { + _, err = c.conn.Write(buf0) + } else { + err = c.writeBufs(buf0, buf1) + } + if err != nil { + return c.writeFatal(err) + } + if frameType == CloseMessage { + c.writeFatal(ErrCloseSent) + } + return nil +} + +func (c *Conn) writeBufs(bufs ...[]byte) error { + b := net.Buffers(bufs) + _, err := b.WriteTo(c.conn) + return err +} + +// WriteControl writes a control message with the given deadline. The allowed +// message types are CloseMessage, PingMessage and PongMessage. +func (c *Conn) WriteControl(messageType int, data []byte, deadline time.Time) error { + if !isControl(messageType) { + return errBadWriteOpCode + } + if len(data) > maxControlFramePayloadSize { + return errInvalidControlFrame + } + + b0 := byte(messageType) | finalBit + b1 := byte(len(data)) + if !c.isServer { + b1 |= maskBit + } + + buf := make([]byte, 0, maxFrameHeaderSize+maxControlFramePayloadSize) + buf = append(buf, b0, b1) + + if c.isServer { + buf = append(buf, data...) + } else { + key := newMaskKey() + buf = append(buf, key[:]...) + buf = append(buf, data...) + maskBytes(key, 0, buf[6:]) + } + + d := 1000 * time.Hour + if !deadline.IsZero() { + d = deadline.Sub(time.Now()) + if d < 0 { + return errWriteTimeout + } + } + + timer := time.NewTimer(d) + select { + case <-c.mu: + timer.Stop() + case <-timer.C: + return errWriteTimeout + } + defer func() { c.mu <- struct{}{} }() + + c.writeErrMu.Lock() + err := c.writeErr + c.writeErrMu.Unlock() + if err != nil { + return err + } + + c.conn.SetWriteDeadline(deadline) + _, err = c.conn.Write(buf) + if err != nil { + return c.writeFatal(err) + } + if messageType == CloseMessage { + c.writeFatal(ErrCloseSent) + } + return err +} + +// beginMessage prepares a connection and message writer for a new message. +func (c *Conn) beginMessage(mw *messageWriter, messageType int) error { + // Close previous writer if not already closed by the application. It's + // probably better to return an error in this situation, but we cannot + // change this without breaking existing applications. + if c.writer != nil { + c.writer.Close() + c.writer = nil + } + + if !isControl(messageType) && !isData(messageType) { + return errBadWriteOpCode + } + + c.writeErrMu.Lock() + err := c.writeErr + c.writeErrMu.Unlock() + if err != nil { + return err + } + + mw.c = c + mw.frameType = messageType + mw.pos = maxFrameHeaderSize + + if c.writeBuf == nil { + wpd, ok := c.writePool.Get().(writePoolData) + if ok { + c.writeBuf = wpd.buf + } else { + c.writeBuf = make([]byte, c.writeBufSize) + } + } + return nil +} + +// NextWriter returns a writer for the next message to send. The writer's Close +// method flushes the complete message to the network. +// +// There can be at most one open writer on a connection. NextWriter closes the +// previous writer if the application has not already done so. +// +// All message types (TextMessage, BinaryMessage, CloseMessage, PingMessage and +// PongMessage) are supported. +func (c *Conn) NextWriter(messageType int) (io.WriteCloser, error) { + var mw messageWriter + if err := c.beginMessage(&mw, messageType); err != nil { + return nil, err + } + c.writer = &mw + if c.newCompressionWriter != nil && c.enableWriteCompression && isData(messageType) { + w := c.newCompressionWriter(c.writer, c.compressionLevel) + mw.compress = true + c.writer = w + } + return c.writer, nil +} + +type messageWriter struct { + c *Conn + compress bool // whether next call to flushFrame should set RSV1 + pos int // end of data in writeBuf. + frameType int // type of the current frame. + err error +} + +func (w *messageWriter) endMessage(err error) error { + if w.err != nil { + return err + } + c := w.c + w.err = err + c.writer = nil + if c.writePool != nil { + c.writePool.Put(writePoolData{buf: c.writeBuf}) + c.writeBuf = nil + } + return err +} + +// flushFrame writes buffered data and extra as a frame to the network. The +// final argument indicates that this is the last frame in the message. +func (w *messageWriter) flushFrame(final bool, extra []byte) error { + c := w.c + length := w.pos - maxFrameHeaderSize + len(extra) + + // Check for invalid control frames. + if isControl(w.frameType) && + (!final || length > maxControlFramePayloadSize) { + return w.endMessage(errInvalidControlFrame) + } + + b0 := byte(w.frameType) + if final { + b0 |= finalBit + } + if w.compress { + b0 |= rsv1Bit + } + w.compress = false + + b1 := byte(0) + if !c.isServer { + b1 |= maskBit + } + + // Assume that the frame starts at beginning of c.writeBuf. + framePos := 0 + if c.isServer { + // Adjust up if mask not included in the header. + framePos = 4 + } + + switch { + case length >= 65536: + c.writeBuf[framePos] = b0 + c.writeBuf[framePos+1] = b1 | 127 + binary.BigEndian.PutUint64(c.writeBuf[framePos+2:], uint64(length)) + case length > 125: + framePos += 6 + c.writeBuf[framePos] = b0 + c.writeBuf[framePos+1] = b1 | 126 + binary.BigEndian.PutUint16(c.writeBuf[framePos+2:], uint16(length)) + default: + framePos += 8 + c.writeBuf[framePos] = b0 + c.writeBuf[framePos+1] = b1 | byte(length) + } + + if !c.isServer { + key := newMaskKey() + copy(c.writeBuf[maxFrameHeaderSize-4:], key[:]) + maskBytes(key, 0, c.writeBuf[maxFrameHeaderSize:w.pos]) + if len(extra) > 0 { + return w.endMessage(c.writeFatal(errors.New("websocket: internal error, extra used in client mode"))) + } + } + + // Write the buffers to the connection with best-effort detection of + // concurrent writes. See the concurrency section in the package + // documentation for more info. + + if c.isWriting { + panic("concurrent write to websocket connection") + } + c.isWriting = true + + err := c.write(w.frameType, c.writeDeadline, c.writeBuf[framePos:w.pos], extra) + + if !c.isWriting { + panic("concurrent write to websocket connection") + } + c.isWriting = false + + if err != nil { + return w.endMessage(err) + } + + if final { + w.endMessage(errWriteClosed) + return nil + } + + // Setup for next frame. + w.pos = maxFrameHeaderSize + w.frameType = continuationFrame + return nil +} + +func (w *messageWriter) ncopy(max int) (int, error) { + n := len(w.c.writeBuf) - w.pos + if n <= 0 { + if err := w.flushFrame(false, nil); err != nil { + return 0, err + } + n = len(w.c.writeBuf) - w.pos + } + if n > max { + n = max + } + return n, nil +} + +func (w *messageWriter) Write(p []byte) (int, error) { + if w.err != nil { + return 0, w.err + } + + if len(p) > 2*len(w.c.writeBuf) && w.c.isServer { + // Don't buffer large messages. + err := w.flushFrame(false, p) + if err != nil { + return 0, err + } + return len(p), nil + } + + nn := len(p) + for len(p) > 0 { + n, err := w.ncopy(len(p)) + if err != nil { + return 0, err + } + copy(w.c.writeBuf[w.pos:], p[:n]) + w.pos += n + p = p[n:] + } + return nn, nil +} + +func (w *messageWriter) WriteString(p string) (int, error) { + if w.err != nil { + return 0, w.err + } + + nn := len(p) + for len(p) > 0 { + n, err := w.ncopy(len(p)) + if err != nil { + return 0, err + } + copy(w.c.writeBuf[w.pos:], p[:n]) + w.pos += n + p = p[n:] + } + return nn, nil +} + +func (w *messageWriter) ReadFrom(r io.Reader) (nn int64, err error) { + if w.err != nil { + return 0, w.err + } + for { + if w.pos == len(w.c.writeBuf) { + err = w.flushFrame(false, nil) + if err != nil { + break + } + } + var n int + n, err = r.Read(w.c.writeBuf[w.pos:]) + w.pos += n + nn += int64(n) + if err != nil { + if err == io.EOF { + err = nil + } + break + } + } + return nn, err +} + +func (w *messageWriter) Close() error { + if w.err != nil { + return w.err + } + return w.flushFrame(true, nil) +} + +// WritePreparedMessage writes prepared message into connection. +func (c *Conn) WritePreparedMessage(pm *PreparedMessage) error { + frameType, frameData, err := pm.frame(prepareKey{ + isServer: c.isServer, + compress: c.newCompressionWriter != nil && c.enableWriteCompression && isData(pm.messageType), + compressionLevel: c.compressionLevel, + }) + if err != nil { + return err + } + if c.isWriting { + panic("concurrent write to websocket connection") + } + c.isWriting = true + err = c.write(frameType, c.writeDeadline, frameData, nil) + if !c.isWriting { + panic("concurrent write to websocket connection") + } + c.isWriting = false + return err +} + +// WriteMessage is a helper method for getting a writer using NextWriter, +// writing the message and closing the writer. +func (c *Conn) WriteMessage(messageType int, data []byte) error { + + if c.isServer && (c.newCompressionWriter == nil || !c.enableWriteCompression) { + // Fast path with no allocations and single frame. + + var mw messageWriter + if err := c.beginMessage(&mw, messageType); err != nil { + return err + } + n := copy(c.writeBuf[mw.pos:], data) + mw.pos += n + data = data[n:] + return mw.flushFrame(true, data) + } + + w, err := c.NextWriter(messageType) + if err != nil { + return err + } + if _, err = w.Write(data); err != nil { + return err + } + return w.Close() +} + +// SetWriteDeadline sets the write deadline on the underlying network +// connection. After a write has timed out, the websocket state is corrupt and +// all future writes will return an error. A zero value for t means writes will +// not time out. +func (c *Conn) SetWriteDeadline(t time.Time) error { + c.writeDeadline = t + return nil +} + +// Read methods + +func (c *Conn) advanceFrame() (int, error) { + // 1. Skip remainder of previous frame. + + if c.readRemaining > 0 { + if _, err := io.CopyN(ioutil.Discard, c.br, c.readRemaining); err != nil { + return noFrame, err + } + } + + // 2. Read and parse first two bytes of frame header. + // To aid debugging, collect and report all errors in the first two bytes + // of the header. + + var errors []string + + p, err := c.read(2) + if err != nil { + return noFrame, err + } + + frameType := int(p[0] & 0xf) + final := p[0]&finalBit != 0 + rsv1 := p[0]&rsv1Bit != 0 + rsv2 := p[0]&rsv2Bit != 0 + rsv3 := p[0]&rsv3Bit != 0 + mask := p[1]&maskBit != 0 + c.setReadRemaining(int64(p[1] & 0x7f)) + + c.readDecompress = false + if rsv1 { + if c.newDecompressionReader != nil { + c.readDecompress = true + } else { + errors = append(errors, "RSV1 set") + } + } + + if rsv2 { + errors = append(errors, "RSV2 set") + } + + if rsv3 { + errors = append(errors, "RSV3 set") + } + + switch frameType { + case CloseMessage, PingMessage, PongMessage: + if c.readRemaining > maxControlFramePayloadSize { + errors = append(errors, "len > 125 for control") + } + if !final { + errors = append(errors, "FIN not set on control") + } + case TextMessage, BinaryMessage: + if !c.readFinal { + errors = append(errors, "data before FIN") + } + c.readFinal = final + case continuationFrame: + if c.readFinal { + errors = append(errors, "continuation after FIN") + } + c.readFinal = final + default: + errors = append(errors, "bad opcode "+strconv.Itoa(frameType)) + } + + if mask != c.isServer { + errors = append(errors, "bad MASK") + } + + if len(errors) > 0 { + return noFrame, c.handleProtocolError(strings.Join(errors, ", ")) + } + + // 3. Read and parse frame length as per + // https://tools.ietf.org/html/rfc6455#section-5.2 + // + // The length of the "Payload data", in bytes: if 0-125, that is the payload + // length. + // - If 126, the following 2 bytes interpreted as a 16-bit unsigned + // integer are the payload length. + // - If 127, the following 8 bytes interpreted as + // a 64-bit unsigned integer (the most significant bit MUST be 0) are the + // payload length. Multibyte length quantities are expressed in network byte + // order. + + switch c.readRemaining { + case 126: + p, err := c.read(2) + if err != nil { + return noFrame, err + } + + if err := c.setReadRemaining(int64(binary.BigEndian.Uint16(p))); err != nil { + return noFrame, err + } + case 127: + p, err := c.read(8) + if err != nil { + return noFrame, err + } + + if err := c.setReadRemaining(int64(binary.BigEndian.Uint64(p))); err != nil { + return noFrame, err + } + } + + // 4. Handle frame masking. + + if mask { + c.readMaskPos = 0 + p, err := c.read(len(c.readMaskKey)) + if err != nil { + return noFrame, err + } + copy(c.readMaskKey[:], p) + } + + // 5. For text and binary messages, enforce read limit and return. + + if frameType == continuationFrame || frameType == TextMessage || frameType == BinaryMessage { + + c.readLength += c.readRemaining + // Don't allow readLength to overflow in the presence of a large readRemaining + // counter. + if c.readLength < 0 { + return noFrame, ErrReadLimit + } + + if c.readLimit > 0 && c.readLength > c.readLimit { + c.WriteControl(CloseMessage, FormatCloseMessage(CloseMessageTooBig, ""), time.Now().Add(writeWait)) + return noFrame, ErrReadLimit + } + + return frameType, nil + } + + // 6. Read control frame payload. + + var payload []byte + if c.readRemaining > 0 { + payload, err = c.read(int(c.readRemaining)) + c.setReadRemaining(0) + if err != nil { + return noFrame, err + } + if c.isServer { + maskBytes(c.readMaskKey, 0, payload) + } + } + + // 7. Process control frame payload. + + switch frameType { + case PongMessage: + if err := c.handlePong(string(payload)); err != nil { + return noFrame, err + } + case PingMessage: + if err := c.handlePing(string(payload)); err != nil { + return noFrame, err + } + case CloseMessage: + closeCode := CloseNoStatusReceived + closeText := "" + if len(payload) >= 2 { + closeCode = int(binary.BigEndian.Uint16(payload)) + if !isValidReceivedCloseCode(closeCode) { + return noFrame, c.handleProtocolError("bad close code " + strconv.Itoa(closeCode)) + } + closeText = string(payload[2:]) + if !utf8.ValidString(closeText) { + return noFrame, c.handleProtocolError("invalid utf8 payload in close frame") + } + } + if err := c.handleClose(closeCode, closeText); err != nil { + return noFrame, err + } + return noFrame, &CloseError{Code: closeCode, Text: closeText} + } + + return frameType, nil +} + +func (c *Conn) handleProtocolError(message string) error { + data := FormatCloseMessage(CloseProtocolError, message) + if len(data) > maxControlFramePayloadSize { + data = data[:maxControlFramePayloadSize] + } + c.WriteControl(CloseMessage, data, time.Now().Add(writeWait)) + return errors.New("websocket: " + message) +} + +// NextReader returns the next data message received from the peer. The +// returned messageType is either TextMessage or BinaryMessage. +// +// There can be at most one open reader on a connection. NextReader discards +// the previous message if the application has not already consumed it. +// +// Applications must break out of the application's read loop when this method +// returns a non-nil error value. Errors returned from this method are +// permanent. Once this method returns a non-nil error, all subsequent calls to +// this method return the same error. +func (c *Conn) NextReader() (messageType int, r io.Reader, err error) { + // Close previous reader, only relevant for decompression. + if c.reader != nil { + c.reader.Close() + c.reader = nil + } + + c.messageReader = nil + c.readLength = 0 + + for c.readErr == nil { + frameType, err := c.advanceFrame() + if err != nil { + c.readErr = hideTempErr(err) + break + } + + if frameType == TextMessage || frameType == BinaryMessage { + c.messageReader = &messageReader{c} + c.reader = c.messageReader + if c.readDecompress { + c.reader = c.newDecompressionReader(c.reader) + } + return frameType, c.reader, nil + } + } + + // Applications that do handle the error returned from this method spin in + // tight loop on connection failure. To help application developers detect + // this error, panic on repeated reads to the failed connection. + c.readErrCount++ + if c.readErrCount >= 1000 { + panic("repeated read on failed websocket connection") + } + + return noFrame, nil, c.readErr +} + +type messageReader struct{ c *Conn } + +func (r *messageReader) Read(b []byte) (int, error) { + c := r.c + if c.messageReader != r { + return 0, io.EOF + } + + for c.readErr == nil { + + if c.readRemaining > 0 { + if int64(len(b)) > c.readRemaining { + b = b[:c.readRemaining] + } + n, err := c.br.Read(b) + c.readErr = hideTempErr(err) + if c.isServer { + c.readMaskPos = maskBytes(c.readMaskKey, c.readMaskPos, b[:n]) + } + rem := c.readRemaining + rem -= int64(n) + c.setReadRemaining(rem) + if c.readRemaining > 0 && c.readErr == io.EOF { + c.readErr = errUnexpectedEOF + } + return n, c.readErr + } + + if c.readFinal { + c.messageReader = nil + return 0, io.EOF + } + + frameType, err := c.advanceFrame() + switch { + case err != nil: + c.readErr = hideTempErr(err) + case frameType == TextMessage || frameType == BinaryMessage: + c.readErr = errors.New("websocket: internal error, unexpected text or binary in Reader") + } + } + + err := c.readErr + if err == io.EOF && c.messageReader == r { + err = errUnexpectedEOF + } + return 0, err +} + +func (r *messageReader) Close() error { + return nil +} + +// ReadMessage is a helper method for getting a reader using NextReader and +// reading from that reader to a buffer. +func (c *Conn) ReadMessage() (messageType int, p []byte, err error) { + var r io.Reader + messageType, r, err = c.NextReader() + if err != nil { + return messageType, nil, err + } + p, err = ioutil.ReadAll(r) + return messageType, p, err +} + +// SetReadDeadline sets the read deadline on the underlying network connection. +// After a read has timed out, the websocket connection state is corrupt and +// all future reads will return an error. A zero value for t means reads will +// not time out. +func (c *Conn) SetReadDeadline(t time.Time) error { + return c.conn.SetReadDeadline(t) +} + +// SetReadLimit sets the maximum size in bytes for a message read from the peer. If a +// message exceeds the limit, the connection sends a close message to the peer +// and returns ErrReadLimit to the application. +func (c *Conn) SetReadLimit(limit int64) { + c.readLimit = limit +} + +// CloseHandler returns the current close handler +func (c *Conn) CloseHandler() func(code int, text string) error { + return c.handleClose +} + +// SetCloseHandler sets the handler for close messages received from the peer. +// The code argument to h is the received close code or CloseNoStatusReceived +// if the close message is empty. The default close handler sends a close +// message back to the peer. +// +// The handler function is called from the NextReader, ReadMessage and message +// reader Read methods. The application must read the connection to process +// close messages as described in the section on Control Messages above. +// +// The connection read methods return a CloseError when a close message is +// received. Most applications should handle close messages as part of their +// normal error handling. Applications should only set a close handler when the +// application must perform some action before sending a close message back to +// the peer. +func (c *Conn) SetCloseHandler(h func(code int, text string) error) { + if h == nil { + h = func(code int, text string) error { + message := FormatCloseMessage(code, "") + c.WriteControl(CloseMessage, message, time.Now().Add(writeWait)) + return nil + } + } + c.handleClose = h +} + +// PingHandler returns the current ping handler +func (c *Conn) PingHandler() func(appData string) error { + return c.handlePing +} + +// SetPingHandler sets the handler for ping messages received from the peer. +// The appData argument to h is the PING message application data. The default +// ping handler sends a pong to the peer. +// +// The handler function is called from the NextReader, ReadMessage and message +// reader Read methods. The application must read the connection to process +// ping messages as described in the section on Control Messages above. +func (c *Conn) SetPingHandler(h func(appData string) error) { + if h == nil { + h = func(message string) error { + err := c.WriteControl(PongMessage, []byte(message), time.Now().Add(writeWait)) + if err == ErrCloseSent { + return nil + } else if e, ok := err.(net.Error); ok && e.Temporary() { + return nil + } + return err + } + } + c.handlePing = h +} + +// PongHandler returns the current pong handler +func (c *Conn) PongHandler() func(appData string) error { + return c.handlePong +} + +// SetPongHandler sets the handler for pong messages received from the peer. +// The appData argument to h is the PONG message application data. The default +// pong handler does nothing. +// +// The handler function is called from the NextReader, ReadMessage and message +// reader Read methods. The application must read the connection to process +// pong messages as described in the section on Control Messages above. +func (c *Conn) SetPongHandler(h func(appData string) error) { + if h == nil { + h = func(string) error { return nil } + } + c.handlePong = h +} + +// NetConn returns the underlying connection that is wrapped by c. +// Note that writing to or reading from this connection directly will corrupt the +// WebSocket connection. +func (c *Conn) NetConn() net.Conn { + return c.conn +} + +// UnderlyingConn returns the internal net.Conn. This can be used to further +// modifications to connection specific flags. +// Deprecated: Use the NetConn method. +func (c *Conn) UnderlyingConn() net.Conn { + return c.conn +} + +// EnableWriteCompression enables and disables write compression of +// subsequent text and binary messages. This function is a noop if +// compression was not negotiated with the peer. +func (c *Conn) EnableWriteCompression(enable bool) { + c.enableWriteCompression = enable +} + +// SetCompressionLevel sets the flate compression level for subsequent text and +// binary messages. This function is a noop if compression was not negotiated +// with the peer. See the compress/flate package for a description of +// compression levels. +func (c *Conn) SetCompressionLevel(level int) error { + if !isValidCompressionLevel(level) { + return errors.New("websocket: invalid compression level") + } + c.compressionLevel = level + return nil +} + +// FormatCloseMessage formats closeCode and text as a WebSocket close message. +// An empty message is returned for code CloseNoStatusReceived. +func FormatCloseMessage(closeCode int, text string) []byte { + if closeCode == CloseNoStatusReceived { + // Return empty message because it's illegal to send + // CloseNoStatusReceived. Return non-nil value in case application + // checks for nil. + return []byte{} + } + buf := make([]byte, 2+len(text)) + binary.BigEndian.PutUint16(buf, uint16(closeCode)) + copy(buf[2:], text) + return buf +} diff --git a/vendor/github.com/gorilla/websocket/doc.go b/vendor/github.com/gorilla/websocket/doc.go new file mode 100644 index 0000000000..8db0cef95a --- /dev/null +++ b/vendor/github.com/gorilla/websocket/doc.go @@ -0,0 +1,227 @@ +// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package websocket implements the WebSocket protocol defined in RFC 6455. +// +// Overview +// +// The Conn type represents a WebSocket connection. A server application calls +// the Upgrader.Upgrade method from an HTTP request handler to get a *Conn: +// +// var upgrader = websocket.Upgrader{ +// ReadBufferSize: 1024, +// WriteBufferSize: 1024, +// } +// +// func handler(w http.ResponseWriter, r *http.Request) { +// conn, err := upgrader.Upgrade(w, r, nil) +// if err != nil { +// log.Println(err) +// return +// } +// ... Use conn to send and receive messages. +// } +// +// Call the connection's WriteMessage and ReadMessage methods to send and +// receive messages as a slice of bytes. This snippet of code shows how to echo +// messages using these methods: +// +// for { +// messageType, p, err := conn.ReadMessage() +// if err != nil { +// log.Println(err) +// return +// } +// if err := conn.WriteMessage(messageType, p); err != nil { +// log.Println(err) +// return +// } +// } +// +// In above snippet of code, p is a []byte and messageType is an int with value +// websocket.BinaryMessage or websocket.TextMessage. +// +// An application can also send and receive messages using the io.WriteCloser +// and io.Reader interfaces. To send a message, call the connection NextWriter +// method to get an io.WriteCloser, write the message to the writer and close +// the writer when done. To receive a message, call the connection NextReader +// method to get an io.Reader and read until io.EOF is returned. This snippet +// shows how to echo messages using the NextWriter and NextReader methods: +// +// for { +// messageType, r, err := conn.NextReader() +// if err != nil { +// return +// } +// w, err := conn.NextWriter(messageType) +// if err != nil { +// return err +// } +// if _, err := io.Copy(w, r); err != nil { +// return err +// } +// if err := w.Close(); err != nil { +// return err +// } +// } +// +// Data Messages +// +// The WebSocket protocol distinguishes between text and binary data messages. +// Text messages are interpreted as UTF-8 encoded text. The interpretation of +// binary messages is left to the application. +// +// This package uses the TextMessage and BinaryMessage integer constants to +// identify the two data message types. The ReadMessage and NextReader methods +// return the type of the received message. The messageType argument to the +// WriteMessage and NextWriter methods specifies the type of a sent message. +// +// It is the application's responsibility to ensure that text messages are +// valid UTF-8 encoded text. +// +// Control Messages +// +// The WebSocket protocol defines three types of control messages: close, ping +// and pong. Call the connection WriteControl, WriteMessage or NextWriter +// methods to send a control message to the peer. +// +// Connections handle received close messages by calling the handler function +// set with the SetCloseHandler method and by returning a *CloseError from the +// NextReader, ReadMessage or the message Read method. The default close +// handler sends a close message to the peer. +// +// Connections handle received ping messages by calling the handler function +// set with the SetPingHandler method. The default ping handler sends a pong +// message to the peer. +// +// Connections handle received pong messages by calling the handler function +// set with the SetPongHandler method. The default pong handler does nothing. +// If an application sends ping messages, then the application should set a +// pong handler to receive the corresponding pong. +// +// The control message handler functions are called from the NextReader, +// ReadMessage and message reader Read methods. The default close and ping +// handlers can block these methods for a short time when the handler writes to +// the connection. +// +// The application must read the connection to process close, ping and pong +// messages sent from the peer. If the application is not otherwise interested +// in messages from the peer, then the application should start a goroutine to +// read and discard messages from the peer. A simple example is: +// +// func readLoop(c *websocket.Conn) { +// for { +// if _, _, err := c.NextReader(); err != nil { +// c.Close() +// break +// } +// } +// } +// +// Concurrency +// +// Connections support one concurrent reader and one concurrent writer. +// +// Applications are responsible for ensuring that no more than one goroutine +// calls the write methods (NextWriter, SetWriteDeadline, WriteMessage, +// WriteJSON, EnableWriteCompression, SetCompressionLevel) concurrently and +// that no more than one goroutine calls the read methods (NextReader, +// SetReadDeadline, ReadMessage, ReadJSON, SetPongHandler, SetPingHandler) +// concurrently. +// +// The Close and WriteControl methods can be called concurrently with all other +// methods. +// +// Origin Considerations +// +// Web browsers allow Javascript applications to open a WebSocket connection to +// any host. It's up to the server to enforce an origin policy using the Origin +// request header sent by the browser. +// +// The Upgrader calls the function specified in the CheckOrigin field to check +// the origin. If the CheckOrigin function returns false, then the Upgrade +// method fails the WebSocket handshake with HTTP status 403. +// +// If the CheckOrigin field is nil, then the Upgrader uses a safe default: fail +// the handshake if the Origin request header is present and the Origin host is +// not equal to the Host request header. +// +// The deprecated package-level Upgrade function does not perform origin +// checking. The application is responsible for checking the Origin header +// before calling the Upgrade function. +// +// Buffers +// +// Connections buffer network input and output to reduce the number +// of system calls when reading or writing messages. +// +// Write buffers are also used for constructing WebSocket frames. See RFC 6455, +// Section 5 for a discussion of message framing. A WebSocket frame header is +// written to the network each time a write buffer is flushed to the network. +// Decreasing the size of the write buffer can increase the amount of framing +// overhead on the connection. +// +// The buffer sizes in bytes are specified by the ReadBufferSize and +// WriteBufferSize fields in the Dialer and Upgrader. The Dialer uses a default +// size of 4096 when a buffer size field is set to zero. The Upgrader reuses +// buffers created by the HTTP server when a buffer size field is set to zero. +// The HTTP server buffers have a size of 4096 at the time of this writing. +// +// The buffer sizes do not limit the size of a message that can be read or +// written by a connection. +// +// Buffers are held for the lifetime of the connection by default. If the +// Dialer or Upgrader WriteBufferPool field is set, then a connection holds the +// write buffer only when writing a message. +// +// Applications should tune the buffer sizes to balance memory use and +// performance. Increasing the buffer size uses more memory, but can reduce the +// number of system calls to read or write the network. In the case of writing, +// increasing the buffer size can reduce the number of frame headers written to +// the network. +// +// Some guidelines for setting buffer parameters are: +// +// Limit the buffer sizes to the maximum expected message size. Buffers larger +// than the largest message do not provide any benefit. +// +// Depending on the distribution of message sizes, setting the buffer size to +// a value less than the maximum expected message size can greatly reduce memory +// use with a small impact on performance. Here's an example: If 99% of the +// messages are smaller than 256 bytes and the maximum message size is 512 +// bytes, then a buffer size of 256 bytes will result in 1.01 more system calls +// than a buffer size of 512 bytes. The memory savings is 50%. +// +// A write buffer pool is useful when the application has a modest number +// writes over a large number of connections. when buffers are pooled, a larger +// buffer size has a reduced impact on total memory use and has the benefit of +// reducing system calls and frame overhead. +// +// Compression EXPERIMENTAL +// +// Per message compression extensions (RFC 7692) are experimentally supported +// by this package in a limited capacity. Setting the EnableCompression option +// to true in Dialer or Upgrader will attempt to negotiate per message deflate +// support. +// +// var upgrader = websocket.Upgrader{ +// EnableCompression: true, +// } +// +// If compression was successfully negotiated with the connection's peer, any +// message received in compressed form will be automatically decompressed. +// All Read methods will return uncompressed bytes. +// +// Per message compression of messages written to a connection can be enabled +// or disabled by calling the corresponding Conn method: +// +// conn.EnableWriteCompression(false) +// +// Currently this package does not support compression with "context takeover". +// This means that messages must be compressed and decompressed in isolation, +// without retaining sliding window or dictionary state across messages. For +// more details refer to RFC 7692. +// +// Use of compression is experimental and may result in decreased performance. +package websocket diff --git a/vendor/github.com/gorilla/websocket/join.go b/vendor/github.com/gorilla/websocket/join.go new file mode 100644 index 0000000000..c64f8c8290 --- /dev/null +++ b/vendor/github.com/gorilla/websocket/join.go @@ -0,0 +1,42 @@ +// Copyright 2019 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "io" + "strings" +) + +// JoinMessages concatenates received messages to create a single io.Reader. +// The string term is appended to each message. The returned reader does not +// support concurrent calls to the Read method. +func JoinMessages(c *Conn, term string) io.Reader { + return &joinReader{c: c, term: term} +} + +type joinReader struct { + c *Conn + term string + r io.Reader +} + +func (r *joinReader) Read(p []byte) (int, error) { + if r.r == nil { + var err error + _, r.r, err = r.c.NextReader() + if err != nil { + return 0, err + } + if r.term != "" { + r.r = io.MultiReader(r.r, strings.NewReader(r.term)) + } + } + n, err := r.r.Read(p) + if err == io.EOF { + err = nil + r.r = nil + } + return n, err +} diff --git a/vendor/github.com/gorilla/websocket/json.go b/vendor/github.com/gorilla/websocket/json.go new file mode 100644 index 0000000000..dc2c1f6415 --- /dev/null +++ b/vendor/github.com/gorilla/websocket/json.go @@ -0,0 +1,60 @@ +// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "encoding/json" + "io" +) + +// WriteJSON writes the JSON encoding of v as a message. +// +// Deprecated: Use c.WriteJSON instead. +func WriteJSON(c *Conn, v interface{}) error { + return c.WriteJSON(v) +} + +// WriteJSON writes the JSON encoding of v as a message. +// +// See the documentation for encoding/json Marshal for details about the +// conversion of Go values to JSON. +func (c *Conn) WriteJSON(v interface{}) error { + w, err := c.NextWriter(TextMessage) + if err != nil { + return err + } + err1 := json.NewEncoder(w).Encode(v) + err2 := w.Close() + if err1 != nil { + return err1 + } + return err2 +} + +// ReadJSON reads the next JSON-encoded message from the connection and stores +// it in the value pointed to by v. +// +// Deprecated: Use c.ReadJSON instead. +func ReadJSON(c *Conn, v interface{}) error { + return c.ReadJSON(v) +} + +// ReadJSON reads the next JSON-encoded message from the connection and stores +// it in the value pointed to by v. +// +// See the documentation for the encoding/json Unmarshal function for details +// about the conversion of JSON to a Go value. +func (c *Conn) ReadJSON(v interface{}) error { + _, r, err := c.NextReader() + if err != nil { + return err + } + err = json.NewDecoder(r).Decode(v) + if err == io.EOF { + // One value is expected in the message. + err = io.ErrUnexpectedEOF + } + return err +} diff --git a/vendor/github.com/gorilla/websocket/mask.go b/vendor/github.com/gorilla/websocket/mask.go new file mode 100644 index 0000000000..d0742bf2a5 --- /dev/null +++ b/vendor/github.com/gorilla/websocket/mask.go @@ -0,0 +1,55 @@ +// Copyright 2016 The Gorilla WebSocket Authors. All rights reserved. Use of +// this source code is governed by a BSD-style license that can be found in the +// LICENSE file. + +//go:build !appengine +// +build !appengine + +package websocket + +import "unsafe" + +const wordSize = int(unsafe.Sizeof(uintptr(0))) + +func maskBytes(key [4]byte, pos int, b []byte) int { + // Mask one byte at a time for small buffers. + if len(b) < 2*wordSize { + for i := range b { + b[i] ^= key[pos&3] + pos++ + } + return pos & 3 + } + + // Mask one byte at a time to word boundary. + if n := int(uintptr(unsafe.Pointer(&b[0]))) % wordSize; n != 0 { + n = wordSize - n + for i := range b[:n] { + b[i] ^= key[pos&3] + pos++ + } + b = b[n:] + } + + // Create aligned word size key. + var k [wordSize]byte + for i := range k { + k[i] = key[(pos+i)&3] + } + kw := *(*uintptr)(unsafe.Pointer(&k)) + + // Mask one word at a time. + n := (len(b) / wordSize) * wordSize + for i := 0; i < n; i += wordSize { + *(*uintptr)(unsafe.Pointer(uintptr(unsafe.Pointer(&b[0])) + uintptr(i))) ^= kw + } + + // Mask one byte at a time for remaining bytes. + b = b[n:] + for i := range b { + b[i] ^= key[pos&3] + pos++ + } + + return pos & 3 +} diff --git a/vendor/github.com/gorilla/websocket/mask_safe.go b/vendor/github.com/gorilla/websocket/mask_safe.go new file mode 100644 index 0000000000..36250ca7c4 --- /dev/null +++ b/vendor/github.com/gorilla/websocket/mask_safe.go @@ -0,0 +1,16 @@ +// Copyright 2016 The Gorilla WebSocket Authors. All rights reserved. Use of +// this source code is governed by a BSD-style license that can be found in the +// LICENSE file. + +//go:build appengine +// +build appengine + +package websocket + +func maskBytes(key [4]byte, pos int, b []byte) int { + for i := range b { + b[i] ^= key[pos&3] + pos++ + } + return pos & 3 +} diff --git a/vendor/github.com/gorilla/websocket/prepared.go b/vendor/github.com/gorilla/websocket/prepared.go new file mode 100644 index 0000000000..c854225e96 --- /dev/null +++ b/vendor/github.com/gorilla/websocket/prepared.go @@ -0,0 +1,102 @@ +// Copyright 2017 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "bytes" + "net" + "sync" + "time" +) + +// PreparedMessage caches on the wire representations of a message payload. +// Use PreparedMessage to efficiently send a message payload to multiple +// connections. PreparedMessage is especially useful when compression is used +// because the CPU and memory expensive compression operation can be executed +// once for a given set of compression options. +type PreparedMessage struct { + messageType int + data []byte + mu sync.Mutex + frames map[prepareKey]*preparedFrame +} + +// prepareKey defines a unique set of options to cache prepared frames in PreparedMessage. +type prepareKey struct { + isServer bool + compress bool + compressionLevel int +} + +// preparedFrame contains data in wire representation. +type preparedFrame struct { + once sync.Once + data []byte +} + +// NewPreparedMessage returns an initialized PreparedMessage. You can then send +// it to connection using WritePreparedMessage method. Valid wire +// representation will be calculated lazily only once for a set of current +// connection options. +func NewPreparedMessage(messageType int, data []byte) (*PreparedMessage, error) { + pm := &PreparedMessage{ + messageType: messageType, + frames: make(map[prepareKey]*preparedFrame), + data: data, + } + + // Prepare a plain server frame. + _, frameData, err := pm.frame(prepareKey{isServer: true, compress: false}) + if err != nil { + return nil, err + } + + // To protect against caller modifying the data argument, remember the data + // copied to the plain server frame. + pm.data = frameData[len(frameData)-len(data):] + return pm, nil +} + +func (pm *PreparedMessage) frame(key prepareKey) (int, []byte, error) { + pm.mu.Lock() + frame, ok := pm.frames[key] + if !ok { + frame = &preparedFrame{} + pm.frames[key] = frame + } + pm.mu.Unlock() + + var err error + frame.once.Do(func() { + // Prepare a frame using a 'fake' connection. + // TODO: Refactor code in conn.go to allow more direct construction of + // the frame. + mu := make(chan struct{}, 1) + mu <- struct{}{} + var nc prepareConn + c := &Conn{ + conn: &nc, + mu: mu, + isServer: key.isServer, + compressionLevel: key.compressionLevel, + enableWriteCompression: true, + writeBuf: make([]byte, defaultWriteBufferSize+maxFrameHeaderSize), + } + if key.compress { + c.newCompressionWriter = compressNoContextTakeover + } + err = c.WriteMessage(pm.messageType, pm.data) + frame.data = nc.buf.Bytes() + }) + return pm.messageType, frame.data, err +} + +type prepareConn struct { + buf bytes.Buffer + net.Conn +} + +func (pc *prepareConn) Write(p []byte) (int, error) { return pc.buf.Write(p) } +func (pc *prepareConn) SetWriteDeadline(t time.Time) error { return nil } diff --git a/vendor/github.com/gorilla/websocket/proxy.go b/vendor/github.com/gorilla/websocket/proxy.go new file mode 100644 index 0000000000..e0f466b72f --- /dev/null +++ b/vendor/github.com/gorilla/websocket/proxy.go @@ -0,0 +1,77 @@ +// Copyright 2017 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "bufio" + "encoding/base64" + "errors" + "net" + "net/http" + "net/url" + "strings" +) + +type netDialerFunc func(network, addr string) (net.Conn, error) + +func (fn netDialerFunc) Dial(network, addr string) (net.Conn, error) { + return fn(network, addr) +} + +func init() { + proxy_RegisterDialerType("http", func(proxyURL *url.URL, forwardDialer proxy_Dialer) (proxy_Dialer, error) { + return &httpProxyDialer{proxyURL: proxyURL, forwardDial: forwardDialer.Dial}, nil + }) +} + +type httpProxyDialer struct { + proxyURL *url.URL + forwardDial func(network, addr string) (net.Conn, error) +} + +func (hpd *httpProxyDialer) Dial(network string, addr string) (net.Conn, error) { + hostPort, _ := hostPortNoPort(hpd.proxyURL) + conn, err := hpd.forwardDial(network, hostPort) + if err != nil { + return nil, err + } + + connectHeader := make(http.Header) + if user := hpd.proxyURL.User; user != nil { + proxyUser := user.Username() + if proxyPassword, passwordSet := user.Password(); passwordSet { + credential := base64.StdEncoding.EncodeToString([]byte(proxyUser + ":" + proxyPassword)) + connectHeader.Set("Proxy-Authorization", "Basic "+credential) + } + } + + connectReq := &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Opaque: addr}, + Host: addr, + Header: connectHeader, + } + + if err := connectReq.Write(conn); err != nil { + conn.Close() + return nil, err + } + + // Read response. It's OK to use and discard buffered reader here becaue + // the remote server does not speak until spoken to. + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, connectReq) + if err != nil { + conn.Close() + return nil, err + } + + if resp.StatusCode != 200 { + conn.Close() + f := strings.SplitN(resp.Status, " ", 2) + return nil, errors.New(f[1]) + } + return conn, nil +} diff --git a/vendor/github.com/gorilla/websocket/server.go b/vendor/github.com/gorilla/websocket/server.go new file mode 100644 index 0000000000..bb33597432 --- /dev/null +++ b/vendor/github.com/gorilla/websocket/server.go @@ -0,0 +1,365 @@ +// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "bufio" + "errors" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// HandshakeError describes an error with the handshake from the peer. +type HandshakeError struct { + message string +} + +func (e HandshakeError) Error() string { return e.message } + +// Upgrader specifies parameters for upgrading an HTTP connection to a +// WebSocket connection. +// +// It is safe to call Upgrader's methods concurrently. +type Upgrader struct { + // HandshakeTimeout specifies the duration for the handshake to complete. + HandshakeTimeout time.Duration + + // ReadBufferSize and WriteBufferSize specify I/O buffer sizes in bytes. If a buffer + // size is zero, then buffers allocated by the HTTP server are used. The + // I/O buffer sizes do not limit the size of the messages that can be sent + // or received. + ReadBufferSize, WriteBufferSize int + + // WriteBufferPool is a pool of buffers for write operations. If the value + // is not set, then write buffers are allocated to the connection for the + // lifetime of the connection. + // + // A pool is most useful when the application has a modest volume of writes + // across a large number of connections. + // + // Applications should use a single pool for each unique value of + // WriteBufferSize. + WriteBufferPool BufferPool + + // Subprotocols specifies the server's supported protocols in order of + // preference. If this field is not nil, then the Upgrade method negotiates a + // subprotocol by selecting the first match in this list with a protocol + // requested by the client. If there's no match, then no protocol is + // negotiated (the Sec-Websocket-Protocol header is not included in the + // handshake response). + Subprotocols []string + + // Error specifies the function for generating HTTP error responses. If Error + // is nil, then http.Error is used to generate the HTTP response. + Error func(w http.ResponseWriter, r *http.Request, status int, reason error) + + // CheckOrigin returns true if the request Origin header is acceptable. If + // CheckOrigin is nil, then a safe default is used: return false if the + // Origin request header is present and the origin host is not equal to + // request Host header. + // + // A CheckOrigin function should carefully validate the request origin to + // prevent cross-site request forgery. + CheckOrigin func(r *http.Request) bool + + // EnableCompression specify if the server should attempt to negotiate per + // message compression (RFC 7692). Setting this value to true does not + // guarantee that compression will be supported. Currently only "no context + // takeover" modes are supported. + EnableCompression bool +} + +func (u *Upgrader) returnError(w http.ResponseWriter, r *http.Request, status int, reason string) (*Conn, error) { + err := HandshakeError{reason} + if u.Error != nil { + u.Error(w, r, status, err) + } else { + w.Header().Set("Sec-Websocket-Version", "13") + http.Error(w, http.StatusText(status), status) + } + return nil, err +} + +// checkSameOrigin returns true if the origin is not set or is equal to the request host. +func checkSameOrigin(r *http.Request) bool { + origin := r.Header["Origin"] + if len(origin) == 0 { + return true + } + u, err := url.Parse(origin[0]) + if err != nil { + return false + } + return equalASCIIFold(u.Host, r.Host) +} + +func (u *Upgrader) selectSubprotocol(r *http.Request, responseHeader http.Header) string { + if u.Subprotocols != nil { + clientProtocols := Subprotocols(r) + for _, serverProtocol := range u.Subprotocols { + for _, clientProtocol := range clientProtocols { + if clientProtocol == serverProtocol { + return clientProtocol + } + } + } + } else if responseHeader != nil { + return responseHeader.Get("Sec-Websocket-Protocol") + } + return "" +} + +// Upgrade upgrades the HTTP server connection to the WebSocket protocol. +// +// The responseHeader is included in the response to the client's upgrade +// request. Use the responseHeader to specify cookies (Set-Cookie). To specify +// subprotocols supported by the server, set Upgrader.Subprotocols directly. +// +// If the upgrade fails, then Upgrade replies to the client with an HTTP error +// response. +func (u *Upgrader) Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (*Conn, error) { + const badHandshake = "websocket: the client is not using the websocket protocol: " + + if !tokenListContainsValue(r.Header, "Connection", "upgrade") { + return u.returnError(w, r, http.StatusBadRequest, badHandshake+"'upgrade' token not found in 'Connection' header") + } + + if !tokenListContainsValue(r.Header, "Upgrade", "websocket") { + return u.returnError(w, r, http.StatusBadRequest, badHandshake+"'websocket' token not found in 'Upgrade' header") + } + + if r.Method != http.MethodGet { + return u.returnError(w, r, http.StatusMethodNotAllowed, badHandshake+"request method is not GET") + } + + if !tokenListContainsValue(r.Header, "Sec-Websocket-Version", "13") { + return u.returnError(w, r, http.StatusBadRequest, "websocket: unsupported version: 13 not found in 'Sec-Websocket-Version' header") + } + + if _, ok := responseHeader["Sec-Websocket-Extensions"]; ok { + return u.returnError(w, r, http.StatusInternalServerError, "websocket: application specific 'Sec-WebSocket-Extensions' headers are unsupported") + } + + checkOrigin := u.CheckOrigin + if checkOrigin == nil { + checkOrigin = checkSameOrigin + } + if !checkOrigin(r) { + return u.returnError(w, r, http.StatusForbidden, "websocket: request origin not allowed by Upgrader.CheckOrigin") + } + + challengeKey := r.Header.Get("Sec-Websocket-Key") + if !isValidChallengeKey(challengeKey) { + return u.returnError(w, r, http.StatusBadRequest, "websocket: not a websocket handshake: 'Sec-WebSocket-Key' header must be Base64 encoded value of 16-byte in length") + } + + subprotocol := u.selectSubprotocol(r, responseHeader) + + // Negotiate PMCE + var compress bool + if u.EnableCompression { + for _, ext := range parseExtensions(r.Header) { + if ext[""] != "permessage-deflate" { + continue + } + compress = true + break + } + } + + h, ok := w.(http.Hijacker) + if !ok { + return u.returnError(w, r, http.StatusInternalServerError, "websocket: response does not implement http.Hijacker") + } + var brw *bufio.ReadWriter + netConn, brw, err := h.Hijack() + if err != nil { + return u.returnError(w, r, http.StatusInternalServerError, err.Error()) + } + + if brw.Reader.Buffered() > 0 { + netConn.Close() + return nil, errors.New("websocket: client sent data before handshake is complete") + } + + var br *bufio.Reader + if u.ReadBufferSize == 0 && bufioReaderSize(netConn, brw.Reader) > 256 { + // Reuse hijacked buffered reader as connection reader. + br = brw.Reader + } + + buf := bufioWriterBuffer(netConn, brw.Writer) + + var writeBuf []byte + if u.WriteBufferPool == nil && u.WriteBufferSize == 0 && len(buf) >= maxFrameHeaderSize+256 { + // Reuse hijacked write buffer as connection buffer. + writeBuf = buf + } + + c := newConn(netConn, true, u.ReadBufferSize, u.WriteBufferSize, u.WriteBufferPool, br, writeBuf) + c.subprotocol = subprotocol + + if compress { + c.newCompressionWriter = compressNoContextTakeover + c.newDecompressionReader = decompressNoContextTakeover + } + + // Use larger of hijacked buffer and connection write buffer for header. + p := buf + if len(c.writeBuf) > len(p) { + p = c.writeBuf + } + p = p[:0] + + p = append(p, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: "...) + p = append(p, computeAcceptKey(challengeKey)...) + p = append(p, "\r\n"...) + if c.subprotocol != "" { + p = append(p, "Sec-WebSocket-Protocol: "...) + p = append(p, c.subprotocol...) + p = append(p, "\r\n"...) + } + if compress { + p = append(p, "Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover; client_no_context_takeover\r\n"...) + } + for k, vs := range responseHeader { + if k == "Sec-Websocket-Protocol" { + continue + } + for _, v := range vs { + p = append(p, k...) + p = append(p, ": "...) + for i := 0; i < len(v); i++ { + b := v[i] + if b <= 31 { + // prevent response splitting. + b = ' ' + } + p = append(p, b) + } + p = append(p, "\r\n"...) + } + } + p = append(p, "\r\n"...) + + // Clear deadlines set by HTTP server. + netConn.SetDeadline(time.Time{}) + + if u.HandshakeTimeout > 0 { + netConn.SetWriteDeadline(time.Now().Add(u.HandshakeTimeout)) + } + if _, err = netConn.Write(p); err != nil { + netConn.Close() + return nil, err + } + if u.HandshakeTimeout > 0 { + netConn.SetWriteDeadline(time.Time{}) + } + + return c, nil +} + +// Upgrade upgrades the HTTP server connection to the WebSocket protocol. +// +// Deprecated: Use websocket.Upgrader instead. +// +// Upgrade does not perform origin checking. The application is responsible for +// checking the Origin header before calling Upgrade. An example implementation +// of the same origin policy check is: +// +// if req.Header.Get("Origin") != "http://"+req.Host { +// http.Error(w, "Origin not allowed", http.StatusForbidden) +// return +// } +// +// If the endpoint supports subprotocols, then the application is responsible +// for negotiating the protocol used on the connection. Use the Subprotocols() +// function to get the subprotocols requested by the client. Use the +// Sec-Websocket-Protocol response header to specify the subprotocol selected +// by the application. +// +// The responseHeader is included in the response to the client's upgrade +// request. Use the responseHeader to specify cookies (Set-Cookie) and the +// negotiated subprotocol (Sec-Websocket-Protocol). +// +// The connection buffers IO to the underlying network connection. The +// readBufSize and writeBufSize parameters specify the size of the buffers to +// use. Messages can be larger than the buffers. +// +// If the request is not a valid WebSocket handshake, then Upgrade returns an +// error of type HandshakeError. Applications should handle this error by +// replying to the client with an HTTP error response. +func Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header, readBufSize, writeBufSize int) (*Conn, error) { + u := Upgrader{ReadBufferSize: readBufSize, WriteBufferSize: writeBufSize} + u.Error = func(w http.ResponseWriter, r *http.Request, status int, reason error) { + // don't return errors to maintain backwards compatibility + } + u.CheckOrigin = func(r *http.Request) bool { + // allow all connections by default + return true + } + return u.Upgrade(w, r, responseHeader) +} + +// Subprotocols returns the subprotocols requested by the client in the +// Sec-Websocket-Protocol header. +func Subprotocols(r *http.Request) []string { + h := strings.TrimSpace(r.Header.Get("Sec-Websocket-Protocol")) + if h == "" { + return nil + } + protocols := strings.Split(h, ",") + for i := range protocols { + protocols[i] = strings.TrimSpace(protocols[i]) + } + return protocols +} + +// IsWebSocketUpgrade returns true if the client requested upgrade to the +// WebSocket protocol. +func IsWebSocketUpgrade(r *http.Request) bool { + return tokenListContainsValue(r.Header, "Connection", "upgrade") && + tokenListContainsValue(r.Header, "Upgrade", "websocket") +} + +// bufioReaderSize size returns the size of a bufio.Reader. +func bufioReaderSize(originalReader io.Reader, br *bufio.Reader) int { + // This code assumes that peek on a reset reader returns + // bufio.Reader.buf[:0]. + // TODO: Use bufio.Reader.Size() after Go 1.10 + br.Reset(originalReader) + if p, err := br.Peek(0); err == nil { + return cap(p) + } + return 0 +} + +// writeHook is an io.Writer that records the last slice passed to it vio +// io.Writer.Write. +type writeHook struct { + p []byte +} + +func (wh *writeHook) Write(p []byte) (int, error) { + wh.p = p + return len(p), nil +} + +// bufioWriterBuffer grabs the buffer from a bufio.Writer. +func bufioWriterBuffer(originalWriter io.Writer, bw *bufio.Writer) []byte { + // This code assumes that bufio.Writer.buf[:1] is passed to the + // bufio.Writer's underlying writer. + var wh writeHook + bw.Reset(&wh) + bw.WriteByte(0) + bw.Flush() + + bw.Reset(originalWriter) + + return wh.p[:cap(wh.p)] +} diff --git a/vendor/github.com/gorilla/websocket/tls_handshake.go b/vendor/github.com/gorilla/websocket/tls_handshake.go new file mode 100644 index 0000000000..a62b68ccb1 --- /dev/null +++ b/vendor/github.com/gorilla/websocket/tls_handshake.go @@ -0,0 +1,21 @@ +//go:build go1.17 +// +build go1.17 + +package websocket + +import ( + "context" + "crypto/tls" +) + +func doHandshake(ctx context.Context, tlsConn *tls.Conn, cfg *tls.Config) error { + if err := tlsConn.HandshakeContext(ctx); err != nil { + return err + } + if !cfg.InsecureSkipVerify { + if err := tlsConn.VerifyHostname(cfg.ServerName); err != nil { + return err + } + } + return nil +} diff --git a/vendor/github.com/gorilla/websocket/tls_handshake_116.go b/vendor/github.com/gorilla/websocket/tls_handshake_116.go new file mode 100644 index 0000000000..e1b2b44f6e --- /dev/null +++ b/vendor/github.com/gorilla/websocket/tls_handshake_116.go @@ -0,0 +1,21 @@ +//go:build !go1.17 +// +build !go1.17 + +package websocket + +import ( + "context" + "crypto/tls" +) + +func doHandshake(ctx context.Context, tlsConn *tls.Conn, cfg *tls.Config) error { + if err := tlsConn.Handshake(); err != nil { + return err + } + if !cfg.InsecureSkipVerify { + if err := tlsConn.VerifyHostname(cfg.ServerName); err != nil { + return err + } + } + return nil +} diff --git a/vendor/github.com/gorilla/websocket/util.go b/vendor/github.com/gorilla/websocket/util.go new file mode 100644 index 0000000000..31a5dee646 --- /dev/null +++ b/vendor/github.com/gorilla/websocket/util.go @@ -0,0 +1,298 @@ +// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "io" + "net/http" + "strings" + "unicode/utf8" +) + +var keyGUID = []byte("258EAFA5-E914-47DA-95CA-C5AB0DC85B11") + +func computeAcceptKey(challengeKey string) string { + h := sha1.New() + h.Write([]byte(challengeKey)) + h.Write(keyGUID) + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +func generateChallengeKey() (string, error) { + p := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, p); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(p), nil +} + +// Token octets per RFC 2616. +var isTokenOctet = [256]bool{ + '!': true, + '#': true, + '$': true, + '%': true, + '&': true, + '\'': true, + '*': true, + '+': true, + '-': true, + '.': true, + '0': true, + '1': true, + '2': true, + '3': true, + '4': true, + '5': true, + '6': true, + '7': true, + '8': true, + '9': true, + 'A': true, + 'B': true, + 'C': true, + 'D': true, + 'E': true, + 'F': true, + 'G': true, + 'H': true, + 'I': true, + 'J': true, + 'K': true, + 'L': true, + 'M': true, + 'N': true, + 'O': true, + 'P': true, + 'Q': true, + 'R': true, + 'S': true, + 'T': true, + 'U': true, + 'W': true, + 'V': true, + 'X': true, + 'Y': true, + 'Z': true, + '^': true, + '_': true, + '`': true, + 'a': true, + 'b': true, + 'c': true, + 'd': true, + 'e': true, + 'f': true, + 'g': true, + 'h': true, + 'i': true, + 'j': true, + 'k': true, + 'l': true, + 'm': true, + 'n': true, + 'o': true, + 'p': true, + 'q': true, + 'r': true, + 's': true, + 't': true, + 'u': true, + 'v': true, + 'w': true, + 'x': true, + 'y': true, + 'z': true, + '|': true, + '~': true, +} + +// skipSpace returns a slice of the string s with all leading RFC 2616 linear +// whitespace removed. +func skipSpace(s string) (rest string) { + i := 0 + for ; i < len(s); i++ { + if b := s[i]; b != ' ' && b != '\t' { + break + } + } + return s[i:] +} + +// nextToken returns the leading RFC 2616 token of s and the string following +// the token. +func nextToken(s string) (token, rest string) { + i := 0 + for ; i < len(s); i++ { + if !isTokenOctet[s[i]] { + break + } + } + return s[:i], s[i:] +} + +// nextTokenOrQuoted returns the leading token or quoted string per RFC 2616 +// and the string following the token or quoted string. +func nextTokenOrQuoted(s string) (value string, rest string) { + if !strings.HasPrefix(s, "\"") { + return nextToken(s) + } + s = s[1:] + for i := 0; i < len(s); i++ { + switch s[i] { + case '"': + return s[:i], s[i+1:] + case '\\': + p := make([]byte, len(s)-1) + j := copy(p, s[:i]) + escape := true + for i = i + 1; i < len(s); i++ { + b := s[i] + switch { + case escape: + escape = false + p[j] = b + j++ + case b == '\\': + escape = true + case b == '"': + return string(p[:j]), s[i+1:] + default: + p[j] = b + j++ + } + } + return "", "" + } + } + return "", "" +} + +// equalASCIIFold returns true if s is equal to t with ASCII case folding as +// defined in RFC 4790. +func equalASCIIFold(s, t string) bool { + for s != "" && t != "" { + sr, size := utf8.DecodeRuneInString(s) + s = s[size:] + tr, size := utf8.DecodeRuneInString(t) + t = t[size:] + if sr == tr { + continue + } + if 'A' <= sr && sr <= 'Z' { + sr = sr + 'a' - 'A' + } + if 'A' <= tr && tr <= 'Z' { + tr = tr + 'a' - 'A' + } + if sr != tr { + return false + } + } + return s == t +} + +// tokenListContainsValue returns true if the 1#token header with the given +// name contains a token equal to value with ASCII case folding. +func tokenListContainsValue(header http.Header, name string, value string) bool { +headers: + for _, s := range header[name] { + for { + var t string + t, s = nextToken(skipSpace(s)) + if t == "" { + continue headers + } + s = skipSpace(s) + if s != "" && s[0] != ',' { + continue headers + } + if equalASCIIFold(t, value) { + return true + } + if s == "" { + continue headers + } + s = s[1:] + } + } + return false +} + +// parseExtensions parses WebSocket extensions from a header. +func parseExtensions(header http.Header) []map[string]string { + // From RFC 6455: + // + // Sec-WebSocket-Extensions = extension-list + // extension-list = 1#extension + // extension = extension-token *( ";" extension-param ) + // extension-token = registered-token + // registered-token = token + // extension-param = token [ "=" (token | quoted-string) ] + // ;When using the quoted-string syntax variant, the value + // ;after quoted-string unescaping MUST conform to the + // ;'token' ABNF. + + var result []map[string]string +headers: + for _, s := range header["Sec-Websocket-Extensions"] { + for { + var t string + t, s = nextToken(skipSpace(s)) + if t == "" { + continue headers + } + ext := map[string]string{"": t} + for { + s = skipSpace(s) + if !strings.HasPrefix(s, ";") { + break + } + var k string + k, s = nextToken(skipSpace(s[1:])) + if k == "" { + continue headers + } + s = skipSpace(s) + var v string + if strings.HasPrefix(s, "=") { + v, s = nextTokenOrQuoted(skipSpace(s[1:])) + s = skipSpace(s) + } + if s != "" && s[0] != ',' && s[0] != ';' { + continue headers + } + ext[k] = v + } + if s != "" && s[0] != ',' { + continue headers + } + result = append(result, ext) + if s == "" { + continue headers + } + s = s[1:] + } + } + return result +} + +// isValidChallengeKey checks if the argument meets RFC6455 specification. +func isValidChallengeKey(s string) bool { + // From RFC6455: + // + // A |Sec-WebSocket-Key| header field with a base64-encoded (see + // Section 4 of [RFC4648]) value that, when decoded, is 16 bytes in + // length. + + if s == "" { + return false + } + decoded, err := base64.StdEncoding.DecodeString(s) + return err == nil && len(decoded) == 16 +} diff --git a/vendor/github.com/gorilla/websocket/x_net_proxy.go b/vendor/github.com/gorilla/websocket/x_net_proxy.go new file mode 100644 index 0000000000..2e668f6b88 --- /dev/null +++ b/vendor/github.com/gorilla/websocket/x_net_proxy.go @@ -0,0 +1,473 @@ +// Code generated by golang.org/x/tools/cmd/bundle. DO NOT EDIT. +//go:generate bundle -o x_net_proxy.go golang.org/x/net/proxy + +// Package proxy provides support for a variety of protocols to proxy network +// data. +// + +package websocket + +import ( + "errors" + "io" + "net" + "net/url" + "os" + "strconv" + "strings" + "sync" +) + +type proxy_direct struct{} + +// Direct is a direct proxy: one that makes network connections directly. +var proxy_Direct = proxy_direct{} + +func (proxy_direct) Dial(network, addr string) (net.Conn, error) { + return net.Dial(network, addr) +} + +// A PerHost directs connections to a default Dialer unless the host name +// requested matches one of a number of exceptions. +type proxy_PerHost struct { + def, bypass proxy_Dialer + + bypassNetworks []*net.IPNet + bypassIPs []net.IP + bypassZones []string + bypassHosts []string +} + +// NewPerHost returns a PerHost Dialer that directs connections to either +// defaultDialer or bypass, depending on whether the connection matches one of +// the configured rules. +func proxy_NewPerHost(defaultDialer, bypass proxy_Dialer) *proxy_PerHost { + return &proxy_PerHost{ + def: defaultDialer, + bypass: bypass, + } +} + +// Dial connects to the address addr on the given network through either +// defaultDialer or bypass. +func (p *proxy_PerHost) Dial(network, addr string) (c net.Conn, err error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + + return p.dialerForRequest(host).Dial(network, addr) +} + +func (p *proxy_PerHost) dialerForRequest(host string) proxy_Dialer { + if ip := net.ParseIP(host); ip != nil { + for _, net := range p.bypassNetworks { + if net.Contains(ip) { + return p.bypass + } + } + for _, bypassIP := range p.bypassIPs { + if bypassIP.Equal(ip) { + return p.bypass + } + } + return p.def + } + + for _, zone := range p.bypassZones { + if strings.HasSuffix(host, zone) { + return p.bypass + } + if host == zone[1:] { + // For a zone ".example.com", we match "example.com" + // too. + return p.bypass + } + } + for _, bypassHost := range p.bypassHosts { + if bypassHost == host { + return p.bypass + } + } + return p.def +} + +// AddFromString parses a string that contains comma-separated values +// specifying hosts that should use the bypass proxy. Each value is either an +// IP address, a CIDR range, a zone (*.example.com) or a host name +// (localhost). A best effort is made to parse the string and errors are +// ignored. +func (p *proxy_PerHost) AddFromString(s string) { + hosts := strings.Split(s, ",") + for _, host := range hosts { + host = strings.TrimSpace(host) + if len(host) == 0 { + continue + } + if strings.Contains(host, "/") { + // We assume that it's a CIDR address like 127.0.0.0/8 + if _, net, err := net.ParseCIDR(host); err == nil { + p.AddNetwork(net) + } + continue + } + if ip := net.ParseIP(host); ip != nil { + p.AddIP(ip) + continue + } + if strings.HasPrefix(host, "*.") { + p.AddZone(host[1:]) + continue + } + p.AddHost(host) + } +} + +// AddIP specifies an IP address that will use the bypass proxy. Note that +// this will only take effect if a literal IP address is dialed. A connection +// to a named host will never match an IP. +func (p *proxy_PerHost) AddIP(ip net.IP) { + p.bypassIPs = append(p.bypassIPs, ip) +} + +// AddNetwork specifies an IP range that will use the bypass proxy. Note that +// this will only take effect if a literal IP address is dialed. A connection +// to a named host will never match. +func (p *proxy_PerHost) AddNetwork(net *net.IPNet) { + p.bypassNetworks = append(p.bypassNetworks, net) +} + +// AddZone specifies a DNS suffix that will use the bypass proxy. A zone of +// "example.com" matches "example.com" and all of its subdomains. +func (p *proxy_PerHost) AddZone(zone string) { + if strings.HasSuffix(zone, ".") { + zone = zone[:len(zone)-1] + } + if !strings.HasPrefix(zone, ".") { + zone = "." + zone + } + p.bypassZones = append(p.bypassZones, zone) +} + +// AddHost specifies a host name that will use the bypass proxy. +func (p *proxy_PerHost) AddHost(host string) { + if strings.HasSuffix(host, ".") { + host = host[:len(host)-1] + } + p.bypassHosts = append(p.bypassHosts, host) +} + +// A Dialer is a means to establish a connection. +type proxy_Dialer interface { + // Dial connects to the given address via the proxy. + Dial(network, addr string) (c net.Conn, err error) +} + +// Auth contains authentication parameters that specific Dialers may require. +type proxy_Auth struct { + User, Password string +} + +// FromEnvironment returns the dialer specified by the proxy related variables in +// the environment. +func proxy_FromEnvironment() proxy_Dialer { + allProxy := proxy_allProxyEnv.Get() + if len(allProxy) == 0 { + return proxy_Direct + } + + proxyURL, err := url.Parse(allProxy) + if err != nil { + return proxy_Direct + } + proxy, err := proxy_FromURL(proxyURL, proxy_Direct) + if err != nil { + return proxy_Direct + } + + noProxy := proxy_noProxyEnv.Get() + if len(noProxy) == 0 { + return proxy + } + + perHost := proxy_NewPerHost(proxy, proxy_Direct) + perHost.AddFromString(noProxy) + return perHost +} + +// proxySchemes is a map from URL schemes to a function that creates a Dialer +// from a URL with such a scheme. +var proxy_proxySchemes map[string]func(*url.URL, proxy_Dialer) (proxy_Dialer, error) + +// RegisterDialerType takes a URL scheme and a function to generate Dialers from +// a URL with that scheme and a forwarding Dialer. Registered schemes are used +// by FromURL. +func proxy_RegisterDialerType(scheme string, f func(*url.URL, proxy_Dialer) (proxy_Dialer, error)) { + if proxy_proxySchemes == nil { + proxy_proxySchemes = make(map[string]func(*url.URL, proxy_Dialer) (proxy_Dialer, error)) + } + proxy_proxySchemes[scheme] = f +} + +// FromURL returns a Dialer given a URL specification and an underlying +// Dialer for it to make network requests. +func proxy_FromURL(u *url.URL, forward proxy_Dialer) (proxy_Dialer, error) { + var auth *proxy_Auth + if u.User != nil { + auth = new(proxy_Auth) + auth.User = u.User.Username() + if p, ok := u.User.Password(); ok { + auth.Password = p + } + } + + switch u.Scheme { + case "socks5": + return proxy_SOCKS5("tcp", u.Host, auth, forward) + } + + // If the scheme doesn't match any of the built-in schemes, see if it + // was registered by another package. + if proxy_proxySchemes != nil { + if f, ok := proxy_proxySchemes[u.Scheme]; ok { + return f(u, forward) + } + } + + return nil, errors.New("proxy: unknown scheme: " + u.Scheme) +} + +var ( + proxy_allProxyEnv = &proxy_envOnce{ + names: []string{"ALL_PROXY", "all_proxy"}, + } + proxy_noProxyEnv = &proxy_envOnce{ + names: []string{"NO_PROXY", "no_proxy"}, + } +) + +// envOnce looks up an environment variable (optionally by multiple +// names) once. It mitigates expensive lookups on some platforms +// (e.g. Windows). +// (Borrowed from net/http/transport.go) +type proxy_envOnce struct { + names []string + once sync.Once + val string +} + +func (e *proxy_envOnce) Get() string { + e.once.Do(e.init) + return e.val +} + +func (e *proxy_envOnce) init() { + for _, n := range e.names { + e.val = os.Getenv(n) + if e.val != "" { + return + } + } +} + +// SOCKS5 returns a Dialer that makes SOCKSv5 connections to the given address +// with an optional username and password. See RFC 1928 and RFC 1929. +func proxy_SOCKS5(network, addr string, auth *proxy_Auth, forward proxy_Dialer) (proxy_Dialer, error) { + s := &proxy_socks5{ + network: network, + addr: addr, + forward: forward, + } + if auth != nil { + s.user = auth.User + s.password = auth.Password + } + + return s, nil +} + +type proxy_socks5 struct { + user, password string + network, addr string + forward proxy_Dialer +} + +const proxy_socks5Version = 5 + +const ( + proxy_socks5AuthNone = 0 + proxy_socks5AuthPassword = 2 +) + +const proxy_socks5Connect = 1 + +const ( + proxy_socks5IP4 = 1 + proxy_socks5Domain = 3 + proxy_socks5IP6 = 4 +) + +var proxy_socks5Errors = []string{ + "", + "general failure", + "connection forbidden", + "network unreachable", + "host unreachable", + "connection refused", + "TTL expired", + "command not supported", + "address type not supported", +} + +// Dial connects to the address addr on the given network via the SOCKS5 proxy. +func (s *proxy_socks5) Dial(network, addr string) (net.Conn, error) { + switch network { + case "tcp", "tcp6", "tcp4": + default: + return nil, errors.New("proxy: no support for SOCKS5 proxy connections of type " + network) + } + + conn, err := s.forward.Dial(s.network, s.addr) + if err != nil { + return nil, err + } + if err := s.connect(conn, addr); err != nil { + conn.Close() + return nil, err + } + return conn, nil +} + +// connect takes an existing connection to a socks5 proxy server, +// and commands the server to extend that connection to target, +// which must be a canonical address with a host and port. +func (s *proxy_socks5) connect(conn net.Conn, target string) error { + host, portStr, err := net.SplitHostPort(target) + if err != nil { + return err + } + + port, err := strconv.Atoi(portStr) + if err != nil { + return errors.New("proxy: failed to parse port number: " + portStr) + } + if port < 1 || port > 0xffff { + return errors.New("proxy: port number out of range: " + portStr) + } + + // the size here is just an estimate + buf := make([]byte, 0, 6+len(host)) + + buf = append(buf, proxy_socks5Version) + if len(s.user) > 0 && len(s.user) < 256 && len(s.password) < 256 { + buf = append(buf, 2 /* num auth methods */, proxy_socks5AuthNone, proxy_socks5AuthPassword) + } else { + buf = append(buf, 1 /* num auth methods */, proxy_socks5AuthNone) + } + + if _, err := conn.Write(buf); err != nil { + return errors.New("proxy: failed to write greeting to SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + + if _, err := io.ReadFull(conn, buf[:2]); err != nil { + return errors.New("proxy: failed to read greeting from SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + if buf[0] != 5 { + return errors.New("proxy: SOCKS5 proxy at " + s.addr + " has unexpected version " + strconv.Itoa(int(buf[0]))) + } + if buf[1] == 0xff { + return errors.New("proxy: SOCKS5 proxy at " + s.addr + " requires authentication") + } + + // See RFC 1929 + if buf[1] == proxy_socks5AuthPassword { + buf = buf[:0] + buf = append(buf, 1 /* password protocol version */) + buf = append(buf, uint8(len(s.user))) + buf = append(buf, s.user...) + buf = append(buf, uint8(len(s.password))) + buf = append(buf, s.password...) + + if _, err := conn.Write(buf); err != nil { + return errors.New("proxy: failed to write authentication request to SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + + if _, err := io.ReadFull(conn, buf[:2]); err != nil { + return errors.New("proxy: failed to read authentication reply from SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + + if buf[1] != 0 { + return errors.New("proxy: SOCKS5 proxy at " + s.addr + " rejected username/password") + } + } + + buf = buf[:0] + buf = append(buf, proxy_socks5Version, proxy_socks5Connect, 0 /* reserved */) + + if ip := net.ParseIP(host); ip != nil { + if ip4 := ip.To4(); ip4 != nil { + buf = append(buf, proxy_socks5IP4) + ip = ip4 + } else { + buf = append(buf, proxy_socks5IP6) + } + buf = append(buf, ip...) + } else { + if len(host) > 255 { + return errors.New("proxy: destination host name too long: " + host) + } + buf = append(buf, proxy_socks5Domain) + buf = append(buf, byte(len(host))) + buf = append(buf, host...) + } + buf = append(buf, byte(port>>8), byte(port)) + + if _, err := conn.Write(buf); err != nil { + return errors.New("proxy: failed to write connect request to SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + + if _, err := io.ReadFull(conn, buf[:4]); err != nil { + return errors.New("proxy: failed to read connect reply from SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + + failure := "unknown error" + if int(buf[1]) < len(proxy_socks5Errors) { + failure = proxy_socks5Errors[buf[1]] + } + + if len(failure) > 0 { + return errors.New("proxy: SOCKS5 proxy at " + s.addr + " failed to connect: " + failure) + } + + bytesToDiscard := 0 + switch buf[3] { + case proxy_socks5IP4: + bytesToDiscard = net.IPv4len + case proxy_socks5IP6: + bytesToDiscard = net.IPv6len + case proxy_socks5Domain: + _, err := io.ReadFull(conn, buf[:1]) + if err != nil { + return errors.New("proxy: failed to read domain length from SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + bytesToDiscard = int(buf[0]) + default: + return errors.New("proxy: got unknown address type " + strconv.Itoa(int(buf[3])) + " from SOCKS5 proxy at " + s.addr) + } + + if cap(buf) < bytesToDiscard { + buf = make([]byte, bytesToDiscard) + } else { + buf = buf[:bytesToDiscard] + } + if _, err := io.ReadFull(conn, buf); err != nil { + return errors.New("proxy: failed to read address from SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + + // Also need to discard the port number + if _, err := io.ReadFull(conn, buf[:2]); err != nil { + return errors.New("proxy: failed to read port from SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + + return nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 62feb87cf3..58fc706b38 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -843,6 +843,9 @@ github.com/gorilla/mux # github.com/gorilla/schema v1.4.1 ## explicit; go 1.20 github.com/gorilla/schema +# github.com/gorilla/websocket v1.5.3 +## explicit; go 1.12 +github.com/gorilla/websocket # github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 ## explicit; go 1.14 github.com/grpc-ecosystem/go-grpc-middleware