From 035f2a408b4a6cf73acbf168dff5d16198930ee2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 12 Jul 2024 20:09:07 +0300 Subject: [PATCH] Add support for authenticated media --- CHANGELOG.md | 8 ++++++ config/bridge.go | 3 ++ config/upgrade.go | 8 ++++-- example-config.yaml | 10 +++++-- go.mod | 4 +-- go.sum | 8 +++--- main.go | 4 +++ portal.go | 70 +++++++++++++++++++++++++++++++++++++++++---- 8 files changed, 99 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1192fce..592e1b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # v0.7.0 (unreleased) * Bumped minimum Go version to 1.21. +* Added support for Matrix v1.11 authenticated media. + * This also changes how avatars are sent to Discord when using relay webhooks. + To keep avatars working, you must configure `public_address` in the *bridge* + section of the config and proxy `/mautrix-discord/avatar/*` from that + address to the bridge. +* Added `create-portal` command to create individual portals bypassing the + bridging mode. When used in combination with the `if-portal-exists` bridging + mode, this can be used to bridge individual channels from a guild. * Changed how direct media access works to make it compatible with Discord's signed URL requirement. The new system must be enabled manually, see [docs](https://docs.mau.fi/bridges/go/discord/direct-media.html) for info. diff --git a/config/bridge.go b/config/bridge.go index 47571e6..dd6ad4e 100644 --- a/config/bridge.go +++ b/config/bridge.go @@ -37,6 +37,9 @@ type BridgeConfig struct { PortalMessageBuffer int `yaml:"portal_message_buffer"` + PublicAddress string `yaml:"public_address"` + AvatarProxyKey string `yaml:"avatar_proxy_key"` + DeliveryReceipts bool `yaml:"delivery_receipts"` MessageStatusEvents bool `yaml:"message_status_events"` MessageErrorNotices bool `yaml:"message_error_notices"` diff --git a/config/upgrade.go b/config/upgrade.go index 9066af5..f84d9ef 100644 --- a/config/upgrade.go +++ b/config/upgrade.go @@ -26,8 +26,6 @@ import ( func DoUpgrade(helper *up.Helper) { bridgeconfig.Upgrader.DoUpgrade(helper) - helper.Copy(up.Str|up.Null, "homeserver", "public_address") - helper.Copy(up.Str, "bridge", "username_template") helper.Copy(up.Str, "bridge", "displayname_template") helper.Copy(up.Str, "bridge", "channel_name_template") @@ -42,6 +40,12 @@ func DoUpgrade(helper *up.Helper) { helper.Copy(up.Str, "bridge", "private_chat_portal_meta") } helper.Copy(up.Int, "bridge", "startup_private_channel_create_limit") + helper.Copy(up.Str|up.Null, "bridge", "public_address") + if apkey, ok := helper.Get(up.Str, "bridge", "avatar_proxy_key"); !ok || apkey == "generate" { + helper.Set(up.Str, random.String(32), "bridge", "avatar_proxy_key") + } else { + helper.Copy(up.Str, "bridge", "avatar_proxy_key") + } helper.Copy(up.Int, "bridge", "portal_message_buffer") helper.Copy(up.Bool, "bridge", "delivery_receipts") helper.Copy(up.Bool, "bridge", "message_status_events") diff --git a/example-config.yaml b/example-config.yaml index 9e2dc4c..7fca846 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -2,9 +2,6 @@ homeserver: # The address that this appservice can use to connect to the homeserver. address: https://matrix.example.com - # Publicly accessible base URL for media, used for avatars in relay mode. - # If not set, the connection address above will be used. - public_address: null # The domain of the homeserver (also known as server_name, used for MXIDs, etc). domain: example.com @@ -113,6 +110,13 @@ bridge: # If set to `never`, DM rooms will never have names and avatars set. private_chat_portal_meta: default + # Publicly accessible base URL that Discord can use to reach the bridge, used for avatars in relay mode. + # If not set, avatars will not be bridged. Only the /mautrix-discord/avatar/{server}/{id}/{hash} endpoint is used on this address. + # This should not have a trailing slash, the endpoint above will be appended to the provided address. + public_address: null + # A random key used to sign the avatar URLs. The bridge will only accept requests with a valid signature. + avatar_proxy_key: generate + portal_message_buffer: 128 # Number of private channel portals to create on bridge startup. diff --git a/go.mod b/go.mod index 323d1f2..69c4580 100644 --- a/go.mod +++ b/go.mod @@ -14,11 +14,11 @@ require ( github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/stretchr/testify v1.8.4 github.com/yuin/goldmark v1.6.0 - go.mau.fi/util v0.2.1 + go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 golang.org/x/sync v0.5.0 maunium.net/go/maulogger/v2 v2.4.1 - maunium.net/go/mautrix v0.16.3-0.20240218195727-4ceb1123b660 + maunium.net/go/mautrix v0.16.3-0.20240712164054-e6046fbf432c ) require ( diff --git a/go.sum b/go.sum index e2de933..0b42764 100644 --- a/go.sum +++ b/go.sum @@ -45,8 +45,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68= github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.mau.fi/util v0.2.1 h1:eazulhFE/UmjOFtPrGg6zkF5YfAyiDzQb8ihLMbsPWw= -go.mau.fi/util v0.2.1/go.mod h1:MjlzCQEMzJ+G8RsPawHzpLB8rwTo3aPIjG5FzBvQT/c= +go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb h1:Is+6vDKgINRy9KHodvi7NElxoDaWA8sc2S3cF3+QWjs= +go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb/go.mod h1:tiBX6nxVSOjU89jVQ7wBh3P8KjM26Lv1k7/I5QdSvBw= go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto= go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= @@ -72,5 +72,5 @@ maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8= maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho= -maunium.net/go/mautrix v0.16.3-0.20240218195727-4ceb1123b660 h1:ZPg1i0wsyEs5ee7z6Gn4mSRsq9BtDV//rfYTGs82l8c= -maunium.net/go/mautrix v0.16.3-0.20240218195727-4ceb1123b660/go.mod h1:YL4l4rZB46/vj/ifRMEjcibbvHjgxHftOF1SgmruLu4= +maunium.net/go/mautrix v0.16.3-0.20240712164054-e6046fbf432c h1:LHjqti3fFzrC8LXkkxxKYlLbuI/CJcwa2JN4Ppg2GK0= +maunium.net/go/mautrix v0.16.3-0.20240712164054-e6046fbf432c/go.mod h1:gCgLw/4c1a8QsiOWTdUdXlt5cYdE0rJ9wLeZQKPD58Q= diff --git a/main.go b/main.go index f3b51d3..b974522 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ package main import ( _ "embed" + "net/http" "sync" "go.mau.fi/util/configupgrade" @@ -105,6 +106,9 @@ func (br *DiscordBridge) Start() { if br.Config.Bridge.Provisioning.SharedSecret != "disable" { br.provisioning = newProvisioningAPI(br) } + if br.Config.Bridge.PublicAddress != "" { + br.AS.Router.HandleFunc("/mautrix-discord/avatar/{server}/{mediaID}/{checksum}", br.serveMediaProxy).Methods(http.MethodGet) + } br.DMA = newDirectMediaAPI(br) br.WaitWebsocketConnected() go br.startUsers() diff --git a/portal.go b/portal.go index 5ff7a2a..a86fa64 100644 --- a/portal.go +++ b/portal.go @@ -3,9 +3,13 @@ package main import ( "bytes" "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" "encoding/json" "errors" "fmt" + "io" "net/http" "reflect" "regexp" @@ -17,6 +21,7 @@ import ( "github.com/bwmarrin/discordgo" "github.com/gabriel-vasile/mimetype" + "github.com/gorilla/mux" "github.com/rs/zerolog" "go.mau.fi/util/exsync" "go.mau.fi/util/variationselector" @@ -1370,6 +1375,64 @@ func (portal *Portal) sendMessageMetrics(evt *event.Event, err error, part strin } } +func (br *DiscordBridge) serveMediaProxy(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + mxc := id.ContentURI{ + Homeserver: vars["server"], + FileID: vars["mediaID"], + } + checksum, err := base64.RawURLEncoding.DecodeString(vars["checksum"]) + if err != nil || len(checksum) != 32 { + w.WriteHeader(http.StatusNotFound) + return + } + _, expectedChecksum := br.hashMediaProxyURL(mxc) + if !hmac.Equal(checksum, expectedChecksum) { + w.WriteHeader(http.StatusNotFound) + return + } + reader, err := br.Bot.Download(mxc) + if err != nil { + br.ZLog.Warn().Err(err).Msg("Failed to download media to proxy") + w.WriteHeader(http.StatusInternalServerError) + return + } + buf := make([]byte, 32*1024) + n, err := io.ReadFull(reader, buf) + if err != nil && (!errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF)) { + br.ZLog.Warn().Err(err).Msg("Failed to read first part of media to proxy") + w.WriteHeader(http.StatusBadGateway) + return + } + w.Header().Add("Content-Type", http.DetectContentType(buf[:n])) + if n < len(buf) { + w.Header().Add("Content-Length", strconv.Itoa(n)) + } + w.WriteHeader(http.StatusOK) + _, err = w.Write(buf[:n]) + if err != nil { + return + } + if n >= len(buf) { + _, _ = io.CopyBuffer(w, reader, buf) + } +} + +func (br *DiscordBridge) hashMediaProxyURL(mxc id.ContentURI) (string, []byte) { + path := fmt.Sprintf("/mautrix-discord/avatar/%s/%s/", mxc.Homeserver, mxc.FileID) + checksum := hmac.New(sha256.New, []byte(br.Config.Bridge.AvatarProxyKey)) + checksum.Write([]byte(path)) + return path, checksum.Sum(nil) +} + +func (br *DiscordBridge) makeMediaProxyURL(mxc id.ContentURI) string { + if br.Config.Bridge.PublicAddress == "" { + return "" + } + path, checksum := br.hashMediaProxyURL(mxc) + return br.Config.Bridge.PublicAddress + path + base64.RawURLEncoding.EncodeToString(checksum) +} + func (portal *Portal) getRelayUserMeta(sender *User) (name, avatarURL string) { member := portal.bridge.StateStore.GetMember(portal.MXID, sender.MXID) name = member.Displayname @@ -1377,11 +1440,8 @@ func (portal *Portal) getRelayUserMeta(sender *User) (name, avatarURL string) { name = sender.MXID.String() } mxc := member.AvatarURL.ParseOrIgnore() - if !mxc.IsEmpty() { - avatarURL = mautrix.BuildURL( - portal.bridge.PublicHSAddress, - "_matrix", "media", "v3", "download", mxc.Homeserver, mxc.FileID, - ).String() + if !mxc.IsEmpty() && portal.bridge.Config.Bridge.PublicAddress != "" { + avatarURL = portal.bridge.makeMediaProxyURL(mxc) } return }