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
This commit is contained in:
Pascal Bleser
2025-09-24 09:36:45 +02:00
parent 4289bfecdc
commit ed11980784
43 changed files with 4693 additions and 95 deletions

1
go.mod
View File

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

2
go.sum
View File

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

View File

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

View File

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

View File

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

13
pkg/jmap/jmap_api_ws.go Normal file
View File

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

View File

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

View File

@@ -30,6 +30,12 @@ const (
JmapErrorAccountNotFound
JmapErrorAccountNotSupportedByMethod
JmapErrorAccountReadOnly
JmapErrorFailedToEstablishWssConnection
JmapErrorWssConnectionResponseMissingJmapSubprotocol
JmapErrorWssFailedToSendWebSocketPushEnable
JmapErrorWssFailedToSendWebSocketPushDisable
JmapErrorWssFailedToClose
JmapErrorWssFailedToRetrieveSession
)
var (

View File

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

View File

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

View File

@@ -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 parts 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 parts 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 its 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 its 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, its 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 its null.
// The value of the `Content-Id` header field of the part, if present; otherwise its 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"`

View File

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

View File

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

View File

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

View File

@@ -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<string>()
const unresolvedExampleReferences = new Set<string>()
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<string>(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}`)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- core-js
- protobufjs

25
vendor/github.com/gorilla/websocket/.gitignore generated vendored Normal file
View File

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

9
vendor/github.com/gorilla/websocket/AUTHORS generated vendored Normal file
View File

@@ -0,0 +1,9 @@
# This is the official list of Gorilla WebSocket authors for copyright
# purposes.
#
# Please keep the list sorted.
Gary Burd <gary@beagledreams.com>
Google LLC (https://opensource.google.com/)
Joachim Bauch <mail@joachim-bauch.de>

22
vendor/github.com/gorilla/websocket/LICENSE generated vendored Normal file
View File

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

33
vendor/github.com/gorilla/websocket/README.md generated vendored Normal file
View File

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

434
vendor/github.com/gorilla/websocket/client.go generated vendored Normal file
View File

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

148
vendor/github.com/gorilla/websocket/compression.go generated vendored Normal file
View File

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

1238
vendor/github.com/gorilla/websocket/conn.go generated vendored Normal file
View File

File diff suppressed because it is too large Load Diff

227
vendor/github.com/gorilla/websocket/doc.go generated vendored Normal file
View File

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

42
vendor/github.com/gorilla/websocket/join.go generated vendored Normal file
View File

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

60
vendor/github.com/gorilla/websocket/json.go generated vendored Normal file
View File

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

55
vendor/github.com/gorilla/websocket/mask.go generated vendored Normal file
View File

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

16
vendor/github.com/gorilla/websocket/mask_safe.go generated vendored Normal file
View File

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

102
vendor/github.com/gorilla/websocket/prepared.go generated vendored Normal file
View File

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

77
vendor/github.com/gorilla/websocket/proxy.go generated vendored Normal file
View File

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

365
vendor/github.com/gorilla/websocket/server.go generated vendored Normal file
View File

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

21
vendor/github.com/gorilla/websocket/tls_handshake.go generated vendored Normal file
View File

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

View File

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

298
vendor/github.com/gorilla/websocket/util.go generated vendored Normal file
View File

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

473
vendor/github.com/gorilla/websocket/x_net_proxy.go generated vendored Normal file
View File

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

3
vendor/modules.txt vendored
View File

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